Files
com.gregkilmer.www/services/site/src/lib/library.ts
T
Claude 151bed230a Match the site to the approved designs
Denser star field, the page nebula, a favicon package, the filament banner
without its box, the About portrait, Kashi and tea photos and Scorpius, the
Store's own header, a header logo glow, a resume PDF that fills its page,
one divider on the Library and Shelf, star-chart art for coverless
favorites, Shelf Players, Play time and blog filters with a Show all fold,
make kind pills on craft pages, and full-width closing bands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:33:15 +00:00

269 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Library and The Shelf: how items group, filter and sort. The pages
* filter on the server from the query string (so they work without JS) and a
* small script repeats the same rules live in the browser.
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
import { formatDate } from "./site.ts";
export type Item = Note<"library-item">;
export type Game = Note<"board-game">;
// ---------------------------------------------------------------- Library
/** The list's three groups. Dropped items are left out. */
export const sections = [
{ key: "now", label: "Now", states: ["now"] },
{ key: "next", label: "Up next", states: ["up next"] },
{ key: "done", label: "Finished", states: ["finished", "caught up", "played"] },
] as const;
export type SectionKey = (typeof sections)[number]["key"];
export const sectionOf = (item: Item): SectionKey | undefined =>
sections.find((s) => (s.states as readonly string[]).includes(item.data.state))?.key;
/** The Type filter: media gathered the way the rail lists them. */
export const mediaGroups = [
{ key: "books", label: "Books", media: ["book", "light novel"] },
{ key: "serials", label: "Webnovels, manga and manhwa", media: ["webnovel", "manga", "manhwa"] },
{ key: "games", label: "Video games", media: ["video game"] },
{ key: "screen", label: "Anime and TV", media: ["anime", "TV"] },
{ key: "movies", label: "Movies", media: ["movie"] },
] as const;
export type MediaKey = (typeof mediaGroups)[number]["key"];
export const mediaGroupOf = (item: Item): MediaKey | undefined =>
mediaGroups.find((g) => (g.media as readonly string[]).includes(item.data.medium))?.key;
/** The short medium label on the right of a row. */
export const mediumLabel = (medium: string) => (medium === "video game" ? "Game" : medium === "TV" ? "TV" : medium);
/** How Finished is ordered: favorites, then items with a verdict, then A to Z. */
export function byStanding(a: Item, b: Item): number {
return (
Number(!!b.data.favorite) - Number(!!a.data.favorite) ||
Number(!!b.data.verdict) - Number(!!a.data.verdict) ||
a.title.localeCompare(b.title)
);
}
/** Progress PersonalReader syncs onto a linked item (see withSynced in content.ts). */
interface ReaderSynced {
chapterProgress?: { chapter?: number; readAt?: string };
bookProgress?: { percent?: number; readAt?: string };
}
/** "Chapter 42, read Oct 3, 2026" or "62% read", from PersonalReader; undefined when nothing is synced. */
export function readerProgress(item: Item): string | undefined {
const d = item.data as ReaderSynced;
const c = d.chapterProgress;
const b = d.bookProgress;
const what = c?.chapter ? `Chapter ${c.chapter}` : b && typeof b.percent === "number" && b.percent > 0 ? `${b.percent}% read` : undefined;
if (!what) return undefined;
const at = (c?.chapter ? c.readAt : b?.readAt)?.slice(0, 10);
return at ? `${what}, read ${formatDate(at)}` : what;
}
/** The row's details line. The note's own `progress` wins over the synced one. */
export const itemDetails = (item: Item): (string | number)[] => {
const d = item.data;
return [d.creator, d.year, d.platform, d.progress || readerProgress(item), d.group].filter((x): x is string | number => x !== undefined && x !== "");
};
export interface LibraryFilter {
type?: MediaKey;
q: string;
show: Set<SectionKey>;
favorites: boolean;
blog: boolean;
}
export function libraryFilter(params: URLSearchParams): LibraryFilter {
const type = mediaGroups.find((g) => g.key === params.get("type"))?.key;
// A submitted form sends `f`; before that every group shows.
const submitted = params.has("f");
const show = new Set<SectionKey>(
submitted ? (params.getAll("show").filter((v) => sections.some((s) => s.key === v)) as SectionKey[]) : sections.map((s) => s.key),
);
return {
type,
q: (params.get("q") ?? "").trim().toLowerCase(),
show,
favorites: params.get("fav") === "1",
blog: params.get("blog") === "1",
};
}
/** Text the search matches against. Kept in step with the row's visible text. */
export const searchText = (parts: (string | number | undefined)[]) =>
parts
.filter((p) => p !== undefined && p !== "")
.join(" ")
.toLowerCase();
/**
* The favorites constellation: favorites with a cover, taken in turn from each
* medium so the strip is not all one kind.
*/
export function constellation(items: Item[], max = 7): Item[] {
const favs = items.filter((i) => i.data.favorite && i.data.cover).sort(byStanding);
const byMedium = new Map<string, Item[]>();
for (const i of favs) byMedium.set(i.data.medium, [...(byMedium.get(i.data.medium) ?? []), i]);
const order = mediaGroups.flatMap((g) => g.media as readonly string[]);
const queues = [...byMedium.entries()].sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([, q]) => q);
const out: Item[] = [];
while (out.length < max && queues.some((q) => q.length)) {
for (const q of queues) {
const next = q.shift();
if (next && out.length < max) out.push(next);
}
}
return out;
}
// ---------------------------------------------------------------- Shelf
export const sorts = [
{ key: "played", label: "Last played" },
{ key: "az", label: "A to Z" },
{ key: "added", label: "Recently added" },
{ key: "time", label: "Play time" },
] as const;
export type SortKey = (typeof sorts)[number]["key"];
export function sortGames(games: Game[], sort: SortKey): Game[] {
const az = (a: Game, b: Game) => a.title.localeCompare(b.title);
const desc = (x?: string, y?: string) => (y ?? "").localeCompare(x ?? "");
const cmp: Record<SortKey, (a: Game, b: Game) => number> = {
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
az,
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
// Shortest first; games BoardGameGeek has not timed go last.
time: (a, b) => (minutesOf(a) ?? Infinity) - (minutesOf(b) ?? Infinity) || az(a, b),
};
return [...games].sort(cmp[sort]);
}
export interface ShelfFilter {
q: string;
plays: Set<string>;
owned: boolean;
want: boolean;
favorites: boolean;
blog: boolean;
players: PlayersKey | "";
time: TimeKey | "";
sort: SortKey;
}
/** The Players pills: who a game has to seat. */
export const playerOptions = [
{ key: "1", label: "1", lo: 1, hi: 1 },
{ key: "2", label: "2", lo: 2, hi: 2 },
{ key: "3-4", label: "3–4", lo: 3, hi: 4 },
{ key: "5+", label: "5+", lo: 5, hi: Infinity },
] as const;
export type PlayersKey = (typeof playerOptions)[number]["key"];
/** The Play time pills, in minutes. */
export const timeOptions = [
{ key: "short", label: "Under 30 min", lo: 0, hi: 29 },
{ key: "mid", label: "30–60", lo: 30, hi: 60 },
{ key: "long", label: "60+", lo: 61, hi: Infinity },
] as const;
export type TimeKey = (typeof timeOptions)[number]["key"];
export function shelfFilter(params: URLSearchParams): ShelfFilter {
const submitted = params.has("f");
return {
q: (params.get("q") ?? "").trim().toLowerCase(),
plays: new Set(params.getAll("plays").map((p) => p.toLowerCase())),
owned: submitted ? params.get("owned") === "1" : true,
want: params.get("want") === "1",
favorites: params.get("fav") === "1",
blog: params.get("blog") === "1",
players: playerOptions.find((o) => o.key === params.get("players"))?.key ?? "",
time: timeOptions.find((o) => o.key === params.get("time"))?.key ?? "",
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
};
}
/**
* Owned and Want to play add games to the list (either one); with neither
* ticked, every game shows. Favorites only, Has a blog post, How it plays,
* Players and Play time narrow it. A game BoardGameGeek has not synced yet
* drops out while Players or Play time is set.
*/
export function gameShows(g: Game, f: ShelfFilter, text: string, hasPost = false): boolean {
const owned = !!g.data.owned;
const want = !!g.data.wantToPlay;
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
if (f.favorites && !g.data.favorite) return false;
if (f.blog && !hasPost) return false;
if (f.players && !seats(g, playerOptions.find((o) => o.key === f.players)!)) return false;
if (f.time && !lasts(g, timeOptions.find((o) => o.key === f.time)!)) return false;
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
return !f.q || text.includes(f.q);
}
/** Values BoardGameGeek syncs onto a game (see withSynced in content.ts). */
interface GameSynced {
minPlayers?: number;
maxPlayers?: number;
playTime?: number;
minPlayTime?: number;
maxPlayTime?: number;
}
/** The player range BoardGameGeek gives, when it has synced. */
export function playersOf(g: Game): [number, number] | undefined {
const d = g.data as GameSynced;
const lo = d.minPlayers || d.maxPlayers;
const hi = d.maxPlayers || d.minPlayers;
return lo && hi ? [lo, hi] : undefined;
}
/** A game's usual length in minutes: the listed play time, else the longest. */
export function minutesOf(g: Game): number | undefined {
const d = g.data as GameSynced;
return d.playTime || d.maxPlayTime || d.minPlayTime || undefined;
}
/** Whether a game plays at some count in the option's range. */
const seats = (g: Game, o: { lo: number; hi: number }) => {
const r = playersOf(g);
return !!r && r[0] <= o.hi && r[1] >= o.lo;
};
const lasts = (g: Game, o: { lo: number; hi: number }) => {
const m = minutesOf(g);
return m !== undefined && m >= o.lo && m <= o.hi;
};
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
export function gamePlayers(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = range(d.minPlayers, d.maxPlayers);
return r ? `${r} ${r === "1" ? "player" : "players"}` : undefined;
}
/** "60–90 min", "45 min" or undefined. */
export function gamePlayTime(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = d.minPlayTime && d.maxPlayTime ? range(d.minPlayTime, d.maxPlayTime) : range(d.playTime);
return r ? `${r} min` : undefined;
}
/** Every "how it plays" value on the shelf, A to Z. */
export const playStyles = (games: Game[]) =>
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));
/** Statuses about a board game: what is on the table now. */
export const onTheTable = (s: SiteIndex) =>
s
.statuses()
.map((status) => ({ status, game: s.byName(status.data.subject) }))
.filter((x): x is { status: Note<"status">; game: Game } => x.game?.type === "board-game");