/** * 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; 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( 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(); 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 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; 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");