From aab41eb450cf08cc54d5980318b2c43ce2a856bb Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:15 +0100 Subject: [PATCH 01/19] feat(env): add the NEXT_PUBLIC_GAMES_LIBRARY flag Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 ++++++++ lib/env.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ lib/env.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/.env.example b/.env.example index ca1ca6f6..5f074e3c 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,14 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # it on only together with enable_confirmations = true and an SMTP provider. NEXT_PUBLIC_AUTH_EMAIL_DELIVERY=off +# Optional. on or off; unset or empty means off. on adds a GAMES tab to +# /watchlist, /upcoming and /watched and accepts ?type=game there (spec 0027). +# Production leaves it unset until people have a way to find games; set it on +# in .env.local and in Vercel Preview. Public on purpose: it only decides +# whether the tab renders, never who may read what. Inlined at build time, so a +# change needs a redeploy. +NEXT_PUBLIC_GAMES_LIBRARY=on + # --------------------------------------------------------------------------- # Deployment (set by the host, not by you) # --------------------------------------------------------------------------- diff --git a/lib/env.test.ts b/lib/env.test.ts index 4b984d36..b578c787 100644 --- a/lib/env.test.ts +++ b/lib/env.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + gamesLibraryEnabled, getAuthEmailDelivery, + getGamesLibrary, getPublicEnv, publicEnvProblems, } from "@/lib/env"; @@ -94,3 +96,39 @@ describe("getAuthEmailDelivery", () => { expect(() => getAuthEmailDelivery()).not.toThrow("yes-please"); }); }); + +/** + * covers: spec 0027, AC-1 + * + * The games library flag: unset means off, the library throws on a typo, + * and the navbar reads a typo as off so no page outside the library breaks. + */ +describe("getGamesLibrary and gamesLibraryEnabled", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([undefined, ""])("reads %j as off", (value) => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", value); + expect(getGamesLibrary()).toBe("off"); + expect(gamesLibraryEnabled()).toBe(false); + }); + + it.each(["on", "off"] as const)("returns %s as given", (value) => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", value); + expect(getGamesLibrary()).toBe(value); + expect(gamesLibraryEnabled()).toBe(value === "on"); + }); + + it("throws on anything else in the library, naming only the variable", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "yes-please"); + + expect(() => getGamesLibrary()).toThrow("NEXT_PUBLIC_GAMES_LIBRARY"); + expect(() => getGamesLibrary()).not.toThrow("yes-please"); + }); + + it("reads anything else as off in the navbar, without throwing", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "yes-please"); + expect(gamesLibraryEnabled()).toBe(false); + }); +}); diff --git a/lib/env.ts b/lib/env.ts index 0d0fb931..a55ffb20 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -152,3 +152,50 @@ export function getAuthEmailDelivery(): AuthEmailDelivery { "Invalid public environment configuration. NEXT_PUBLIC_AUTH_EMAIL_DELIVERY must be on or off.", ); } + +/** Whether the games library tab is switched on for this deployment. */ +export type GamesLibrary = "on" | "off"; + +/** The raw flag, or null for a value that is neither `on` nor `off`. */ +function readGamesLibrary(): GamesLibrary | null { + const value = process.env.NEXT_PUBLIC_GAMES_LIBRARY; + + if (value === undefined || value === "" || value === "off") return "off"; + if (value === "on") return "on"; + return null; +} + +/** + * Reads `NEXT_PUBLIC_GAMES_LIBRARY`, the switch for the GAMES tab on the + * library pages (spec 0027, AC-1). Production leaves it unset until a games + * browse page or game search gives people a way to find games, so unset or + * empty means `off`. + * + * Only `LibrarySection` calls this, so a misconfigured deploy shows the + * library error boundary there instead of guessing (AC-5). Written like + * `getAuthEmailDelivery()`, apart from `getPublicEnv()`, so a build with no + * Supabase values still works. Read as a literal `process.env` access, so + * Next.js inlines it at build time and a change needs a redeploy. + * + * @returns `on` or `off`. + * @throws If the variable holds anything else. The message names the variable + * and never its value. + */ +export function getGamesLibrary(): GamesLibrary { + const value = readGamesLibrary(); + if (value !== null) return value; + + throw new Error( + "Invalid public environment configuration. NEXT_PUBLIC_GAMES_LIBRARY must be on or off.", + ); +} + +/** + * The same flag for the navbar, which renders on every page (spec 0027, + * AC-1, AC-3). An invalid value counts as off and never throws, so a bad + * value cannot take a page outside the library down: the `publicEnvProblems()` + * and `getPublicEnv()` split. + */ +export function gamesLibraryEnabled(): boolean { + return readGamesLibrary() === "on"; +} From b1d6a80785a5e2f2858b8dc65ea4057492015182 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:15 +0100 Subject: [PATCH 02/19] feat(catalog): add LibraryType so game reaches only the library pages Co-Authored-By: Claude Opus 5.5 --- lib/catalog/media-type.test.ts | 69 +++++++++++++++++++++++++++++ lib/catalog/media-type.ts | 81 +++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/lib/catalog/media-type.test.ts b/lib/catalog/media-type.test.ts index 323fc14b..04663961 100644 --- a/lib/catalog/media-type.test.ts +++ b/lib/catalog/media-type.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { + gameHref, + isLibraryPath, isTypedPath, + libraryTypeForLocation, mediaTypeForLocation, + parseLibraryTypeParam, parseMediaTypeParam, titleHref, typedHref, @@ -92,3 +96,68 @@ describe("titleHref", () => { expect(titleHref("tv", 1396)).toBe("/shows/1396"); }); }); + +/** covers: spec 0027, AC-2 to AC-4 */ +describe("parseLibraryTypeParam", () => { + it("accepts game only while the games library is on", () => { + expect(parseLibraryTypeParam("game", true)).toBe("game"); + expect(parseLibraryTypeParam("game", false)).toBeNull(); + }); + + it("reads everything else as parseMediaTypeParam does", () => { + for (const enabled of [true, false]) { + expect(parseLibraryTypeParam(undefined, enabled)).toBe("tv"); + expect(parseLibraryTypeParam("movie", enabled)).toBe("movie"); + expect(parseLibraryTypeParam("", enabled)).toBeNull(); + expect(parseLibraryTypeParam(["game", "game"], enabled)).toBeNull(); + expect(parseLibraryTypeParam("GAME", enabled)).toBeNull(); + } + }); + + it("never lets game into the catalog parser", () => { + expect(parseMediaTypeParam("game")).toBeNull(); + }); +}); + +describe("libraryTypeForLocation", () => { + it("is game on a library page asking for it while the flag is on", () => { + for (const path of ["/watchlist", "/upcoming", "/watched"]) { + expect(libraryTypeForLocation(path, "game", true)).toBe("game"); + } + }); + + it("is never game on /search, a catalog page or with the flag off", () => { + expect(libraryTypeForLocation("/search", "game", true)).toBe("tv"); + expect(libraryTypeForLocation("/movies", "game", true)).toBe("movie"); + expect(libraryTypeForLocation("/watchlist", "game", false)).toBe("tv"); + }); + + it("matches mediaTypeForLocation for the catalogs", () => { + expect(libraryTypeForLocation("/upcoming", "movie", true)).toBe("movie"); + expect(libraryTypeForLocation("/account", null, true)).toBeNull(); + }); +}); + +describe("the library paths", () => { + it("are the three library pages and not /search", () => { + expect(isLibraryPath("/watchlist")).toBe(true); + expect(isLibraryPath("/upcoming")).toBe(true); + expect(isLibraryPath("/watched")).toBe(true); + expect(isLibraryPath("/search")).toBe(false); + expect(isLibraryPath(null)).toBe(false); + }); + + it("take game in typedHref, and /search does not compile with it", () => { + expect(typedHref("/watched", "game", { page: 2 })).toBe( + "/watched?type=game&page=2", + ); + // @ts-expect-error: /search has no games tab until G7. + typedHref("/search", "game"); + }); +}); + +describe("gameHref", () => { + it("opens a game on its game page", () => { + expect(gameHref(1942)).toBe("/games/1942"); + }); +}); diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts index fb36dc31..fa94d73d 100644 --- a/lib/catalog/media-type.ts +++ b/lib/catalog/media-type.ts @@ -3,6 +3,15 @@ import { z } from "zod"; /** The two catalogs: TV shows and movies. */ export type MediaType = "tv" | "movie"; +/** + * What a library page can list: the two catalogs, and games (spec 0027, + * AC-2). Games reach only `/watchlist`, `/upcoming` and `/watched` until a + * games browse page or game search exists, so `MediaType` stays narrow and + * the compiler keeps `game` away from `/search`, the catalog pages, the title + * pages and the proxy. + */ +export type LibraryType = MediaType | "game"; + /** * Each catalog's browse page, which also types every title page below it * (feature 22). The one place these two paths are written as a pair. @@ -26,6 +35,11 @@ export const TYPED_PATHS = [ export type TypedPath = (typeof TYPED_PATHS)[number]; +/** The three library pages, the only typed pages a game tab exists on. */ +const LIBRARY_PATHS = ["/watchlist", "/upcoming", "/watched"] as const; + +export type LibraryPath = (typeof LIBRARY_PATHS)[number]; + /** The `/search` parameters a tab switch keeps; genres and page are dropped. */ const SEARCH_KEPT = ["q", "year", "rating"] as const; @@ -36,6 +50,13 @@ export function isTypedPath(pathname: string | null): pathname is TypedPath { return (TYPED_PATHS as readonly (string | null)[]).includes(pathname); } +/** True for `/watchlist`, `/upcoming` and `/watched` (spec 0027, AC-2). */ +export function isLibraryPath( + pathname: string | null, +): pathname is LibraryPath { + return (LIBRARY_PATHS as readonly (string | null)[]).includes(pathname); +} + /** * True for exactly `tv` or `movie`. Unlike `parseMediaTypeParam`, an absent * value is not `tv`: for a stored value, absence must stay absence. @@ -63,6 +84,23 @@ export function parseMediaTypeParam( return parsed.success ? parsed.data : null; } +/** + * The library pages' `type` parameter (spec 0027, AC-2): `parseMediaTypeParam` + * plus `game`, which only counts while the games library is switched on. With + * it off, `?type=game` is null like any unknown value, so the page shows its + * "doesn't exist" panel. + * + * @param gamesEnabled `getGamesLibrary() === "on"` on the server, + * `gamesLibraryEnabled()` in the navbar. + */ +export function parseLibraryTypeParam( + value: string | string[] | null | undefined, + gamesEnabled: boolean, +): LibraryType | null { + if (value === "game") return gamesEnabled ? "game" : null; + return parseMediaTypeParam(value); +} + /** * A title's own page: `/shows/{id}` or `/movies/{id}`, under its catalog's * browse path (feature 22). The one builder of a title page URL, shared by @@ -72,6 +110,11 @@ export function titleHref(type: MediaType, id: number): string { return `${CATALOG_PATHS[type]}/${id}`; } +/** A game's own page, `/games/{id}`, beside `titleHref` (spec 0027). */ +export function gameHref(id: number): string { + return `/games/${id}`; +} + /** * The media type of the page being viewed, which the tabs light and the * Library links and navbar search carry on (feature 22). The catalog and @@ -96,9 +139,33 @@ export function mediaTypeForLocation( return null; } +/** + * `mediaTypeForLocation` for the controls that may list games: the navbar + * tabs and the Library links (spec 0027, AC-3, AC-4). It is `game` only on a + * library page asking for it while the games library is on, and otherwise + * exactly `mediaTypeForLocation`. + */ +export function libraryTypeForLocation( + pathname: string | null, + typeParam: string | null, + gamesEnabled: boolean, +): LibraryType | null { + if ( + isLibraryPath(pathname) && + parseLibraryTypeParam(typeParam, gamesEnabled) === "game" + ) { + return "game"; + } + return mediaTypeForLocation(pathname, typeParam); +} + +type TypedHrefOptions = { current?: URLSearchParams; page?: number }; + /** * A typed page's URL for one type. `type` is always written, so a copied link - * says which tab it opens, as `/search` already does. + * says which tab it opens, as `/search` already does. A library page also + * takes `game`; any other typed page only a catalog, so + * `typedHref("/search", "game")` does not compile (spec 0027, AC-2). * * @param options.current The current query, from which `/search` keeps its * query, year and rating: genre ids differ between the two catalogs, and the @@ -106,10 +173,20 @@ export function mediaTypeForLocation( * @param options.page The page to open, written only from page 2, so page 1 * has one URL, as on `/movies`. */ +export function typedHref( + path: LibraryPath, + type: LibraryType, + options?: TypedHrefOptions, +): string; export function typedHref( path: TypedPath, type: MediaType, - options: { current?: URLSearchParams; page?: number } = {}, + options?: TypedHrefOptions, +): string; +export function typedHref( + path: TypedPath, + type: LibraryType, + options: TypedHrefOptions = {}, ): string { const query = new URLSearchParams({ type }); if (path === "/search" && options.current) { From 8815f0497221e17ad9123782f319c8f5779c0a52 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:15 +0100 Subject: [PATCH 03/19] feat(tracking): add the games library copy, month format and shared read failure Co-Authored-By: Claude Opus 5.5 --- lib/format.test.ts | 16 +++++++++++++ lib/format.ts | 20 ++++++++++++++++ lib/tracking/library-lists.ts | 24 +++++++++++-------- lib/tracking/messages.test.ts | 40 ++++++++++++++++++++++++++++++++ lib/tracking/messages.ts | 43 ++++++++++++++++++++++++++++++----- 5 files changed, 127 insertions(+), 16 deletions(-) diff --git a/lib/format.test.ts b/lib/format.test.ts index a9995dc0..9379f362 100644 --- a/lib/format.test.ts +++ b/lib/format.test.ts @@ -4,6 +4,7 @@ import { formatAirDate, formatAirSpan, formatEpisodeCount, + formatMonthYear, formatPersonalScore, formatReleaseDate, formatRuntime, @@ -294,3 +295,18 @@ describe("formatUsdCompact", () => { expect(formatUsdCompact(amount)).toBe(expected); }); }); + +/** covers: spec 0027, AC-9 */ +describe("formatMonthYear", () => { + it("prints the short month and the year", () => { + expect(formatMonthYear("2026-10-24T09:00:00+00:00")).toBe("Oct 2026"); + }); + + it("uses the UTC month, whatever the offset written", () => { + expect(formatMonthYear("2026-11-01T01:00:00+02:00")).toBe("Oct 2026"); + }); + + it("returns null for a value that is not a date", () => { + expect(formatMonthYear("not a date")).toBeNull(); + }); +}); diff --git a/lib/format.ts b/lib/format.ts index efb4ba99..0b2d6ad1 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -202,6 +202,26 @@ export function formatAirDate(date: string | null): string | null { return utc === null ? null : AIR_DATE_FORMAT.format(utc); } +const MONTH_YEAR_FORMAT = new Intl.DateTimeFormat("en", { + month: "short", + year: "numeric", + timeZone: "UTC", +}); + +/** + * The month a game was started or finished, as its library card prints it: + * `Oct 2026` (spec 0027, AC-9). The UTC calendar month, formatted on the + * server, so the server and the browser never disagree; near midnight it can + * be a month off for someone far from UTC, an accepted tradeoff. + * + * @param iso A Postgres `timestamptz` as PostgREST returns it. + * @returns The month and year, or null for a value that is not a date. + */ +export function formatMonthYear(iso: string): string | null { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? null : MONTH_YEAR_FORMAT.format(date); +} + const SHORT_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", diff --git a/lib/tracking/library-lists.ts b/lib/tracking/library-lists.ts index 4d3a9c5b..5cef2970 100644 --- a/lib/tracking/library-lists.ts +++ b/lib/tracking/library-lists.ts @@ -150,7 +150,7 @@ const EPISODE_PAGE_SIZE = 1000; * needs the total to redirect to the last page, so it is read again as a * count alone. */ -const RANGE_NOT_SATISFIABLE = "PGRST103"; +export const RANGE_NOT_SATISFIABLE = "PGRST103"; /** The 0 based slice of a 1 based page. */ function pageSlice(items: readonly T[], page: number): T[] { @@ -290,7 +290,7 @@ export async function getShowLibraryTab( .order("last_activity_at", { ascending: false }) .order("show_id", { ascending: true }) .limit(LIBRARY_CLASSIFY_LIMIT + 1); - if (error) return failed(); + if (error) return listReadFailed(); // A view's columns are all nullable to the type generator; the view // itself never yields a null id or `tracked_at`. @@ -316,12 +316,12 @@ export async function getShowLibraryTab( userId, rows.map((row) => row.id), ); - if (read === null) return failed(); + if (read === null) return listReadFailed(); watched = read; } catch { // A network failure inside supabase-js. The error is dropped on purpose, // as in the actions: its message can carry request details. - return failed(); + return listReadFailed(); } return classifyTab({ @@ -468,7 +468,7 @@ export async function getMovieLibraryTab( .order("watchlisted_at", { ascending: false }) .order("movie_id", { ascending: true }) .limit(LIBRARY_CLASSIFY_LIMIT + 1); - if (error) return failed(); + if (error) return listReadFailed(); // A planned row always has a plan time (a check constraint). ({ rows, capped } = capRows( @@ -477,7 +477,7 @@ export async function getMovieLibraryTab( ), )); } catch { - return failed(); + return listReadFailed(); } // The rows are already in plan order, so a stable sort on the date alone @@ -561,10 +561,10 @@ export async function getWatchedMoviesPage( .select("movie_id", { count: "exact", head: true }) .eq("user_id", userId) .not("watched_at", "is", null); - if (head.error || head.count === null) return failed(); + if (head.error || head.count === null) return listReadFailed(); return { kind: "ok", rows: [], total: head.count }; } - if (error || count === null) return failed(); + if (error || count === null) return listReadFailed(); return { kind: "ok", @@ -582,7 +582,7 @@ export async function getWatchedMoviesPage( ), }; } catch { - return failed(); + return listReadFailed(); } } @@ -612,7 +612,11 @@ export async function getLibraryMovieTitles( }; } -function failed(): { kind: "failed" } { +/** + * A failed library read, logged once with its outcome class only (spec + * 0020, AC-17). Shared with the games tabs (spec 0027, AC-7). + */ +export function listReadFailed(): { kind: "failed" } { logTrackingEvent(TRACKING_EVENT.listRead, "db_error"); return { kind: "failed" }; } diff --git a/lib/tracking/messages.test.ts b/lib/tracking/messages.test.ts index 037f9645..cb4bee26 100644 --- a/lib/tracking/messages.test.ts +++ b/lib/tracking/messages.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { EPISODE_TRACKING_MESSAGES, + GAME_DATE_LINES, GAME_STATUS_LABELS, GAME_TRACKING_MESSAGES, GAME_UNDO_MESSAGES, @@ -260,3 +261,42 @@ describe("game copy (spec 0026, AC-2, AC-4, AC-5)", () => { ); }); }); + +/** covers: spec 0027, AC-9, AC-14 */ +describe("the games tabs' copy (spec 0027)", () => { + it("pins each empty games tab's text, with no browse button (AC-14)", () => { + expect(LIBRARY_COPY.watchlist.game).toEqual({ + grid: "Games you want to play", + empty: { + title: "No games to play yet", + description: "Games you mark Want to play show up here.", + }, + }); + expect(LIBRARY_COPY.upcoming.game).toEqual({ + grid: "Games you're playing", + empty: { + title: "Nothing in progress", + description: "Games you mark Playing show up here.", + }, + }); + expect(LIBRARY_COPY.watched.game).toEqual({ + grid: "Games you finished", + empty: { + title: "No finished games yet", + description: "Games you mark Finished show up here.", + }, + }); + }); + + it("keeps a browse button on every show and movie tab", () => { + for (const list of ["watchlist", "upcoming", "watched"] as const) { + expect(LIBRARY_COPY[list].tv.browse).toBe("Browse shows"); + expect(LIBRARY_COPY[list].movie.browse).toBe("Browse movies"); + } + }); + + it("writes the Started and Finished month lines (AC-9)", () => { + expect(GAME_DATE_LINES.started("Feb 2026")).toBe("Started Feb 2026"); + expect(GAME_DATE_LINES.finished("Oct 2026")).toBe("Finished Oct 2026"); + }); +}); diff --git a/lib/tracking/messages.ts b/lib/tracking/messages.ts index 91c17de0..43d4cc73 100644 --- a/lib/tracking/messages.ts +++ b/lib/tracking/messages.ts @@ -1,4 +1,4 @@ -import type { MediaType } from "@/lib/catalog/media-type"; +import type { LibraryType, MediaType } from "@/lib/catalog/media-type"; import type { EpisodeTrackingError, @@ -188,21 +188,25 @@ export const UPCOMING_MESSAGES = { releaseTba: "Release date to be announced", } as const; -/** What a list page's grid, empty state and notes say (spec 0020, AC-18). */ +/** + * What a list page's grid, empty state and notes say (spec 0020, AC-18). + * `browse` is the empty state's button, absent where there is no catalog to + * browse yet (the games tabs until G6, spec 0027, AC-14). + */ type ListCopy = { grid: string; empty: { title: string; description: string }; - browse: string; + browse?: string; }; /** * Every piece of copy that differs between the three library pages and their - * two tabs (spec 0020, AC-12, AC-15, AC-18; feature 22). Each empty state - * offers the one catalog its tab lists. + * tabs (spec 0020, AC-12, AC-15, AC-18; feature 22; spec 0027, AC-14). Each + * show and movie empty state offers the one catalog its tab lists. */ export const LIBRARY_COPY: Record< "watchlist" | "upcoming" | "watched", - Record & { failed: string } + Record & { failed: string } > = { watchlist: { tv: { @@ -222,6 +226,13 @@ export const LIBRARY_COPY: Record< }, browse: "Browse movies", }, + game: { + grid: "Games you want to play", + empty: { + title: "No games to play yet", + description: "Games you mark Want to play show up here.", + }, + }, failed: "Couldn't load your watchlist", }, upcoming: { @@ -242,6 +253,13 @@ export const LIBRARY_COPY: Record< }, browse: "Browse movies", }, + game: { + grid: "Games you're playing", + empty: { + title: "Nothing in progress", + description: "Games you mark Playing show up here.", + }, + }, failed: "Couldn't load your upcoming titles", }, watched: { @@ -261,10 +279,23 @@ export const LIBRARY_COPY: Record< }, browse: "Browse movies", }, + game: { + grid: "Games you finished", + empty: { + title: "No finished games yet", + description: "Games you mark Finished show up here.", + }, + }, failed: "Couldn't load your watched titles", }, }; +/** The month line under a Playing or Finished game card (spec 0027, AC-9). */ +export const GAME_DATE_LINES = { + started: (month: string) => `Started ${month}`, + finished: (month: string) => `Finished ${month}`, +} as const; + /** "1 show", "4 movies". */ function titles(n: number, type: MediaType): string { const noun = type === "tv" ? "show" : "movie"; From 6003540c7320e02e2b278556a1536083924484b6 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:15 +0100 Subject: [PATCH 04/19] refactor(tracking): factor showGameUndo out of useGameUndo Co-Authored-By: Claude Opus 5.5 --- components/tracking/use-game-undo.test.tsx | 220 +++++++++++++++++++++ components/tracking/use-game-undo.ts | 111 +++++++---- 2 files changed, 291 insertions(+), 40 deletions(-) create mode 100644 components/tracking/use-game-undo.test.tsx diff --git a/components/tracking/use-game-undo.test.tsx b/components/tracking/use-game-undo.test.tsx new file mode 100644 index 00000000..c1594e4b --- /dev/null +++ b/components/tracking/use-game-undo.test.tsx @@ -0,0 +1,220 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { GameUndo } from "@/lib/tracking/types"; + +/** + * covers: spec 0026, AC-4, AC-5; spec 0027, AC-12 + * + * The two restore actions, the router and Sonner are the boundaries. + * `showGameUndo` is the plain function the games library grid calls once per + * card, and `useGameUndo` the thin wrapper the game page keeps. + */ +const restoreRemovedGame = vi.fn(); +const restoreGameStatus = vi.fn(); +vi.mock("@/app/games/actions", () => ({ + restoreRemovedGame: (...args: unknown[]) => restoreRemovedGame(...args), + restoreGameStatus: (...args: unknown[]) => restoreGameStatus(...args), +})); +const push = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) })); +const toast = vi.fn(); +const dismiss = vi.fn(); +vi.mock("sonner", () => ({ + toast: Object.assign((...args: unknown[]) => toast(...args), { + dismiss: (...args: unknown[]) => dismiss(...args), + }), +})); + +const { showGameUndo, useGameUndo } = await import("./use-game-undo"); + +const PREVIOUS = { + status: "playing", + rating: 8, + addedAt: "2026-01-01T00:00:00Z", + startedAt: "2026-02-01T00:00:00Z", + finishedAt: null, +} as const; + +const REMOVED: GameUndo = { + kind: "removed", + previous: PREVIOUS, + removedAt: "2026-10-24T10:00:00Z", +}; +const STATUS: GameUndo = { kind: "status", previous: PREVIOUS }; + +const RETURN_PATH = "/watched?type=game&page=2"; + +type ToastOptions = { + id: string; + duration: number; + action: { + label: string; + onClick: (event: { preventDefault: () => void }) => void; + }; +}; + +function lastToast(): [string, ToastOptions] { + return toast.mock.calls.at(-1) as [string, ToastOptions]; +} + +function clickUndo() { + const [, options] = lastToast(); + act(() => options.action.onClick({ preventDefault: vi.fn() })); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("showGameUndo", () => { + it("shows nothing when the write returned no undo", () => { + showGameUndo({ + gameId: 1, + undo: undefined, + returnPath: RETURN_PATH, + navigate: push, + }); + + expect(toast).not.toHaveBeenCalled(); + }); + + it("offers Undo on a removal for ten seconds, under the game's undo id", () => { + showGameUndo({ + gameId: 1942, + undo: REMOVED, + returnPath: RETURN_PATH, + navigate: push, + }); + + const [message, options] = lastToast(); + expect(message).toBe("Removed from your games."); + expect(options.id).toBe("game-undo-1942"); + expect(options.duration).toBe(10_000); + expect(options.action.label).toBe("Undo"); + }); + + it("restores a removal with its old state and removal time", async () => { + restoreRemovedGame.mockResolvedValue({ ok: true }); + showGameUndo({ + gameId: 1942, + undo: REMOVED, + returnPath: RETURN_PATH, + navigate: push, + }); + + clickUndo(); + + expect(restoreRemovedGame).toHaveBeenCalledWith( + 1942, + PREVIOUS, + REMOVED.removedAt, + ); + expect(restoreGameStatus).not.toHaveBeenCalled(); + await waitFor(() => expect(dismiss).toHaveBeenCalledWith("game-undo-1942")); + }); + + it("restores a cleared score through restoreGameStatus", () => { + restoreGameStatus.mockResolvedValue({ ok: true }); + showGameUndo({ + gameId: 7, + undo: STATUS, + returnPath: RETURN_PATH, + navigate: push, + }); + + expect(lastToast()[0]).toBe( + "Moved to Want to play. Your score was removed.", + ); + clickUndo(); + + expect(restoreGameStatus).toHaveBeenCalledWith(7, PREVIOUS); + expect(restoreRemovedGame).not.toHaveBeenCalled(); + }); + + it("runs the optimistic restoring callback inside the Undo", async () => { + restoreRemovedGame.mockResolvedValue({ ok: true }); + const restoring = vi.fn(); + showGameUndo({ + gameId: 1, + undo: REMOVED, + returnPath: RETURN_PATH, + navigate: push, + restoring, + }); + + clickUndo(); + + await waitFor(() => expect(restoring).toHaveBeenCalledTimes(1)); + }); + + it("says the Undo expired when the restore is refused as too late", async () => { + restoreRemovedGame.mockResolvedValue({ ok: false, error: "undo_expired" }); + showGameUndo({ + gameId: 1, + undo: REMOVED, + returnPath: RETURN_PATH, + navigate: push, + }); + + clickUndo(); + + await waitFor(() => + expect(toast).toHaveBeenLastCalledWith( + "Couldn't undo. Set the status again on this page.", + expect.objectContaining({ id: "game-undo-1", action: undefined }), + ), + ); + }); + + it("offers Sign in back to the page when the session expired during Undo", async () => { + restoreRemovedGame.mockResolvedValue({ + ok: false, + error: "session_expired", + }); + showGameUndo({ + gameId: 1, + undo: REMOVED, + returnPath: RETURN_PATH, + navigate: push, + }); + + clickUndo(); + + await waitFor(() => + expect(lastToast()[0]).toBe( + "Your session expired. Sign in to save this.", + ), + ); + const [, options] = lastToast(); + expect(options.id).toBe("game-tracking-1-status"); + (options.action.onClick as () => void)(); + expect(push).toHaveBeenCalledWith( + `/sign-in?next=${encodeURIComponent(RETURN_PATH)}`, + ); + }); +}); + +describe("useGameUndo", () => { + it("binds the game, the return path and the router for the game page", async () => { + restoreRemovedGame.mockResolvedValue({ + ok: false, + error: "session_expired", + }); + const { result } = renderHook(() => + useGameUndo({ gameId: 42, returnPath: "/games/42" }), + ); + + act(() => result.current(REMOVED)); + expect(lastToast()[1].id).toBe("game-undo-42"); + clickUndo(); + + await waitFor(() => + expect(lastToast()[1].id).toBe("game-tracking-42-status"), + ); + (lastToast()[1].action.onClick as () => void)(); + expect(push).toHaveBeenCalledWith( + `/sign-in?next=${encodeURIComponent("/games/42")}`, + ); + }); +}); diff --git a/components/tracking/use-game-undo.ts b/components/tracking/use-game-undo.ts index c1588e99..12644acf 100644 --- a/components/tracking/use-game-undo.ts +++ b/components/tracking/use-game-undo.ts @@ -15,10 +15,71 @@ import { } from "./tracking-toast"; /** - * The two game toasts that carry an Undo (spec 0026, AC-4, AC-5): a removal, - * and a move to Want to play that cleared a score. Both share the id - * `game-undo-{id}`, so a newer one replaces an older one, and both run - * through `runUndoInToast`, which keeps the toast while the restore runs. + * Shows the toast for what a game write can take back (spec 0026, AC-4, + * AC-5): a removal, or a move to Want to play that cleared a score. Every + * game Undo toast for one game shares the id `game-undo-{id}`, so a newer one + * replaces an older one, whichever surface showed it. It runs through + * `runUndoInToast`, which keeps the toast while the restore runs. + * + * A plain function, not a hook, so the games library grid can show one per + * card (spec 0027, AC-12). The toast holds only the action and `navigate`, + * so its Undo still works after the grid that showed it is gone. + * + * @param options.undo The action's `undo`; nothing shows when it is absent. + * @param options.returnPath Where the session expired toast's Sign in comes + * back to. + * @param options.navigate The router push, for that Sign in action. + * @param options.restoring Runs inside the Undo's transition, for an + * optimistic update that shows the old state back at once. + */ +export function showGameUndo({ + gameId, + undo, + returnPath, + navigate, + restoring, +}: { + gameId: number; + undo: GameUndo | undefined; + returnPath: string; + navigate: (href: string) => void; + restoring?: () => void; +}): void { + if (!undo) return; + const toastId = `game-undo-${gameId}`; + const message = + undo.kind === "removed" + ? GAME_UNDO_MESSAGES.removed + : GAME_UNDO_MESSAGES.scoreCleared; + toast(message, { + id: toastId, + duration: UNDO_TOAST_MS, + action: { + label: UNDO_ACTION_LABEL, + onClick: (event) => + runUndoInToast(event, { + id: toastId, + message, + expiredMessage: GAME_UNDO_MESSAGES.undoExpired, + restore: () => + undo.kind === "removed" + ? restoreRemovedGame(gameId, undo.previous, undo.removedAt) + : restoreGameStatus(gameId, undo.previous), + restoring, + onError: (error) => + showGameTrackingError(error, { + id: gameTrackingToastId(gameId, "status"), + returnPath, + navigate, + }), + }), + }, + }); +} + +/** + * `showGameUndo` for the game page, which shows one game: it binds the game, + * the return path and the router. * * @param gameId The game. * @param returnPath Where the session expired toast's Sign in comes back to. @@ -31,44 +92,14 @@ export function useGameUndo({ returnPath: string; }) { const router = useRouter(); - const toastId = `game-undo-${gameId}`; - /** - * Shows the toast for what a write can take back. - * - * @param undo The action's `undo`; nothing shows when it is absent. - * @param restoring Runs inside the Undo's transition, for an optimistic - * update that shows the old state back at once. - */ return function showUndo(undo: GameUndo | undefined, restoring?: () => void) { - if (!undo) return; - const message = - undo.kind === "removed" - ? GAME_UNDO_MESSAGES.removed - : GAME_UNDO_MESSAGES.scoreCleared; - toast(message, { - id: toastId, - duration: UNDO_TOAST_MS, - action: { - label: UNDO_ACTION_LABEL, - onClick: (event) => - runUndoInToast(event, { - id: toastId, - message, - expiredMessage: GAME_UNDO_MESSAGES.undoExpired, - restore: () => - undo.kind === "removed" - ? restoreRemovedGame(gameId, undo.previous, undo.removedAt) - : restoreGameStatus(gameId, undo.previous), - restoring, - onError: (error) => - showGameTrackingError(error, { - id: gameTrackingToastId(gameId, "status"), - returnPath, - navigate: router.push, - }), - }), - }, + showGameUndo({ + gameId, + undo, + returnPath, + navigate: router.push, + restoring, }); }; } From 24448c4a1045f302b337a5cc6c5ed347c34104d5 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:15 +0100 Subject: [PATCH 05/19] feat(games): read one games library page from Postgres and IGDB Co-Authored-By: Claude Opus 5.5 --- lib/tracking/game-library.test.ts | 206 ++++++++++++++++++++++++++++++ lib/tracking/game-library.ts | 148 +++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 lib/tracking/game-library.test.ts create mode 100644 lib/tracking/game-library.ts diff --git a/lib/tracking/game-library.test.ts b/lib/tracking/game-library.test.ts new file mode 100644 index 00000000..6e97e563 --- /dev/null +++ b/lib/tracking/game-library.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0027, AC-6, AC-7, AC-15 + * + * The database and IGDB are the boundaries, so those are replaced. The query + * builder records what it was asked, so each test asserts the status filter, + * the order, the range and the owner filter that would reach PostgREST. + */ +const calls: { method: string; args: unknown[] }[] = []; +let responses: { data: unknown; error: unknown; count?: number | null }[] = []; + +function builder() { + const chain: Record = {}; + for (const method of ["from", "select", "eq", "order", "range"]) { + chain[method] = (...args: unknown[]) => { + calls.push({ method, args }); + return chain; + }; + } + // biome-ignore lint/suspicious/noThenProperty: stands in for a thenable PostgREST builder. + chain.then = (resolve: (value: unknown) => unknown) => + Promise.resolve(resolve(responses.shift())); + return chain; +} + +const createClient = vi.fn(async () => ({ + from: (...args: unknown[]) => + (builder().from as (...a: unknown[]) => unknown)(...args), +})); +vi.mock("server-only", () => ({})); +vi.mock("@/lib/supabase/server", () => ({ createClient })); +vi.mock("@/lib/tmdb", () => ({ TmdbError: class extends Error {} })); + +class FakeIgdbError extends Error {} +const getGamesByIds = vi.fn(); +vi.mock("@/lib/igdb", () => ({ + getGamesByIds: (...args: unknown[]) => getGamesByIds(...args), + IgdbError: FakeIgdbError, +})); + +const { GAME_LIBRARY_TABS, getGameLibraryPage, getLibraryGames } = await import( + "./game-library" +); + +let warn: ReturnType; + +beforeEach(() => { + calls.length = 0; + responses = []; + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.clearAllMocks(); + warn.mockRestore(); +}); + +const ROW = { + game_id: 1942, + rating: 9, + added_at: "2026-01-01T10:00:00+00:00", + started_at: "2026-02-01T10:00:00+00:00", + finished_at: "2026-03-01T10:00:00+00:00", +}; + +describe("getGameLibraryPage", () => { + it.each([ + ["watchlist", "want_to_play", "added_at"], + ["upcoming", "playing", "started_at"], + ["watched", "finished", "finished_at"], + ] as const)( + "reads %s as the owner's %s games, newest %s first, 24 per page", + async (tab, status, order) => { + responses = [{ data: [ROW], error: null, count: 25 }]; + + expect( + await getGameLibraryPage({ userId: "user-a", tab, page: 2 }), + ).toEqual({ + kind: "ok", + total: 25, + rows: [ + { + gameId: 1942, + rating: 9, + addedAt: ROW.added_at, + startedAt: ROW.started_at, + finishedAt: ROW.finished_at, + }, + ], + }); + expect(GAME_LIBRARY_TABS[tab]).toEqual({ status, order }); + expect(calls).toEqual([ + { method: "from", args: ["user_game_state"] }, + { + method: "select", + args: [ + "game_id, rating, added_at, started_at, finished_at", + { count: "exact" }, + ], + }, + { method: "eq", args: ["user_id", "user-a"] }, + { method: "eq", args: ["status", status] }, + { method: "order", args: [order, { ascending: false }] }, + { method: "order", args: ["game_id", { ascending: true }] }, + { method: "range", args: [24, 47] }, + ]); + }, + ); + + it("answers a page past the end with the real total from a count read", async () => { + responses = [ + { data: null, error: { code: "PGRST103" }, count: null }, + { data: null, error: null, count: 30 }, + ]; + + expect( + await getGameLibraryPage({ userId: "user-a", tab: "watched", page: 5 }), + ).toEqual({ kind: "ok", rows: [], total: 30 }); + expect(calls.slice(-4)).toEqual([ + { method: "from", args: ["user_game_state"] }, + { method: "select", args: ["game_id", { count: "exact", head: true }] }, + { method: "eq", args: ["user_id", "user-a"] }, + { method: "eq", args: ["status", "finished"] }, + ]); + }); + + it("fails, logging the outcome class only, on a Postgres error", async () => { + responses = [{ data: null, error: { code: "42501" }, count: null }]; + + expect( + await getGameLibraryPage({ userId: "user-a", tab: "watchlist", page: 1 }), + ).toEqual({ kind: "failed" }); + expect(warn).toHaveBeenCalledWith( + "movie_tracking.list_read refused db_error", + ); + }); + + it("reads page 1 from the first row", async () => { + responses = [{ data: [], error: null, count: 0 }]; + + expect( + await getGameLibraryPage({ userId: "user-a", tab: "watchlist", page: 1 }), + ).toEqual({ kind: "ok", rows: [], total: 0 }); + expect(calls.at(-1)).toEqual({ method: "range", args: [0, 23] }); + }); + + it("fails when PostgREST returns no count, rather than guessing a total", async () => { + responses = [{ data: [ROW], error: null, count: null }]; + + expect( + await getGameLibraryPage({ userId: "user-a", tab: "watched", page: 1 }), + ).toEqual({ kind: "failed" }); + }); + + it("fails when the count read after a page past the end fails too", async () => { + responses = [ + { data: null, error: { code: "PGRST103" }, count: null }, + { data: null, error: { code: "57014" }, count: null }, + ]; + + expect( + await getGameLibraryPage({ userId: "user-a", tab: "watched", page: 9 }), + ).toEqual({ kind: "failed" }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("fails when the client cannot be created", async () => { + createClient.mockRejectedValueOnce(new Error("no cookies")); + + expect( + await getGameLibraryPage({ userId: "user-a", tab: "upcoming", page: 1 }), + ).toEqual({ kind: "failed" }); + }); +}); + +describe("getLibraryGames", () => { + it("keys IGDB's games by id and passes the missing ids through", async () => { + const game = { id: 1942, name: "The Witcher 3" }; + getGamesByIds.mockResolvedValueOnce({ found: [game], missingIds: [7] }); + + const result = await getLibraryGames([1942, 7]); + + expect(getGamesByIds).toHaveBeenCalledWith([1942, 7]); + expect(result).toEqual({ + kind: "ok", + games: new Map([[1942, game]]), + missingIds: [7], + }); + }); + + it("turns an IgdbError into a failed read, never an empty map", async () => { + getGamesByIds.mockRejectedValueOnce(new FakeIgdbError("rate limited")); + + expect(await getLibraryGames([1942])).toEqual({ kind: "failed" }); + expect(warn).toHaveBeenCalledWith( + "movie_tracking.list_read refused igdb_unavailable", + ); + }); + + it("rethrows anything else as a bug", async () => { + getGamesByIds.mockRejectedValueOnce(new TypeError("bad id")); + + await expect(getLibraryGames([1942])).rejects.toThrow("bad id"); + }); +}); diff --git a/lib/tracking/game-library.ts b/lib/tracking/game-library.ts new file mode 100644 index 00000000..73ed4eae --- /dev/null +++ b/lib/tracking/game-library.ts @@ -0,0 +1,148 @@ +import "server-only"; + +import type { LibraryList } from "@/lib/catalog/library-list"; +import { type GameSummary, getGamesByIds, IgdbError } from "@/lib/igdb"; +import { createClient } from "@/lib/supabase/server"; + +import { + LIBRARY_PAGE_SIZE, + type LibraryPage, + listReadFailed, + RANGE_NOT_SATISFIABLE, +} from "./library-lists"; +import { logTrackingEvent, TRACKING_EVENT } from "./log"; +import type { GameStatus } from "./types"; + +/** + * The reads behind the games tabs of `/watchlist`, `/upcoming` and + * `/watched` (spec 0027, AC-6, AC-7). + * + * A game's status is stored, set by hand (`AGENTS.md`, Games), so unlike the + * show and movie tabs nothing is classified: each tab is one ordered, + * counted Postgres page, then one IGDB batch for just that page's games. + * There is no 500 title ceiling and no failed titles note. + * + * Neither may ever run inside `use cache`: the rows belong to one person + * (`AGENTS.md` section 11). The query filters on `user_id` explicitly as well + * as through row level security, and every order ends in `game_id`, so two + * games stamped in the same instant never swap places between pages. + */ + +/** The date column a tab orders by, which is also its card's date line. */ +type GameOrder = "added_at" | "started_at" | "finished_at"; + +/** + * Which status each library page lists for games, and in what order (spec + * 0027, tab mapping). The one place the route to status mapping is written. + */ +export const GAME_LIBRARY_TABS: Record< + LibraryList, + { status: GameStatus; order: GameOrder } +> = { + watchlist: { status: "want_to_play", order: "added_at" }, + upcoming: { status: "playing", order: "started_at" }, + watched: { status: "finished", order: "finished_at" }, +}; + +/** One game row on a tab, before IGDB fills in the card. */ +export type GameLibraryRow = { + gameId: number; + /** The personal score; only Playing and Finished can hold one. */ + rating: number | null; + addedAt: string; + startedAt: string | null; + finishedAt: string | null; +}; + +/** + * One page of a games tab, newest first by the tab's date, with an exact + * count (AC-6). A page past the end comes back empty with the real total, so + * the page can redirect to the last page. + * + * @param query.userId The verified session's user, never a client value. + * @param query.page A page already parsed by `parsePageParam`. + */ +export async function getGameLibraryPage({ + userId, + tab, + page, +}: { + userId: string; + tab: LibraryList; + page: number; +}): Promise> { + const { status, order } = GAME_LIBRARY_TABS[tab]; + try { + const supabase = await createClient(); + const from = (page - 1) * LIBRARY_PAGE_SIZE; + const { data, error, count } = await supabase + .from("user_game_state") + .select("game_id, rating, added_at, started_at, finished_at", { + count: "exact", + }) + .eq("user_id", userId) + .eq("status", status) + .order(order, { ascending: false }) + .order("game_id", { ascending: true }) + .range(from, from + LIBRARY_PAGE_SIZE - 1); + + if (error?.code === RANGE_NOT_SATISFIABLE) { + const head = await supabase + .from("user_game_state") + .select("game_id", { count: "exact", head: true }) + .eq("user_id", userId) + .eq("status", status); + if (head.error || head.count === null) return listReadFailed(); + return { kind: "ok", rows: [], total: head.count }; + } + if (error || count === null) return listReadFailed(); + + return { + kind: "ok", + total: count, + rows: data.map((row) => ({ + gameId: row.game_id, + rating: row.rating, + addedAt: row.added_at, + startedAt: row.started_at, + finishedAt: row.finished_at, + })), + }; + } catch { + return listReadFailed(); + } +} + +/** A page's games, keyed by IGDB id, or a systemic IGDB failure. */ +export type LibraryGames = + | { kind: "ok"; games: Map; missingIds: number[] } + | { kind: "failed" }; + +/** + * The IGDB games for one page of a games tab, in one cached batch read + * (AC-7). A game IGDB no longer has is in `missingIds`, so the page shows its + * "No longer on IGDB" card. A systemic failure is `failed` rather than an + * empty map, which would render every game as missing and invite the user to + * remove rows that are fine. + * + * Only an `IgdbError` is a failure. An `IgdbValidationError` cannot happen + * with ids from the database, so it still throws, as a bug. + * + * @param gameIds At most `LIBRARY_PAGE_SIZE` ids. + */ +export async function getLibraryGames( + gameIds: readonly number[], +): Promise { + try { + const batch = await getGamesByIds(gameIds); + return { + kind: "ok", + games: new Map(batch.found.map((game) => [game.id, game])), + missingIds: batch.missingIds, + }; + } catch (error) { + if (!(error instanceof IgdbError)) throw error; + logTrackingEvent(TRACKING_EVENT.listRead, "igdb_unavailable"); + return { kind: "failed" }; + } +} From 2dc2bd038848cede9a0ad4066704ec71f083e336 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:16 +0100 Subject: [PATCH 06/19] feat(library): show the games tabs on the three library pages Co-Authored-By: Claude Opus 5.5 --- app/upcoming/page.tsx | 10 +- app/watched/page.tsx | 10 +- app/watchlist/page.tsx | 10 +- components/library/game-library-card.tsx | 109 ++++++ components/library/game-library-grid.test.tsx | 350 ++++++++++++++++++ components/library/game-library-grid.tsx | 156 ++++++++ components/library/game-library-tab.test.tsx | 346 +++++++++++++++++ components/library/game-library-tab.tsx | 140 +++++++ .../library/library-error-boundary.test.tsx | 63 ++++ components/library/library-error-boundary.tsx | 49 +++ components/library/library-panels.tsx | 47 ++- components/library/library-section.tsx | 16 +- components/library/types.ts | 30 ++ 13 files changed, 1312 insertions(+), 24 deletions(-) create mode 100644 components/library/game-library-card.tsx create mode 100644 components/library/game-library-grid.test.tsx create mode 100644 components/library/game-library-grid.tsx create mode 100644 components/library/game-library-tab.test.tsx create mode 100644 components/library/game-library-tab.tsx create mode 100644 components/library/library-error-boundary.test.tsx create mode 100644 components/library/library-error-boundary.tsx diff --git a/app/upcoming/page.tsx b/app/upcoming/page.tsx index 13e39646..e05daceb 100644 --- a/app/upcoming/page.tsx +++ b/app/upcoming/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { LibraryErrorBoundary } from "@/components/library/library-error-boundary"; import { LibraryHeading } from "@/components/library/library-heading"; import { LibrarySection, @@ -23,6 +24,7 @@ export const metadata: Metadata = { * the session, the `type` and `page` parameters or the user's rows streams in * behind the Suspense boundary, inside `LibrarySection`, which calls * `requireUser()` itself: the proxy's redirect is only the convenience layer. + * A section that throws shows `LibraryErrorBoundary`'s panel under the heading. * Nothing on this page writes when it loads (AC-21). */ export default function UpcomingPage({ searchParams }: PageProps<"/upcoming">) { @@ -30,9 +32,11 @@ export default function UpcomingPage({ searchParams }: PageProps<"/upcoming">) {
Upcoming - }> - - + + }> + + +
); } diff --git a/app/watched/page.tsx b/app/watched/page.tsx index faa4097e..bf268f6f 100644 --- a/app/watched/page.tsx +++ b/app/watched/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { LibraryErrorBoundary } from "@/components/library/library-error-boundary"; import { LibraryHeading } from "@/components/library/library-heading"; import { LibrarySection, @@ -23,6 +24,7 @@ export const metadata: Metadata = { * the session, the `type` and `page` parameters or the user's rows streams in * behind the Suspense boundary, inside `LibrarySection`, which calls * `requireUser()` itself: the proxy's redirect is only the convenience layer. + * A section that throws shows `LibraryErrorBoundary`'s panel under the heading. * * `LibraryHeading` takes the focus when a removal empties the page (spec * 0008, AC-16). Nothing on this page writes when it loads (spec 0020, AC-21). @@ -32,9 +34,11 @@ export default function WatchedPage({ searchParams }: PageProps<"/watched">) {
Watched - }> - - + + }> + + +
); } diff --git a/app/watchlist/page.tsx b/app/watchlist/page.tsx index 144324b8..7ed45c34 100644 --- a/app/watchlist/page.tsx +++ b/app/watchlist/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { LibraryErrorBoundary } from "@/components/library/library-error-boundary"; import { LibraryHeading } from "@/components/library/library-heading"; import { LibrarySection, @@ -22,6 +23,7 @@ export const metadata: Metadata = { * the session, the `type` and `page` parameters or the user's rows streams in * behind the Suspense boundary, inside `LibrarySection`, which calls * `requireUser()` itself: the proxy's redirect is only the convenience layer. + * A section that throws shows `LibraryErrorBoundary`'s panel under the heading. * * `LibraryHeading` takes the focus when a removal empties the page (spec * 0008, AC-16). Nothing on this page writes when it loads (spec 0020, AC-21). @@ -33,9 +35,11 @@ export default function WatchlistPage({
Watchlist - }> - - + + }> + + +
); } diff --git a/components/library/game-library-card.tsx b/components/library/game-library-card.tsx new file mode 100644 index 00000000..2b589027 --- /dev/null +++ b/components/library/game-library-card.tsx @@ -0,0 +1,109 @@ +import { XIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { PosterCard } from "@/components/poster-card"; +import { + IgdbRatingBadge, + PersonalScoreBadge, +} from "@/components/rating-badges"; +import { CardRoundButton } from "@/components/tracking/card-round-button"; +import { gameHref } from "@/lib/catalog/media-type"; + +import { ReleasePill } from "./dated-pill"; +import type { LibraryGameItem, LibraryList } from "./types"; + +/** + * The caption and fallback tile text for a game IGDB no longer has (spec + * 0027, AC-11). Never an invented name or a placeholder cover. + */ +const MISSING_GAME = "No longer on IGDB"; + +/** + * One game card on a games tab (spec 0027, AC-8 to AC-12), built on + * `PosterCard`. + * + * Want to play carries the amber IGDB community rating and, while the game + * is not out, its release pill or Date TBA. Playing and Finished carry the + * cyan personal score when there is one, else the community rating, and the + * Started or Finished month under the name. A hidden game keeps its card + * but loses its link, because its page answers a soft 404 (AC-10). Every card + * has one remove button, which only reports the tap: `GameLibraryGrid` owns + * the optimistic hide, the action call and the toast. + * + * @param list The tab, which picks the badge and the date line (AC-9). + */ +function GameLibraryCard({ + list, + item, + onRemove, + priority = false, +}: { + list: LibraryList; + item: LibraryGameItem; + onRemove: () => void; + priority?: boolean; +}) { + if (item.name === null) { + return ( + } + /> + ); + } + + const unreleased = + list === "watchlist" && item.release.state === "unreleased" + ? item.release + : null; + + return ( + + {unreleased ? : null} + + + } + priority={priority} + /> + ); +} + +/** + * The cyan personal score on Playing and Finished when there is one, else + * the amber community rating (AC-8, AC-9). A score on a Want to play row + * cannot exist (spec 0024, AC-2), so it is never looked at there. + */ +function cardBadge(list: LibraryList, item: LibraryGameItem): ReactNode { + if (list !== "watchlist" && item.rating !== null) { + return ; + } + return ; +} + +function RemoveButton({ + name, + onRemove, +}: { + name: string | null; + onRemove: () => void; +}) { + return ( + + + ); +} + +export { GameLibraryCard, MISSING_GAME }; diff --git a/components/library/game-library-grid.test.tsx b/components/library/game-library-grid.test.tsx new file mode 100644 index 00000000..83078f70 --- /dev/null +++ b/components/library/game-library-grid.test.tsx @@ -0,0 +1,350 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { LibraryGameItem, LibraryList } from "./types"; + +/** + * covers: spec 0027, AC-8 to AC-13 + * + * `removeGame`, `restoreRemovedGame`, the router and Sonner are the + * boundaries. Each action returns a promise the test settles by hand, so a + * test can look at the grid while the write is in flight. The app's + * `refresh()` delivers the next page inside the same transition; here that is + * a rerender with the new `items`, done before the write settles. + */ +const pending: ((value: unknown) => void)[] = []; +function deferred() { + return vi.fn( + (..._args: unknown[]) => + new Promise((resolve) => { + pending.push(resolve); + }), + ); +} +const removeGame = deferred(); +const restoreRemovedGame = deferred(); +vi.mock("@/app/games/actions", () => ({ + removeGame: (...args: unknown[]) => removeGame(...args), + restoreRemovedGame: (...args: unknown[]) => restoreRemovedGame(...args), + restoreGameStatus: vi.fn(), +})); +const push = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) })); +const toast = vi.fn(); +const dismiss = vi.fn(); +vi.mock("sonner", () => ({ + toast: Object.assign((...args: unknown[]) => toast(...args), { + dismiss: (...args: unknown[]) => dismiss(...args), + }), +})); + +const { GameLibraryGrid } = await import("./game-library-grid"); +const { LibraryHeading } = await import("./library-heading"); + +function item( + gameId: number, + overrides: Partial = {}, +): LibraryGameItem { + return { + gameId, + name: `Game ${gameId}`, + coverUrl: null, + communityRating: 8.1, + rating: null, + hidden: false, + release: { state: "released" }, + dateLine: null, + ...overrides, + }; +} + +const RETURN_PATH = "/upcoming?type=game&page=2"; + +function grid(list: LibraryList, items: LibraryGameItem[], page = 1) { + return ( + <> + Heading + + + ); +} + +async function settleNext(value: unknown) { + await act(async () => pending.shift()?.(value)); +} + +const UNDO = { + kind: "removed", + previous: { + status: "playing", + rating: 8, + addedAt: "2026-01-01T00:00:00Z", + startedAt: "2026-02-01T00:00:00Z", + finishedAt: null, + }, + removedAt: "2026-10-24T10:00:00Z", +} as const; + +afterEach(() => { + for (const resolve of pending.splice(0)) resolve({ ok: true }); + vi.clearAllMocks(); +}); + +describe("GameLibraryCard through the grid", () => { + it("links a game to its page with the IGDB community rating on Want to play", () => { + render(grid("watchlist", [item(1942, { name: "The Witcher 3" })])); + + expect(screen.getByRole("link", { name: "The Witcher 3" })).toHaveAttribute( + "href", + "/games/1942", + ); + expect(screen.getByText("IGDB community rating")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Remove The Witcher 3 from your games", + }), + ).toBeInTheDocument(); + }); + + it("shows the release pill or Date TBA only on an unreleased Want to play game", () => { + render( + grid("watchlist", [ + item(1, { + release: { + state: "unreleased", + date: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" }, + }, + }), + item(2, { release: { state: "unreleased", date: null } }), + item(3), + ]), + ); + + expect(screen.getByText("Releases Feb 3, 2027")).toBeInTheDocument(); + expect( + screen.getByText("Release date to be announced"), + ).toBeInTheDocument(); + expect(screen.getAllByText(/^Releases|^Release date/)).toHaveLength(2); + }); + + it("shows the personal score on Playing when there is one, else the community rating", () => { + render( + grid("upcoming", [ + item(1, { rating: 9, dateLine: "Started Feb 2026" }), + item(2, { dateLine: "Started Mar 2026" }), + ]), + ); + + expect(screen.getAllByText("IGDB community rating")).toHaveLength(1); + expect(screen.getByText("9")).toBeInTheDocument(); + expect(screen.getByText("Started Feb 2026")).toBeInTheDocument(); + expect(screen.getByText("Started Mar 2026")).toBeInTheDocument(); + }); + + it("shows the personal score and the Finished month on a scored Finished game", () => { + render( + grid("watched", [item(1, { rating: 10, dateLine: "Finished Oct 2026" })]), + ); + + expect(screen.getByText("Your score")).toBeInTheDocument(); + expect(screen.queryByText("IGDB community rating")).not.toBeInTheDocument(); + expect(screen.getByText("Finished Oct 2026")).toBeInTheDocument(); + }); + + it("ignores a score and a date line on Want to play", () => { + render( + grid("watchlist", [item(1, { rating: 7, dateLine: "Started Feb 2026" })]), + ); + + expect(screen.queryByText("Your score")).not.toBeInTheDocument(); + expect(screen.getByText("IGDB community rating")).toBeInTheDocument(); + expect(screen.queryByText("Started Feb 2026")).not.toBeInTheDocument(); + }); + + it("never shows a release pill off Want to play", () => { + render( + grid("upcoming", [ + item(1, { release: { state: "unreleased", date: null } }), + ]), + ); + + expect( + screen.queryByText("Release date to be announced"), + ).not.toBeInTheDocument(); + }); + + it("gives a hidden game no link but keeps its remove button", () => { + render(grid("watched", [item(1, { name: "Hidden Game", hidden: true })])); + + expect( + screen.getByRole("heading", { name: "Hidden Game" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Remove Hidden Game from your games", + }), + ).toBeInTheDocument(); + }); + + it("shows a missing game as No longer on IGDB, removable, with nothing invented", () => { + render( + grid("watched", [ + item(1, { name: null, communityRating: null, dateLine: null }), + ]), + ); + + expect(screen.getAllByText("No longer on IGDB").length).toBeGreaterThan(0); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.queryByText("IGDB community rating")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Remove missing game from your games", + }), + ).toBeInTheDocument(); + }); +}); + +describe("removal (AC-12, AC-13)", () => { + it("hides the card at once, then shows the Undo toast", async () => { + const user = userEvent.setup(); + const view = render(grid("upcoming", [item(1), item(2)])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + expect(removeGame).toHaveBeenCalledWith(1); + expect(screen.queryByText("Game 1")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Game 2" })).toHaveFocus(); + + view.rerender(grid("upcoming", [item(2)])); + await settleNext({ ok: true, undo: UNDO }); + + expect(toast).toHaveBeenCalledWith( + "Removed from your games.", + expect.objectContaining({ id: "game-undo-1" }), + ); + expect(screen.queryByText("Game 1")).not.toBeInTheDocument(); + }); + + it("restores the removal from the toast's Undo", async () => { + const user = userEvent.setup(); + render(grid("upcoming", [item(1)])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + await settleNext({ ok: true, undo: UNDO }); + + const options = toast.mock.calls.at(-1)?.[1] as { + action: { onClick: (event: { preventDefault: () => void }) => void }; + }; + act(() => options.action.onClick({ preventDefault: vi.fn() })); + + expect(restoreRemovedGame).toHaveBeenCalledWith( + 1, + UNDO.previous, + UNDO.removedAt, + ); + }); + + it("is a quiet success when another tab removed the game first", async () => { + const user = userEvent.setup(); + render(grid("watched", [item(1)])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + await settleNext({ ok: true }); + + expect(toast).not.toHaveBeenCalled(); + }); + + it("puts the card back and offers Sign in back to this page when the session expired", async () => { + const user = userEvent.setup(); + render(grid("upcoming", [item(1)])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + await settleNext({ ok: false, error: "session_expired" }); + + expect(screen.getByRole("link", { name: "Game 1" })).toBeInTheDocument(); + const [message, options] = toast.mock.calls.at(-1) as [ + string, + { id: string; action: { onClick: () => void } }, + ]; + expect(message).toBe("Your session expired. Sign in to save this."); + expect(options.id).toBe("game-tracking-1-status"); + options.action.onClick(); + expect(push).toHaveBeenCalledWith( + `/sign-in?next=${encodeURIComponent(RETURN_PATH)}`, + ); + }); + + it("puts the card back in place with the error toast when the write fails", async () => { + const user = userEvent.setup(); + render(grid("watched", [item(1), item(2)])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + await settleNext({ ok: false, error: "write_failed" }); + + const links = screen.getAllByRole("link").map((link) => link.textContent); + expect(links).toEqual(["Game 1", "Game 2"]); + expect(toast).toHaveBeenCalledWith( + "Couldn't save that change. Try again.", + expect.objectContaining({ id: "game-tracking-1-status" }), + ); + }); + + it("keeps two removals in a row independent, one toast each", async () => { + const user = userEvent.setup(); + const view = render(grid("upcoming", [item(1), item(2), item(3)])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + await user.click( + screen.getByRole("button", { name: "Remove Game 2 from your games" }), + ); + expect(removeGame.mock.calls).toEqual([[1], [2]]); + expect(screen.getAllByRole("link").map((l) => l.textContent)).toEqual([ + "Game 3", + ]); + + view.rerender(grid("upcoming", [item(3)])); + await settleNext({ ok: true, undo: UNDO }); + await settleNext({ ok: true, undo: UNDO }); + + expect(toast.mock.calls.map(([, options]) => options.id)).toEqual([ + "game-undo-1", + "game-undo-2", + ]); + }); + + it("focuses a linkless neighbour's remove button, then the heading", async () => { + const user = userEvent.setup(); + render(grid("watched", [item(1), item(2, { hidden: true })])); + + await user.click( + screen.getByRole("button", { name: "Remove Game 1 from your games" }), + ); + expect( + screen.getByRole("button", { name: "Remove Game 2 from your games" }), + ).toHaveFocus(); + + await user.click( + screen.getByRole("button", { name: "Remove Game 2 from your games" }), + ); + expect(screen.getByRole("heading", { name: "Heading" })).toHaveFocus(); + }); +}); diff --git a/components/library/game-library-grid.tsx b/components/library/game-library-grid.tsx new file mode 100644 index 00000000..53bb1656 --- /dev/null +++ b/components/library/game-library-grid.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { startTransition, useEffect, useOptimistic, useRef } from "react"; + +import { removeGame } from "@/app/games/actions"; +import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; +import { + gameTrackingToastId, + settleResultCall, + showGameTrackingError, +} from "@/components/tracking/tracking-toast"; +import { showGameUndo } from "@/components/tracking/use-game-undo"; + +import { GameLibraryCard } from "./game-library-card"; +import { + cancelLibraryHeadingFocus, + focusLibraryHeading, +} from "./library-heading"; +import { gameItemKey, type LibraryGameItem, type LibraryList } from "./types"; + +/** + * The game grid on the games tabs of `/watchlist`, `/upcoming` and + * `/watched`, and everything a removal does (spec 0027, AC-12, AC-13). + * + * The movie grid's order exactly (`library-grid.tsx`): a removal hides the + * card at once through `useOptimistic`, then runs spec 0026's `removeGame`, + * whose `refresh()` delivers the new page in the same response, so on + * success the hidden card is simply absent from the next `items`. On failure + * the transition ends with the card still in `items`, so it reappears in + * place with no rollback code. Each removal is its own transition and toast, + * so several in a row never interfere. + * + * Undo is not optimistic: `restoreRemovedGame` puts the row back with its + * dates, and its `refresh()` returns the card to its old place. A removal + * that found no row (another tab got there first) has no `undo` and shows no + * toast. + * + * @param page The page number. Emptying a page past page 1 redirects, which + * remounts the page, so the heading has to take the focus again. + * @param returnPath The page, for the session expired toast's Sign in action. + */ +function GameLibraryGrid({ + list, + items, + label, + page, + returnPath, +}: { + list: LibraryList; + items: LibraryGameItem[]; + label: string; + page: number; + returnPath: string; +}) { + const router = useRouter(); + const gridRef = useRef(null); + const [visible, hide] = useOptimistic(items, (state, key: string) => + state.filter((item) => gameItemKey(item) !== key), + ); + + /** + * The removal that sent the focus to the heading expecting a remount. If + * this grid instead receives a refreshed page without that game, the page + * did not redirect, so the new heading must not take the focus later. + */ + const headingFocusFor = useRef(null); + useEffect(() => { + const key = headingFocusFor.current; + if (key === null || items.some((item) => gameItemKey(item) === key)) { + return; + } + headingFocusFor.current = null; + cancelLibraryHeadingFocus(key); + }, [items]); + + /** + * Focus leaves the card before it disappears: to the nearest following + * card's name link, else the nearest preceding one's, else the heading. A + * card with no link (a hidden or missing game) takes the focus on its + * remove button (AC-13). + */ + function moveFocusFrom(key: string) { + const index = visible.findIndex((item) => gameItemKey(item) === key); + const candidates = [ + ...visible.slice(index + 1), + ...visible.slice(0, index).reverse(), + ]; + + for (const target of candidates) { + const card = gridRef.current?.querySelector( + `[data-item-key="${gameItemKey(target)}"]`, + ); + const focusable = + card?.querySelector("h3 a") ?? + card?.querySelector("button"); + if (focusable) { + focusable.focus(); + return; + } + } + + const remountFor = page > 1 ? key : null; + headingFocusFor.current = remountFor; + focusLibraryHeading({ remountFor }); + } + + function remove(item: LibraryGameItem) { + const key = gameItemKey(item); + moveFocusFrom(key); + + startTransition(async () => { + hide(key); + const result = await settleResultCall(() => removeGame(item.gameId)); + if (!result.ok) { + if (headingFocusFor.current === key) { + headingFocusFor.current = null; + } + cancelLibraryHeadingFocus(key); + showGameTrackingError(result.error, { + id: gameTrackingToastId(item.gameId, "status"), + returnPath, + navigate: router.push, + }); + return; + } + + showGameUndo({ + gameId: item.gameId, + undo: result.undo, + returnPath, + navigate: router.push, + }); + }); + } + + return ( + + {visible.map((item, index) => { + const key = gameItemKey(item); + return ( +
  • + remove(item)} + priority={index < POSTER_GRID_EAGER.catalog} + /> +
  • + ); + })} +
    + ); +} + +export { GameLibraryGrid }; diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx new file mode 100644 index 00000000..3c990c3a --- /dev/null +++ b/components/library/game-library-tab.test.tsx @@ -0,0 +1,346 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { LibraryGameItem } from "./types"; + +/** + * covers: spec 0027, AC-5 to AC-11, AC-14 + * + * The games tabs through `LibrarySection`, as the page renders them. The + * session, the flag, the Postgres page and the IGDB batch are the + * boundaries and are replaced; `libraryLastPage`, the item mapping and the + * date formatting stay real. The client grid has its own suite, so here it + * is a stub that shows what it was handed. `redirect()` throws, as in Next. + */ +const requireUser = vi.fn(); +vi.mock("@/lib/auth/user", () => ({ requireUser: () => requireUser() })); + +const getGameLibraryPage = vi.fn(); +const getLibraryGames = vi.fn(); +const getShowLibraryTab = vi.fn(); +vi.mock("server-only", () => ({})); +vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() })); +vi.mock("@/lib/tmdb", () => ({ TmdbError: class extends Error {} })); +vi.mock("@/lib/igdb", () => ({ IgdbError: class extends Error {} })); +vi.mock("@/lib/tracking/game-library", () => ({ + getGameLibraryPage: (...args: unknown[]) => getGameLibraryPage(...args), + getLibraryGames: (...args: unknown[]) => getLibraryGames(...args), +})); +vi.mock("@/lib/tracking/library-lists", async (importOriginal) => ({ + ...(await importOriginal()), + getShowLibraryTab: (...args: unknown[]) => getShowLibraryTab(...args), +})); +vi.mock("@/lib/tracking/episode-state", () => ({ + requestTodayUtc: () => "2026-10-24", +})); + +class RedirectSignal extends Error { + constructor(readonly url: string) { + super(`redirect ${url}`); + } +} +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + throw new RedirectSignal(url); + }, +})); + +vi.mock("./game-library-grid", () => ({ + GameLibraryGrid: (props: { + items: LibraryGameItem[]; + label: string; + returnPath: string; + }) => ( +
      + {props.items.map((item) => ( +
    • + {JSON.stringify(item)} +
    • + ))} +
    + ), +})); + +const { LibrarySection } = await import("./library-section"); + +type List = "watchlist" | "upcoming" | "watched"; + +async function renderSection( + list: List, + params: Record = {}, +) { + return render( + await LibrarySection({ list, searchParams: Promise.resolve(params) }), + ); +} + +function received(id: number): LibraryGameItem { + return JSON.parse(screen.getByTestId(`game-${id}`).textContent ?? ""); +} + +function row(gameId: number, extra: Record = {}) { + return { + gameId, + rating: null, + addedAt: "2026-01-05T10:00:00+00:00", + startedAt: "2026-02-05T10:00:00+00:00", + finishedAt: "2026-03-05T10:00:00+00:00", + ...extra, + }; +} + +function game(id: number, extra: Record = {}) { + return { + id, + name: `Game ${id}`, + coverUrl: null, + releaseDate: "2020-05-01", + releaseYear: 2020, + communityRating: 8.1, + communityRatingCount: 100, + genreIds: [], + platformIds: [], + hidden: false, + ...extra, + }; +} + +function page(rows: unknown[], total = rows.length) { + return { kind: "ok", rows, total }; +} + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + requireUser.mockResolvedValue({ id: "user-a", email: "a@example.test" }); + getGameLibraryPage.mockResolvedValue(page([])); + getLibraryGames.mockResolvedValue({ + kind: "ok", + games: new Map(), + missingIds: [], + }); + getShowLibraryTab.mockResolvedValue({ + kind: "ok", + cards: [], + total: 0, + failedCount: 0, + capped: false, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("the flag and the parameters (AC-5)", () => { + it("answers ?type=game with the doesn't exist panel while the flag is off", async () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off"); + await renderSection("watchlist", { type: "game" }); + + expect(screen.getByText("That page doesn't exist")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Back to Watchlist" }), + ).toHaveAttribute("href", "/watchlist?type=tv"); + expect(getGameLibraryPage).not.toHaveBeenCalled(); + }); + + it("throws on an invalid flag, so the error boundary shows", async () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "maybe"); + await expect(renderSection("watchlist", { type: "game" })).rejects.toThrow( + "NEXT_PUBLIC_GAMES_LIBRARY", + ); + }); + + it("keeps type=game in a malformed page's Back to page 1 link", async () => { + await renderSection("watched", { type: "game", page: "x" }); + + expect( + screen.getByRole("link", { name: "Back to page 1" }), + ).toHaveAttribute("href", "/watched?type=game"); + expect(getGameLibraryPage).not.toHaveBeenCalled(); + }); + + it("reads nothing when requireUser refuses the request", async () => { + requireUser.mockRejectedValue(new RedirectSignal("/sign-in")); + await expect( + renderSection("upcoming", { type: "game" }), + ).rejects.toMatchObject({ url: "/sign-in" }); + expect(getGameLibraryPage).not.toHaveBeenCalled(); + }); + + it("leaves the shows tab as it was", async () => { + await renderSection("watchlist", {}); + expect(getShowLibraryTab).toHaveBeenCalled(); + expect(getGameLibraryPage).not.toHaveBeenCalled(); + }); +}); + +describe("the reads and the panels (AC-6, AC-7, AC-14)", () => { + it.each(["watchlist", "upcoming", "watched"] as const)( + "reads the %s games page for the session user and that page's games", + async (list) => { + getGameLibraryPage.mockResolvedValue(page([row(1), row(2)], 30)); + await renderSection(list, { type: "game", page: "1" }); + + expect(getGameLibraryPage).toHaveBeenCalledWith({ + userId: "user-a", + tab: list, + page: 1, + }); + expect(getLibraryGames).toHaveBeenCalledWith([1, 2]); + expect( + screen.getByRole("navigation", { name: "Pagination" }), + ).toBeInTheDocument(); + }, + ); + + it("labels each grid and keeps type=game in its return path", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1)])); + await renderSection("upcoming", { type: "game" }); + + expect( + screen.getByRole("list", { name: "Games you're playing, page 1" }), + ).toHaveAttribute("data-return-path", "/upcoming?type=game"); + }); + + it("redirects a page past the end to the last page", async () => { + getGameLibraryPage.mockResolvedValue(page([], 30)); + await expect( + renderSection("watched", { type: "game", page: "5" }), + ).rejects.toMatchObject({ url: "/watched?type=game&page=2" }); + }); + + it("redirects an empty tab past page 1 to page 1", async () => { + getGameLibraryPage.mockResolvedValue(page([], 0)); + await expect( + renderSection("watched", { type: "game", page: "3" }), + ).rejects.toMatchObject({ url: "/watched?type=game" }); + }); + + it.each([ + ["watchlist", "No games to play yet"], + ["upcoming", "Nothing in progress"], + ["watched", "No finished games yet"], + ] as const)("shows the %s empty state as text only", async (list, title) => { + await renderSection(list, { type: "game" }); + + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(getLibraryGames).not.toHaveBeenCalled(); + }); + + it("shows the list's failed panel when Postgres fails", async () => { + getGameLibraryPage.mockResolvedValue({ kind: "failed" }); + await renderSection("watchlist", { type: "game" }); + + expect( + screen.getByText("Couldn't load your watchlist"), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /try again/i })).toHaveAttribute( + "href", + "/watchlist?type=game", + ); + }); + + it("shows Couldn't reach IGDB, never the empty state, when IGDB fails", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1)], 1)); + getLibraryGames.mockResolvedValue({ kind: "failed" }); + await renderSection("watched", { type: "game", page: "1" }); + + expect(screen.getByText("Couldn't reach IGDB")).toBeInTheDocument(); + expect( + screen.getByText( + "IGDB didn't respond, so the games couldn't load. Try again in a moment.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText("No finished games yet")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /try again/i })).toHaveAttribute( + "href", + "/watched?type=game", + ); + }); +}); + +describe("the cards (AC-8 to AC-11)", () => { + function withGames(...games: ReturnType[]) { + getLibraryGames.mockResolvedValue({ + kind: "ok", + games: new Map(games.map((g) => [g.id, g])), + missingIds: [], + }); + } + + it("marks an unreleased Want to play game with its date, or Date TBA", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1), row(2), row(3)])); + withGames( + game(1), + game(2, { releaseDate: "2027-02-03" }), + game(3, { releaseDate: null }), + ); + await renderSection("watchlist", { type: "game" }); + + expect(received(1).release).toEqual({ state: "released" }); + expect(received(2).release).toEqual({ + state: "unreleased", + date: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" }, + }); + expect(received(3).release).toEqual({ state: "unreleased", date: null }); + expect(received(1).dateLine).toBeNull(); + }); + + it("counts the release day itself as released", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1)])); + withGames(game(1, { releaseDate: "2026-10-24" })); + await renderSection("watchlist", { type: "game" }); + + expect(received(1).release).toEqual({ state: "released" }); + }); + + it("gives Playing a Started line and Finished a Finished line", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1, { rating: 8 })])); + withGames(game(1)); + const playing = await renderSection("upcoming", { type: "game" }); + expect(received(1)).toMatchObject({ + dateLine: "Started Feb 2026", + rating: 8, + }); + playing.unmount(); + + getGameLibraryPage.mockResolvedValue(page([row(1)])); + await renderSection("watched", { type: "game" }); + expect(received(1).dateLine).toBe("Finished Mar 2026"); + }); + + it("ignores a score on a Want to play row", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1, { rating: 7 })])); + withGames(game(1)); + await renderSection("watchlist", { type: "game" }); + + expect(received(1).rating).toBeNull(); + }); + + it("passes hidden through, and invents nothing for a missing game", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1), row(2)])); + getLibraryGames.mockResolvedValue({ + kind: "ok", + games: new Map([[2, game(2, { hidden: true })]]), + missingIds: [1], + }); + await renderSection("watched", { type: "game" }); + + expect(received(1)).toEqual({ + gameId: 1, + name: null, + coverUrl: null, + communityRating: null, + rating: null, + hidden: false, + release: { state: "released" }, + dateLine: null, + }); + expect(received(2).hidden).toBe(true); + expect( + screen.getAllByRole("listitem").map((item) => item.dataset.testid), + ).toEqual(["game-1", "game-2"]); + }); +}); diff --git a/components/library/game-library-tab.tsx b/components/library/game-library-tab.tsx new file mode 100644 index 00000000..3a70bbbd --- /dev/null +++ b/components/library/game-library-tab.tsx @@ -0,0 +1,140 @@ +import { redirect } from "next/navigation"; + +import { PaginationLinks } from "@/components/pagination-links"; +import { isGameReleased } from "@/lib/catalog/game-page"; +import { formatMonthYear, formatReleaseDate } from "@/lib/format"; +import type { GameSummary } from "@/lib/igdb"; +import { + type GameLibraryRow, + getGameLibraryPage, + getLibraryGames, +} from "@/lib/tracking/game-library"; +import { libraryLastPage } from "@/lib/tracking/library-lists"; +import { GAME_DATE_LINES, LIBRARY_COPY } from "@/lib/tracking/messages"; + +import { GameLibraryGrid } from "./game-library-grid"; +import { + CatalogFailed, + EmptyPanel, + ListFailed, + pageHref, +} from "./library-panels"; +import type { LibraryGameItem, LibraryList } from "./types"; + +/** + * A games tab: Want to play on `/watchlist`, Playing on `/upcoming`, + * Finished on `/watched` (spec 0027, AC-6 to AC-14). + * + * One counted Postgres page, a redirect when the page is past the end, then + * one IGDB batch for that page's games. A failed Postgres read is the list's + * own failed panel, a systemic IGDB failure "Couldn't reach IGDB"; neither + * is ever the empty state. `requireUser()` already ran in `LibrarySection`, + * and nothing here writes (spec 0020, AC-21). + * + * @param query.today `requestTodayUtc()`, read once per request, which + * decides whether a Want to play game is out yet (AC-8). + */ +export async function gameLibraryTab({ + tab: list, + userId, + page, + today, +}: { + tab: LibraryList; + userId: string; + page: number; + today: string; +}) { + const result = await getGameLibraryPage({ userId, tab: list, page }); + if (result.kind === "failed") { + return ; + } + const lastPage = libraryLastPage(result.total); + if (page > lastPage) redirect(pageHref(list, "game", lastPage)); + + if (result.rows.length === 0) { + return ; + } + + const games = await getLibraryGames(result.rows.map((row) => row.gameId)); + if (games.kind === "failed") { + return ; + } + + const items = result.rows.map((row) => + gameItem(list, row, games.games.get(row.gameId), today), + ); + + return ( +
    + + + {lastPage > 1 ? ( + pageHref(list, "game", target)} + /> + ) : null} +
    + ); +} + +/** + * A game card's plain data, with its release pill and date line formatted + * here, beside the `today` that decided them. A game IGDB did not return is + * a missing card at its place in the order (AC-11): no name, cover, badge + * or date is invented for it. + */ +function gameItem( + list: LibraryList, + row: GameLibraryRow, + game: GameSummary | undefined, + today: string, +): LibraryGameItem { + if (game === undefined) { + return { + gameId: row.gameId, + name: null, + coverUrl: null, + communityRating: null, + rating: null, + hidden: false, + release: { state: "released" }, + dateLine: null, + }; + } + + return { + gameId: row.gameId, + name: game.name, + coverUrl: game.coverUrl, + communityRating: game.communityRating, + rating: list === "watchlist" ? null : row.rating, + hidden: game.hidden, + release: isGameReleased(game.releaseDate, today) + ? { state: "released" } + : { + state: "unreleased", + date: formatReleaseDate(game.releaseDate, today), + }, + dateLine: dateLine(list, row), + }; +} + +/** `Started Oct 2026` on Playing, `Finished Oct 2026` on Finished (AC-9). */ +function dateLine(list: LibraryList, row: GameLibraryRow): string | null { + if (list === "watchlist") return null; + const iso = list === "upcoming" ? row.startedAt : row.finishedAt; + const month = iso === null ? null : formatMonthYear(iso); + if (month === null) return null; + return list === "upcoming" + ? GAME_DATE_LINES.started(month) + : GAME_DATE_LINES.finished(month); +} diff --git a/components/library/library-error-boundary.test.tsx b/components/library/library-error-boundary.test.tsx new file mode 100644 index 00000000..66e8ac82 --- /dev/null +++ b/components/library/library-error-boundary.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LibraryErrorBoundary } from "./library-error-boundary"; + +/** + * covers: spec 0027, AC-5 (an invalid flag shows the library error boundary) + * + * The section is replaced by a child that throws, the way `LibrarySection` + * does on a bad `NEXT_PUBLIC_GAMES_LIBRARY`. React logs every caught error, + * so the console is silenced for the throwing cases. + */ +function Throws(): never { + throw new Error("NEXT_PUBLIC_GAMES_LIBRARY must be on or off"); +} + +describe("LibraryErrorBoundary", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders its children when nothing throws", () => { + render( + +

    The grid

    +
    , + ); + expect(screen.getByText("The grid")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it.each([ + ["watchlist", "Couldn't load your watchlist"], + ["upcoming", "Couldn't load your upcoming titles"], + ["watched", "Couldn't load your watched titles"], + ] as const)("shows the %s failed panel with a retry", (list, title) => { + render( + + + , + ); + const panel = screen.getByRole("alert"); + expect(panel).toHaveTextContent(title); + expect(panel).toHaveTextContent( + "Your list didn't load. Try again in a moment.", + ); + expect(screen.getByRole("button", { name: "Try again" })).toBeVisible(); + }); + + it("never shows the error's own message", () => { + render( + + + , + ); + expect( + screen.queryByText(/NEXT_PUBLIC_GAMES_LIBRARY/), + ).not.toBeInTheDocument(); + }); +}); diff --git a/components/library/library-error-boundary.tsx b/components/library/library-error-boundary.tsx new file mode 100644 index 00000000..d6702776 --- /dev/null +++ b/components/library/library-error-boundary.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { catchError, type ErrorInfo } from "next/error"; + +import { StatePanel } from "@/components/state-panel"; +import { Button } from "@/components/ui/button"; +import { LIBRARY_COPY } from "@/lib/tracking/messages"; + +import type { LibraryList } from "./types"; + +/** + * The panel a library page shows when its section throws instead of + * returning a panel of its own: an invalid `NEXT_PUBLIC_GAMES_LIBRARY` value + * (spec 0027, AC-5), or any read that fails in a way the tabs do not settle. + * + * It wraps only the streamed section, so the heading stays in the static + * shell and keeps the focus target removals rely on (spec 0008, AC-16). It + * reads as `ListFailed` does, never as the empty state, which would tell the + * user their list is gone, and never shows the error's message: a server + * error reaches the client only as a digest anyway. + * + * `catchError` rather than a hand written boundary, because it lets the + * section's `redirect()` (a page past the end, a lost session) pass through, + * and its `retry()` fetches the section again from the server, where a plain + * reset would replay the same error. + */ +function LibraryErrorFallback( + { list }: { list: LibraryList }, + { retry }: ErrorInfo, +) { + return ( +
    + retry()}> + Try again + + } + /> +
    + ); +} + +const LibraryErrorBoundary = catchError(LibraryErrorFallback); + +export { LibraryErrorBoundary }; diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx index ead43bf1..dca245aa 100644 --- a/components/library/library-panels.tsx +++ b/components/library/library-panels.tsx @@ -8,6 +8,7 @@ import { StatePanel } from "@/components/state-panel"; import { ButtonLink } from "@/components/ui/button"; import { CATALOG_PATHS, + type LibraryType, type MediaType, typedHref, } from "@/lib/catalog/media-type"; @@ -32,7 +33,7 @@ import type { LibraryList } from "./types"; */ export function pageHref( list: LibraryList, - type: MediaType, + type: LibraryType, page: number, ): string { return typedHref(`/${list}`, type, { page }); @@ -121,12 +122,17 @@ export function TabNotes({ ); } +/** + * A tab with nothing on it. A show or movie tab offers its catalog; a games + * tab is text only, because there is no games browse page until G6 (spec + * 0027, AC-14). + */ export function EmptyPanel({ list, type, }: { list: LibraryList; - type: MediaType; + type: LibraryType; }) { const copy = LIBRARY_COPY[list][type]; return ( @@ -136,9 +142,11 @@ export function EmptyPanel({ title={copy.empty.title} description={copy.empty.description} action={ - - {copy.browse} - + type !== "game" && copy.browse ? ( + + {copy.browse} + + ) : undefined } /> @@ -158,7 +166,7 @@ export function NoSuchPage({ type, }: { list: LibraryList; - type: MediaType | null; + type: LibraryType | null; }) { return (
    @@ -187,7 +195,7 @@ export function ListFailed({ page, }: { list: LibraryList; - type: MediaType; + type: LibraryType; page: number; }) { return ( @@ -199,17 +207,34 @@ export function ListFailed({ ); } -/** A systemic TMDB failure: a rejected credential or a rate limit (AC-17). */ -export function TmdbFailed({ href }: { href: string }) { +/** + * A systemic catalog failure: a rejected credential or a rate limit (spec + * 0020, AC-17), from TMDB for shows and movies or IGDB for games (spec 0027, + * AC-7). Never the empty state, which would tell the user their list is gone. + */ +export function CatalogFailed({ + source, + href, +}: { + source: "TMDB" | "IGDB"; + href: string; +}) { + const what = source === "TMDB" ? "titles" : "games"; return ( ); } +/** A systemic TMDB failure (spec 0020, AC-17). */ +export function TmdbFailed({ href }: { href: string }) { + return ; +} + +/** The error panel every failed library read shows, with Retry. */ function LoadFailed({ title, description, diff --git a/components/library/library-section.tsx b/components/library/library-section.tsx index ff267bd6..4eb47d3a 100644 --- a/components/library/library-section.tsx +++ b/components/library/library-section.tsx @@ -1,8 +1,10 @@ import { requireUser } from "@/lib/auth/user"; -import { parseMediaTypeParam } from "@/lib/catalog/media-type"; +import { parseLibraryTypeParam } from "@/lib/catalog/media-type"; import { parsePageParam } from "@/lib/catalog/pages"; +import { getGamesLibrary } from "@/lib/env"; import { requestTodayUtc } from "@/lib/tracking/episode-state"; +import { gameLibraryTab } from "./game-library-tab"; import { LibrarySkeleton, NoSuchPage } from "./library-panels"; import { movieLibraryTab, watchedMoviesTab } from "./movie-library-tab"; import { showLibraryTab } from "./show-library-tab"; @@ -13,13 +15,16 @@ import type { LibraryList } from "./types"; * spec 0008 for the shared states). It streams behind the page's Suspense * boundary, because it reads the session, the search params and the user's * rows. It lists one media type, the navbar tab's `type` parameter - * (feature 22). + * (feature 22), or games while `NEXT_PUBLIC_GAMES_LIBRARY` is on (spec 0027, + * AC-5). An invalid flag value throws here, so a misconfigured deploy shows + * the library error boundary instead of guessing. * * The order is deliberate. `requireUser()` first, so a request that got past * the proxy still receives no list data. Then the type and page parameters, * so a malformed value never costs a query. Then today, read once for the * request (AC-22), and the tab's own read: a classified tab for shows and for - * planned movies, the plain watched query for watched movies (AC-16). A + * planned movies, the plain watched query for watched movies (AC-16), and + * one stored status page for games (spec 0027, AC-6). A * redirect when the page is past the end, outside any `try`. * * Nothing here writes (AC-21). A failure of any read replaces the grid with @@ -36,12 +41,15 @@ async function LibrarySection({ const user = await requireUser(); const raw = await searchParams; - const type = parseMediaTypeParam(raw.type); + const type = parseLibraryTypeParam(raw.type, getGamesLibrary() === "on"); const page = parsePageParam(raw.page); if (type === null) return ; if (page === null) return ; const today = requestTodayUtc(); + if (type === "game") { + return gameLibraryTab({ tab: list, userId: user.id, page, today }); + } if (type === "tv") { return showLibraryTab({ tab: list, userId: user.id, page, today }); } diff --git a/components/library/types.ts b/components/library/types.ts index 7a4f6c0c..06fe6c81 100644 --- a/components/library/types.ts +++ b/components/library/types.ts @@ -41,3 +41,33 @@ export type LibraryMovieItem = { export function libraryItemKey(item: Pick) { return `movie-${item.tmdbId}`; } + +/** + * One game card on a games tab (spec 0027, API surface): a `user_game_state` + * row joined with its IGDB game, when IGDB has it. + */ +export type LibraryGameItem = { + /** The IGDB id. */ + gameId: number; + /** Null when IGDB no longer has the game (`missingIds`, AC-11). */ + name: string | null; + coverUrl: string | null; + /** IGDB's community rating out of 10; the badge with no personal score. */ + communityRating: number | null; + /** The personal score; only a Playing or Finished card shows it. */ + rating: number | null; + /** + * True when IGDB's catalog filter hides the game: its page is a soft 404 + * (spec 0025, AC-7), so the card has no link (AC-10). + */ + hidden: boolean; + /** Only Want to play cares; an unreleased `date: null` is Date TBA. */ + release: { state: "released" } | { state: "unreleased"; date: ReleaseDate }; + /** `Started Oct 2026` or `Finished Oct 2026`, already formatted. */ + dateLine: string | null; +}; + +/** One key per game card, apart from the movie keys. */ +export function gameItemKey(item: Pick) { + return `game-${item.gameId}`; +} From d1d5dce609c558c917665ec36ec4610282ca67d8 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:24:16 +0100 Subject: [PATCH 07/19] feat(navbar): add the GAMES tab on the library pages Co-Authored-By: Claude Opus 5.5 --- components/layout/library-nav.test.tsx | 17 +++ components/layout/library-nav.tsx | 24 ++-- components/layout/media-type-tabs.test.tsx | 132 ++++++++++++++++++++- components/layout/media-type-tabs.tsx | 77 +++++++----- components/layout/navbar.test.tsx | 37 +++++- components/layout/page-media-type.tsx | 89 +++++++++++--- components/search/navbar-search.tsx | 3 +- 7 files changed, 321 insertions(+), 58 deletions(-) diff --git a/components/layout/library-nav.test.tsx b/components/layout/library-nav.test.tsx index 2ba7e0af..c1fc9d11 100644 --- a/components/layout/library-nav.test.tsx +++ b/components/layout/library-nav.test.tsx @@ -112,4 +112,21 @@ describe("LibraryNav", () => { ).not.toBeInTheDocument(); }, ); + + // covers: spec 0027, AC-4 + it("carries type=game on a games tab while the games library is on", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + pathname = "/upcoming"; + search = "type=game"; + render(); + + expect( + screen.getAllByRole("link").map((link) => link.getAttribute("href")), + ).toEqual([ + "/watchlist?type=game", + "/upcoming?type=game", + "/watched?type=game", + ]); + vi.unstubAllEnvs(); + }); }); diff --git a/components/layout/library-nav.tsx b/components/layout/library-nav.tsx index cbd9f327..cc6e8c28 100644 --- a/components/layout/library-nav.tsx +++ b/components/layout/library-nav.tsx @@ -3,9 +3,9 @@ import { cn } from "cn"; import Link from "next/link"; -import { type MediaType, typedHref } from "@/lib/catalog/media-type"; +import { type LibraryType, typedHref } from "@/lib/catalog/media-type"; -import { WithTypedPage } from "./page-media-type"; +import { WithLibraryTypedPage } from "./page-media-type"; /** * The signed in library links: the pill in the desktop navbar and the list in @@ -20,8 +20,10 @@ import { WithTypedPage } from "./page-media-type"; * Upcoming sits between the two (spec 0014, AC-1). * * Each link carries the media type of the page being viewed, so the navbar - * tab chosen on `/movies` is still lit on `/watchlist` (feature 22), read - * through `WithTypedPage`; the fallback links to the default type. On a + * tab chosen on `/movies` is still lit on `/watchlist` (feature 22), and on a + * games tab they carry `type=game`, so moving between the three pages keeps + * you on games (spec 0027, AC-4). It is read through `WithLibraryTypedPage`; + * the fallback links to the default type. On a * page about neither catalog it renders nothing, like the media tabs and the * search (prompts/hide-tabs-without-type.md). * @@ -41,15 +43,19 @@ type Variant = "bar" | "sheet"; function LibraryNav({ variant }: { variant: Variant }) { return ( - ( )} > - {({ pathname, type }) => ( - + {({ pathname, libraryType }) => ( + )} - + ); } @@ -61,7 +67,7 @@ function LibraryNavView({ }: { variant: Variant; pathname: string; - type: MediaType; + type: LibraryType; }) { return (
    ); diff --git a/components/tracking/game-tracking-slot.tsx b/components/tracking/game-tracking-slot.tsx index 8198d01c..0bfb285d 100644 --- a/components/tracking/game-tracking-slot.tsx +++ b/components/tracking/game-tracking-slot.tsx @@ -1,5 +1,6 @@ import { RetryLink } from "@/components/retry-link"; import { gameReleaseNote, isGameReleased } from "@/lib/catalog/game-page"; +import { gameHref } from "@/lib/catalog/media-type"; import { requestTodayUtc } from "@/lib/tracking/episode-state"; import { getGameTracking } from "@/lib/tracking/game-state"; import { TRACKING_READ_FAILED } from "@/lib/tracking/messages"; @@ -35,7 +36,7 @@ async function GameTrackingSlot({ releaseDate: string | null; }) { const result = await getGameTracking(gameId); - const returnPath = `/games/${gameId}`; + const returnPath = gameHref(gameId); if (result.kind === "signed_out") return null; diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts index fa94d73d..2be966b8 100644 --- a/lib/catalog/media-type.ts +++ b/lib/catalog/media-type.ts @@ -110,7 +110,11 @@ export function titleHref(type: MediaType, id: number): string { return `${CATALOG_PATHS[type]}/${id}`; } -/** A game's own page, `/games/{id}`, beside `titleHref` (spec 0027). */ +/** + * A game's own page, `/games/{id}`, beside `titleHref` (spec 0027). The one + * builder of a game page URL: the library cards, the game page's Retry link + * and its tracking slot's return path all use it. + */ export function gameHref(id: number): string { return `/games/${id}`; } From fe29436a606a5a56c2873fbf299c30acd8951699 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:36:53 +0100 Subject: [PATCH 11/19] refactor(library): share the removal focus order between the movie and game grids Co-Authored-By: Claude Opus 5.5 --- components/AGENTS.md | 2 +- components/library/game-library-grid.tsx | 64 +++-------------- components/library/library-grid.tsx | 65 +++--------------- components/library/use-removal-focus.ts | 87 ++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 110 deletions(-) create mode 100644 components/library/use-removal-focus.ts diff --git a/components/AGENTS.md b/components/AGENTS.md index b3dfeed8..63d1da58 100644 --- a/components/AGENTS.md +++ b/components/AGENTS.md @@ -45,7 +45,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline` - `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006). - `components/catalog/` holds the title page sections both `/movies/{id}` and `/shows/{id}` share (spec 0022): `title-details.tsx` (crew and Facts, from the detail read), and `title-extras.tsx`, `where-to-watch.tsx` and `trailer-button.tsx`, fed by the separate extras read. The extras sections stream in their own Suspense boundary and read the visitor's region there through `getVisitorRegion()` (`lib/region.ts`), never in the shell. The trailer iframe (`youtube-nocookie`) mounts only while its dialog is open. - `components/catalog/landing/` holds the `/movies` and `/shows` landing sections, all Server Components except `landing-parallax.tsx` (`LandingParallax`), the landing's one client island, outside the shell. It writes only inline transforms to the `data-*` nodes the sections render, so every card and link stays server HTML. It and `layout/page-background-motion.tsx` share `startMotionLoop` (`lib/motion/loop.ts`), which owns the reduced motion stop and the mouse-only lean; a new motion loop uses it too. -- `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo. The games tabs (spec 0027) are `game-library-tab.tsx` (server), `game-library-grid.tsx` (client, the movie grid's `useOptimistic` and focus order copied on purpose, so a removal fix may need making in both) and `game-library-card.tsx`; their Undo toast is the plain `showGameUndo` in `components/tracking/use-game-undo.ts`. `library-error-boundary.tsx` (`catchError`) wraps each page's streamed section, below the heading, so `redirect()` still passes through. +- `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo. The games tabs (spec 0027) are `game-library-tab.tsx` (server), `game-library-grid.tsx` (client, the movie grid's `useOptimistic` order copied on purpose, so a removal fix may need making in both; the focus order after a removal is shared through `use-removal-focus.ts`) and `game-library-card.tsx`; their Undo toast is the plain `showGameUndo` in `components/tracking/use-game-undo.ts`. `library-error-boundary.tsx` (`catchError`) wraps each page's streamed section, below the heading, so `redirect()` still passes through. - `components/layout/site-footer.tsx` renders under every route from the root layout and must read no request state (`app/layout-purity.test.ts`). It is flat (no glass, no border) and carries no email address. Its credit line links `OPERATOR_HANDLE` to `OPERATOR_GITHUB_URL`, both from `lib/legal/operator.ts`. `components/legal/` holds the reading column and helpers `/privacy` and `/terms` share; use `LEGAL_LINK_CLASS` for any link in legal text (spec 0017). - The card marks are explained once, by `layout/icon-key.tsx`, the footer's first row on every route; there is no per page legend. A new mark adds its entry there, and a new badge must reuse `GlassPill` rather than re-derive it. diff --git a/components/library/game-library-grid.tsx b/components/library/game-library-grid.tsx index 53bb1656..ceaa6a0f 100644 --- a/components/library/game-library-grid.tsx +++ b/components/library/game-library-grid.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { startTransition, useEffect, useOptimistic, useRef } from "react"; +import { startTransition, useOptimistic, useRef } from "react"; import { removeGame } from "@/app/games/actions"; import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; @@ -13,11 +13,8 @@ import { import { showGameUndo } from "@/components/tracking/use-game-undo"; import { GameLibraryCard } from "./game-library-card"; -import { - cancelLibraryHeadingFocus, - focusLibraryHeading, -} from "./library-heading"; import { gameItemKey, type LibraryGameItem, type LibraryList } from "./types"; +import { useRemovalFocus } from "./use-removal-focus"; /** * The game grid on the games tabs of `/watchlist`, `/upcoming` and @@ -59,51 +56,13 @@ function GameLibraryGrid({ state.filter((item) => gameItemKey(item) !== key), ); - /** - * The removal that sent the focus to the heading expecting a remount. If - * this grid instead receives a refreshed page without that game, the page - * did not redirect, so the new heading must not take the focus later. - */ - const headingFocusFor = useRef(null); - useEffect(() => { - const key = headingFocusFor.current; - if (key === null || items.some((item) => gameItemKey(item) === key)) { - return; - } - headingFocusFor.current = null; - cancelLibraryHeadingFocus(key); - }, [items]); - - /** - * Focus leaves the card before it disappears: to the nearest following - * card's name link, else the nearest preceding one's, else the heading. A - * card with no link (a hidden or missing game) takes the focus on its - * remove button (AC-13). - */ - function moveFocusFrom(key: string) { - const index = visible.findIndex((item) => gameItemKey(item) === key); - const candidates = [ - ...visible.slice(index + 1), - ...visible.slice(0, index).reverse(), - ]; - - for (const target of candidates) { - const card = gridRef.current?.querySelector( - `[data-item-key="${gameItemKey(target)}"]`, - ); - const focusable = - card?.querySelector("h3 a") ?? - card?.querySelector("button"); - if (focusable) { - focusable.focus(); - return; - } - } - - const remountFor = page > 1 ? key : null; - headingFocusFor.current = remountFor; - focusLibraryHeading({ remountFor }); - } + const { moveFocusFrom, removalFailed } = useRemovalFocus({ + items, + visible, + itemKey: gameItemKey, + page, + gridRef, + }); function remove(item: LibraryGameItem) { const key = gameItemKey(item); @@ -113,10 +72,7 @@ function GameLibraryGrid({ hide(key); const result = await settleResultCall(() => removeGame(item.gameId)); if (!result.ok) { - if (headingFocusFor.current === key) { - headingFocusFor.current = null; - } - cancelLibraryHeadingFocus(key); + removalFailed(key); showGameTrackingError(result.error, { id: gameTrackingToastId(item.gameId, "status"), returnPath, diff --git a/components/library/library-grid.tsx b/components/library/library-grid.tsx index fad14a97..5bf5d060 100644 --- a/components/library/library-grid.tsx +++ b/components/library/library-grid.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { startTransition, useEffect, useOptimistic, useRef } from "react"; +import { startTransition, useOptimistic, useRef } from "react"; import { toast } from "sonner"; import { @@ -28,15 +28,12 @@ import type { } from "@/lib/tracking/types"; import { LibraryCard } from "./library-card"; -import { - cancelLibraryHeadingFocus, - focusLibraryHeading, -} from "./library-heading"; import { type LibraryList, type LibraryMovieItem, libraryItemKey, } from "./types"; +import { useRemovalFocus } from "./use-removal-focus"; /** * The movie grid on `/watchlist`, `/upcoming` and `/watched`, and everything @@ -82,52 +79,13 @@ function LibraryGrid({ state.filter((item) => libraryItemKey(item) !== key), ); - /** - * The removal that sent the focus to the heading expecting a remount. If - * this grid instead receives a refreshed page without that movie, the page - * did not redirect (later pages moved up), so the new heading must not - * take the focus on some later visit. - */ - const headingFocusFor = useRef(null); - useEffect(() => { - const key = headingFocusFor.current; - if (key === null || items.some((item) => libraryItemKey(item) === key)) { - return; - } - headingFocusFor.current = null; - cancelLibraryHeadingFocus(key); - }, [items]); - - /** - * Focus leaves the card before it disappears: to the nearest following - * card's title link, else the nearest preceding one's, else the heading. A - * card with no title link (a missing title) takes the focus on its remove - * button (AC-16). - */ - function moveFocusFrom(key: string) { - const index = visible.findIndex((item) => libraryItemKey(item) === key); - const candidates = [ - ...visible.slice(index + 1), - ...visible.slice(0, index).reverse(), - ]; - - for (const target of candidates) { - const card = gridRef.current?.querySelector( - `[data-item-key="${libraryItemKey(target)}"]`, - ); - const focusable = - card?.querySelector("h3 a") ?? - card?.querySelector("button"); - if (focusable) { - focusable.focus(); - return; - } - } - - const remountFor = page > 1 ? key : null; - headingFocusFor.current = remountFor; - focusLibraryHeading({ remountFor }); - } + const { moveFocusFrom, removalFailed } = useRemovalFocus({ + items, + visible, + itemKey: libraryItemKey, + page, + gridRef, + }); function onError(error: MovieTrackingError, item: LibraryMovieItem) { showTrackingError(error, { @@ -166,10 +124,7 @@ function LibraryGrid({ ); if (!result.ok) { const { error } = result; - if (headingFocusFor.current === key) { - headingFocusFor.current = null; - } - cancelLibraryHeadingFocus(key); + removalFailed(key); onError(error, item); return; } diff --git a/components/library/use-removal-focus.ts b/components/library/use-removal-focus.ts new file mode 100644 index 00000000..779d5761 --- /dev/null +++ b/components/library/use-removal-focus.ts @@ -0,0 +1,87 @@ +import { type RefObject, useEffect, useRef } from "react"; + +import { + cancelLibraryHeadingFocus, + focusLibraryHeading, +} from "./library-heading"; + +/** + * Where the focus goes when a library card is removed, shared by the movie + * grid and the game grid so a fix lands in both (spec 0008, AC-16; spec + * 0027, AC-13). + * + * Focus leaves the card before it disappears: to the nearest following + * card's name link, else the nearest preceding one's, else the heading. A + * card with no link (a missing title, or a hidden game) takes the focus on + * its remove button. Emptying a page past page 1 redirects, which remounts + * the page, so the heading is asked to take the focus again after the + * remount; when the refreshed page arrives without a redirect, or the + * removal fails, that request is cancelled, so the new heading never takes + * the focus on some later visit. + * + * @param items The server's cards; a refresh without the removed card means + * the page did not redirect. + * @param visible The cards on screen, with the optimistic removals applied. + * @param itemKey The card key the grid writes to `data-item-key`. + * @param page The page number; only a page past 1 can redirect. + * @returns `moveFocusFrom`, called before a card is hidden, and + * `removalFailed`, called when its removal fails and the card stays. + */ +export function useRemovalFocus({ + items, + visible, + itemKey, + page, + gridRef, +}: { + items: readonly Item[]; + visible: readonly Item[]; + itemKey: (item: Item) => string; + page: number; + gridRef: RefObject; +}) { + /** The removal that sent the focus to the heading expecting a remount. */ + const headingFocusFor = useRef(null); + useEffect(() => { + const key = headingFocusFor.current; + if (key === null || items.some((item) => itemKey(item) === key)) { + return; + } + headingFocusFor.current = null; + cancelLibraryHeadingFocus(key); + }, [items, itemKey]); + + function moveFocusFrom(key: string) { + const index = visible.findIndex((item) => itemKey(item) === key); + const candidates = [ + ...visible.slice(index + 1), + ...visible.slice(0, index).reverse(), + ]; + + for (const target of candidates) { + const card = gridRef.current?.querySelector( + `[data-item-key="${itemKey(target)}"]`, + ); + const focusable = + card?.querySelector("h3 a") ?? + card?.querySelector("button"); + if (focusable) { + focusable.focus(); + return; + } + } + + const remountFor = page > 1 ? key : null; + headingFocusFor.current = remountFor; + focusLibraryHeading({ remountFor }); + } + + function removalFailed(key: string) { + if (headingFocusFor.current === key) { + headingFocusFor.current = null; + } + cancelLibraryHeadingFocus(key); + } + + return { moveFocusFrom, removalFailed }; +} From 7c407aae49d48b4194315876fd77bfcd40839ec3 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:37:49 +0100 Subject: [PATCH 12/19] refactor(tracking): share the counted page read between Watched movies and the games tabs Co-Authored-By: Claude Opus 5.5 --- lib/tracking/game-library.ts | 59 +++++++------------- lib/tracking/library-lists.ts | 102 +++++++++++++++++++++++++--------- 2 files changed, 96 insertions(+), 65 deletions(-) diff --git a/lib/tracking/game-library.ts b/lib/tracking/game-library.ts index 73ed4eae..bfba2e4d 100644 --- a/lib/tracking/game-library.ts +++ b/lib/tracking/game-library.ts @@ -2,14 +2,8 @@ import "server-only"; import type { LibraryList } from "@/lib/catalog/library-list"; import { type GameSummary, getGamesByIds, IgdbError } from "@/lib/igdb"; -import { createClient } from "@/lib/supabase/server"; -import { - LIBRARY_PAGE_SIZE, - type LibraryPage, - listReadFailed, - RANGE_NOT_SATISFIABLE, -} from "./library-lists"; +import { type LibraryPage, readCountedPage } from "./library-lists"; import { logTrackingEvent, TRACKING_EVENT } from "./log"; import type { GameStatus } from "./types"; @@ -62,7 +56,7 @@ export type GameLibraryRow = { * @param query.userId The verified session's user, never a client value. * @param query.page A page already parsed by `parsePageParam`. */ -export async function getGameLibraryPage({ +export function getGameLibraryPage({ userId, tab, page, @@ -72,45 +66,34 @@ export async function getGameLibraryPage({ page: number; }): Promise> { const { status, order } = GAME_LIBRARY_TABS[tab]; - try { - const supabase = await createClient(); - const from = (page - 1) * LIBRARY_PAGE_SIZE; - const { data, error, count } = await supabase - .from("user_game_state") - .select("game_id, rating, added_at, started_at, finished_at", { - count: "exact", - }) - .eq("user_id", userId) - .eq("status", status) - .order(order, { ascending: false }) - .order("game_id", { ascending: true }) - .range(from, from + LIBRARY_PAGE_SIZE - 1); - - if (error?.code === RANGE_NOT_SATISFIABLE) { - const head = await supabase + return readCountedPage({ + page, + read: (supabase, from, to) => + supabase + .from("user_game_state") + .select("game_id, rating, added_at, started_at, finished_at", { + count: "exact", + }) + .eq("user_id", userId) + .eq("status", status) + .order(order, { ascending: false }) + .order("game_id", { ascending: true }) + .range(from, to), + count: (supabase) => + supabase .from("user_game_state") .select("game_id", { count: "exact", head: true }) .eq("user_id", userId) - .eq("status", status); - if (head.error || head.count === null) return listReadFailed(); - return { kind: "ok", rows: [], total: head.count }; - } - if (error || count === null) return listReadFailed(); - - return { - kind: "ok", - total: count, - rows: data.map((row) => ({ + .eq("status", status), + map: (rows) => + rows.map((row) => ({ gameId: row.game_id, rating: row.rating, addedAt: row.added_at, startedAt: row.started_at, finishedAt: row.finished_at, })), - }; - } catch { - return listReadFailed(); - } + }); } /** A page's games, keyed by IGDB id, or a systemic IGDB failure. */ diff --git a/lib/tracking/library-lists.ts b/lib/tracking/library-lists.ts index 5cef2970..8acc0018 100644 --- a/lib/tracking/library-lists.ts +++ b/lib/tracking/library-lists.ts @@ -150,7 +150,7 @@ const EPISODE_PAGE_SIZE = 1000; * needs the total to redirect to the last page, so it is read again as a * count alone. */ -export const RANGE_NOT_SATISFIABLE = "PGRST103"; +const RANGE_NOT_SATISFIABLE = "PGRST103"; /** The 0 based slice of a 1 based page. */ function pageSlice(items: readonly T[], page: number): T[] { @@ -539,37 +539,29 @@ export type WatchedMovieRow = { * @param userId The verified session's user, never a client value. * @param page A page already parsed by `parsePageParam`. */ -export async function getWatchedMoviesPage( +export function getWatchedMoviesPage( userId: string, page: number, ): Promise> { - try { - const supabase = await createClient(); - const from = (page - 1) * LIBRARY_PAGE_SIZE; - const { data, error, count } = await supabase - .from("user_movie_state") - .select("movie_id, watched_at, rating", { count: "exact" }) - .eq("user_id", userId) - .not("watched_at", "is", null) - .order("watched_at", { ascending: false }) - .order("movie_id", { ascending: true }) - .range(from, from + LIBRARY_PAGE_SIZE - 1); - - if (error?.code === RANGE_NOT_SATISFIABLE) { - const head = await supabase + return readCountedPage({ + page, + read: (supabase, from, to) => + supabase + .from("user_movie_state") + .select("movie_id, watched_at, rating", { count: "exact" }) + .eq("user_id", userId) + .not("watched_at", "is", null) + .order("watched_at", { ascending: false }) + .order("movie_id", { ascending: true }) + .range(from, to), + count: (supabase) => + supabase .from("user_movie_state") .select("movie_id", { count: "exact", head: true }) .eq("user_id", userId) - .not("watched_at", "is", null); - if (head.error || head.count === null) return listReadFailed(); - return { kind: "ok", rows: [], total: head.count }; - } - if (error || count === null) return listReadFailed(); - - return { - kind: "ok", - total: count, - rows: data.flatMap((row) => + .not("watched_at", "is", null), + map: (rows) => + rows.flatMap((row) => row.watched_at === null ? [] : [ @@ -580,7 +572,63 @@ export async function getWatchedMoviesPage( }, ], ), - }; + }); +} + +/** The server client a counted page read runs its queries on. */ +type ServerClient = Awaited>; + +/** + * One ordered, counted Postgres page of a tab that needs no classification: + * Watched movies (spec 0020, AC-16) and every games tab (spec 0027, AC-6). + * The one home of the page slice, the past the end recount and the failure + * paths, so both tabs settle a stale page link the same way. + * + * A page past the end comes back empty with the real total, read again as a + * count alone (`RANGE_NOT_SATISFIABLE`), so the page can redirect to the last + * page. Every failure is logged once and is `failed`, never an empty page, + * which would tell the user their list is gone. + * + * @param query.read The page query, given the inclusive row range. + * @param query.count The same filters as `read`, as a head count. + * @param query.map The page's rows, as the tab hands them on. + */ +export async function readCountedPage({ + page, + read, + count, + map, +}: { + page: number; + read: ( + supabase: ServerClient, + from: number, + to: number, + ) => PromiseLike<{ + data: Raw[] | null; + error: { code: string } | null; + count: number | null; + }>; + count: ( + supabase: ServerClient, + ) => PromiseLike<{ error: unknown; count: number | null }>; + map: (rows: Raw[]) => Row[]; +}): Promise> { + try { + const supabase = await createClient(); + const from = (page - 1) * LIBRARY_PAGE_SIZE; + const result = await read(supabase, from, from + LIBRARY_PAGE_SIZE - 1); + + if (result.error?.code === RANGE_NOT_SATISFIABLE) { + const head = await count(supabase); + if (head.error || head.count === null) return listReadFailed(); + return { kind: "ok", rows: [], total: head.count }; + } + if (result.error || result.data === null || result.count === null) { + return listReadFailed(); + } + + return { kind: "ok", total: result.count, rows: map(result.data) }; } catch { return listReadFailed(); } From f908e720bb466fb02f329e69ab28cbe9b2d37dc7 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:38:26 +0100 Subject: [PATCH 13/19] refactor(library): keep the failed list line in the library copy Co-Authored-By: Claude Opus 5.5 --- components/library/library-error-boundary.tsx | 4 ++-- components/library/library-panels.tsx | 8 ++++++-- lib/tracking/messages.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/components/library/library-error-boundary.tsx b/components/library/library-error-boundary.tsx index d6702776..02529405 100644 --- a/components/library/library-error-boundary.tsx +++ b/components/library/library-error-boundary.tsx @@ -4,7 +4,7 @@ import { catchError, type ErrorInfo } from "next/error"; import { StatePanel } from "@/components/state-panel"; import { Button } from "@/components/ui/button"; -import { LIBRARY_COPY } from "@/lib/tracking/messages"; +import { LIBRARY_COPY, LIBRARY_LIST_FAILED } from "@/lib/tracking/messages"; import type { LibraryList } from "./types"; @@ -33,7 +33,7 @@ function LibraryErrorFallback( retry()}> Try again diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx index dca245aa..cd0ddab4 100644 --- a/components/library/library-panels.tsx +++ b/components/library/library-panels.tsx @@ -17,7 +17,11 @@ import { type LibraryTab, libraryLastPage, } from "@/lib/tracking/library-lists"; -import { LIBRARY_COPY, LIBRARY_NOTES } from "@/lib/tracking/messages"; +import { + LIBRARY_COPY, + LIBRARY_LIST_FAILED, + LIBRARY_NOTES, +} from "@/lib/tracking/messages"; import type { LibraryList } from "./types"; @@ -201,7 +205,7 @@ export function ListFailed({ return ( ); diff --git a/lib/tracking/messages.ts b/lib/tracking/messages.ts index 43d4cc73..f48edd8a 100644 --- a/lib/tracking/messages.ts +++ b/lib/tracking/messages.ts @@ -290,6 +290,14 @@ export const LIBRARY_COPY: Record< }, }; +/** + * The line under every failed library list, beside `LIBRARY_COPY[list].failed`: + * a failed read (`ListFailed`) and a section that threw (`LibraryErrorBoundary`, + * spec 0027, AC-5) read the same. + */ +export const LIBRARY_LIST_FAILED = + "Your list didn't load. Try again in a moment."; + /** The month line under a Playing or Finished game card (spec 0027, AC-9). */ export const GAME_DATE_LINES = { started: (month: string) => `Started ${month}`, From 8e849351538b5ac1c8d553285725fe6b4e2858fb Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:40:00 +0100 Subject: [PATCH 14/19] refactor(library): decide each games tab's card contents in one map Co-Authored-By: Claude Opus 5.5 --- app/showcase/game-library-card-demo.tsx | 21 ++----- components/library/game-library-card.tsx | 28 ++++----- components/library/game-library-grid.test.tsx | 61 ++++++------------- components/library/game-library-grid.tsx | 5 +- components/library/game-library-tab.test.tsx | 8 +++ components/library/game-library-tab.tsx | 61 +++++++++++++------ components/library/types.ts | 2 +- 7 files changed, 88 insertions(+), 98 deletions(-) diff --git a/app/showcase/game-library-card-demo.tsx b/app/showcase/game-library-card-demo.tsx index 3ee53daf..cd4e0d3c 100644 --- a/app/showcase/game-library-card-demo.tsx +++ b/app/showcase/game-library-card-demo.tsx @@ -1,7 +1,7 @@ "use client"; import { GameLibraryCard } from "@/components/library/game-library-card"; -import type { LibraryGameItem, LibraryList } from "@/components/library/types"; +import type { LibraryGameItem } from "@/components/library/types"; import { PosterGrid } from "@/components/poster-grid"; /** @@ -21,10 +21,9 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { release: { state: "released" }, dateLine: null, }; - const cards: { list: LibraryList; item: LibraryGameItem }[] = [ - { list: "watchlist", item: { ...base, name: "Want to play, released" } }, + const cards: { item: LibraryGameItem }[] = [ + { item: { ...base, name: "Want to play, released" } }, { - list: "watchlist", item: { ...base, name: "Want to play, coming soon", @@ -36,7 +35,6 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { }, }, { - list: "watchlist", item: { ...base, name: "Want to play, Date TBA", @@ -46,7 +44,6 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { }, }, { - list: "upcoming", item: { ...base, name: "Playing, scored", @@ -55,7 +52,6 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { }, }, { - list: "upcoming", item: { ...base, name: "Playing, not scored", @@ -63,7 +59,6 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { }, }, { - list: "watched", item: { ...base, name: "Finished, scored", @@ -72,7 +67,6 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { }, }, { - list: "watched", item: { ...base, name: "Hidden by IGDB, no link", @@ -80,18 +74,15 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { dateLine: "Finished Jan 2025", }, }, - { - list: "watched", - item: { ...base, name: null, coverUrl: null, communityRating: null }, - }, + { item: { ...base, name: null, coverUrl: null, communityRating: null } }, ]; return ( - {cards.map(({ list, item }, index) => ( + {cards.map(({ item }, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: a fixed demo list with no identity.
  • - {}} /> + {}} />
  • ))}
    diff --git a/components/library/game-library-card.tsx b/components/library/game-library-card.tsx index 2b589027..5bc832f6 100644 --- a/components/library/game-library-card.tsx +++ b/components/library/game-library-card.tsx @@ -10,7 +10,7 @@ import { CardRoundButton } from "@/components/tracking/card-round-button"; import { gameHref } from "@/lib/catalog/media-type"; import { ReleasePill } from "./dated-pill"; -import type { LibraryGameItem, LibraryList } from "./types"; +import type { LibraryGameItem } from "./types"; /** * The caption and fallback tile text for a game IGDB no longer has (spec @@ -28,17 +28,15 @@ const MISSING_GAME = "No longer on IGDB"; * Started or Finished month under the name. A hidden game keeps its card * but loses its link, because its page answers a soft 404 (AC-10). Every card * has one remove button, which only reports the tap: `GameLibraryGrid` owns - * the optimistic hide, the action call and the toast. - * - * @param list The tab, which picks the badge and the date line (AC-9). + * the optimistic hide, the action call and the toast. Which of these a tab + * shows is decided once, on the server, by `gameLibraryTab`: the card shows + * the score, release pill and date line its item carries. */ function GameLibraryCard({ - list, item, onRemove, priority = false, }: { - list: LibraryList; item: LibraryGameItem; onRemove: () => void; priority?: boolean; @@ -53,18 +51,15 @@ function GameLibraryCard({ ); } - const unreleased = - list === "watchlist" && item.release.state === "unreleased" - ? item.release - : null; + const unreleased = item.release.state === "unreleased" ? item.release : null; return ( {unreleased ? : null} @@ -77,12 +72,11 @@ function GameLibraryCard({ } /** - * The cyan personal score on Playing and Finished when there is one, else - * the amber community rating (AC-8, AC-9). A score on a Want to play row - * cannot exist (spec 0024, AC-2), so it is never looked at there. + * The cyan personal score when the item carries one, else the amber + * community rating (AC-8, AC-9). A Want to play item never carries a score. */ -function cardBadge(list: LibraryList, item: LibraryGameItem): ReactNode { - if (list !== "watchlist" && item.rating !== null) { +function cardBadge(item: LibraryGameItem): ReactNode { + if (item.rating !== null) { return ; } return ; diff --git a/components/library/game-library-grid.test.tsx b/components/library/game-library-grid.test.tsx index 83078f70..55efbaa1 100644 --- a/components/library/game-library-grid.test.tsx +++ b/components/library/game-library-grid.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { LibraryGameItem, LibraryList } from "./types"; +import type { LibraryGameItem } from "./types"; /** * covers: spec 0027, AC-8 to AC-13 @@ -61,12 +61,11 @@ function item( const RETURN_PATH = "/upcoming?type=game&page=2"; -function grid(list: LibraryList, items: LibraryGameItem[], page = 1) { +function grid(items: LibraryGameItem[], page = 1) { return ( <> Heading { describe("GameLibraryCard through the grid", () => { it("links a game to its page with the IGDB community rating on Want to play", () => { - render(grid("watchlist", [item(1942, { name: "The Witcher 3" })])); + render(grid([item(1942, { name: "The Witcher 3" })])); expect(screen.getByRole("link", { name: "The Witcher 3" })).toHaveAttribute( "href", @@ -115,7 +114,7 @@ describe("GameLibraryCard through the grid", () => { it("shows the release pill or Date TBA only on an unreleased Want to play game", () => { render( - grid("watchlist", [ + grid([ item(1, { release: { state: "unreleased", @@ -136,7 +135,7 @@ describe("GameLibraryCard through the grid", () => { it("shows the personal score on Playing when there is one, else the community rating", () => { render( - grid("upcoming", [ + grid([ item(1, { rating: 9, dateLine: "Started Feb 2026" }), item(2, { dateLine: "Started Mar 2026" }), ]), @@ -149,39 +148,15 @@ describe("GameLibraryCard through the grid", () => { }); it("shows the personal score and the Finished month on a scored Finished game", () => { - render( - grid("watched", [item(1, { rating: 10, dateLine: "Finished Oct 2026" })]), - ); + render(grid([item(1, { rating: 10, dateLine: "Finished Oct 2026" })])); expect(screen.getByText("Your score")).toBeInTheDocument(); expect(screen.queryByText("IGDB community rating")).not.toBeInTheDocument(); expect(screen.getByText("Finished Oct 2026")).toBeInTheDocument(); }); - it("ignores a score and a date line on Want to play", () => { - render( - grid("watchlist", [item(1, { rating: 7, dateLine: "Started Feb 2026" })]), - ); - - expect(screen.queryByText("Your score")).not.toBeInTheDocument(); - expect(screen.getByText("IGDB community rating")).toBeInTheDocument(); - expect(screen.queryByText("Started Feb 2026")).not.toBeInTheDocument(); - }); - - it("never shows a release pill off Want to play", () => { - render( - grid("upcoming", [ - item(1, { release: { state: "unreleased", date: null } }), - ]), - ); - - expect( - screen.queryByText("Release date to be announced"), - ).not.toBeInTheDocument(); - }); - it("gives a hidden game no link but keeps its remove button", () => { - render(grid("watched", [item(1, { name: "Hidden Game", hidden: true })])); + render(grid([item(1, { name: "Hidden Game", hidden: true })])); expect( screen.getByRole("heading", { name: "Hidden Game" }), @@ -196,9 +171,7 @@ describe("GameLibraryCard through the grid", () => { it("shows a missing game as No longer on IGDB, removable, with nothing invented", () => { render( - grid("watched", [ - item(1, { name: null, communityRating: null, dateLine: null }), - ]), + grid([item(1, { name: null, communityRating: null, dateLine: null })]), ); expect(screen.getAllByText("No longer on IGDB").length).toBeGreaterThan(0); @@ -215,7 +188,7 @@ describe("GameLibraryCard through the grid", () => { describe("removal (AC-12, AC-13)", () => { it("hides the card at once, then shows the Undo toast", async () => { const user = userEvent.setup(); - const view = render(grid("upcoming", [item(1), item(2)])); + const view = render(grid([item(1), item(2)])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), @@ -224,7 +197,7 @@ describe("removal (AC-12, AC-13)", () => { expect(screen.queryByText("Game 1")).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: "Game 2" })).toHaveFocus(); - view.rerender(grid("upcoming", [item(2)])); + view.rerender(grid([item(2)])); await settleNext({ ok: true, undo: UNDO }); expect(toast).toHaveBeenCalledWith( @@ -236,7 +209,7 @@ describe("removal (AC-12, AC-13)", () => { it("restores the removal from the toast's Undo", async () => { const user = userEvent.setup(); - render(grid("upcoming", [item(1)])); + render(grid([item(1)])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), @@ -257,7 +230,7 @@ describe("removal (AC-12, AC-13)", () => { it("is a quiet success when another tab removed the game first", async () => { const user = userEvent.setup(); - render(grid("watched", [item(1)])); + render(grid([item(1)])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), @@ -269,7 +242,7 @@ describe("removal (AC-12, AC-13)", () => { it("puts the card back and offers Sign in back to this page when the session expired", async () => { const user = userEvent.setup(); - render(grid("upcoming", [item(1)])); + render(grid([item(1)])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), @@ -291,7 +264,7 @@ describe("removal (AC-12, AC-13)", () => { it("puts the card back in place with the error toast when the write fails", async () => { const user = userEvent.setup(); - render(grid("watched", [item(1), item(2)])); + render(grid([item(1), item(2)])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), @@ -308,7 +281,7 @@ describe("removal (AC-12, AC-13)", () => { it("keeps two removals in a row independent, one toast each", async () => { const user = userEvent.setup(); - const view = render(grid("upcoming", [item(1), item(2), item(3)])); + const view = render(grid([item(1), item(2), item(3)])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), @@ -321,7 +294,7 @@ describe("removal (AC-12, AC-13)", () => { "Game 3", ]); - view.rerender(grid("upcoming", [item(3)])); + view.rerender(grid([item(3)])); await settleNext({ ok: true, undo: UNDO }); await settleNext({ ok: true, undo: UNDO }); @@ -333,7 +306,7 @@ describe("removal (AC-12, AC-13)", () => { it("focuses a linkless neighbour's remove button, then the heading", async () => { const user = userEvent.setup(); - render(grid("watched", [item(1), item(2, { hidden: true })])); + render(grid([item(1), item(2, { hidden: true })])); await user.click( screen.getByRole("button", { name: "Remove Game 1 from your games" }), diff --git a/components/library/game-library-grid.tsx b/components/library/game-library-grid.tsx index ceaa6a0f..3121a34e 100644 --- a/components/library/game-library-grid.tsx +++ b/components/library/game-library-grid.tsx @@ -13,7 +13,7 @@ import { import { showGameUndo } from "@/components/tracking/use-game-undo"; import { GameLibraryCard } from "./game-library-card"; -import { gameItemKey, type LibraryGameItem, type LibraryList } from "./types"; +import { gameItemKey, type LibraryGameItem } from "./types"; import { useRemovalFocus } from "./use-removal-focus"; /** @@ -38,13 +38,11 @@ import { useRemovalFocus } from "./use-removal-focus"; * @param returnPath The page, for the session expired toast's Sign in action. */ function GameLibraryGrid({ - list, items, label, page, returnPath, }: { - list: LibraryList; items: LibraryGameItem[]; label: string; page: number; @@ -97,7 +95,6 @@ function GameLibraryGrid({ return (
  • remove(item)} priority={index < POSTER_GRID_EAGER.catalog} diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx index 3c990c3a..536bba51 100644 --- a/components/library/game-library-tab.test.tsx +++ b/components/library/game-library-tab.test.tsx @@ -296,6 +296,14 @@ describe("the cards (AC-8 to AC-11)", () => { expect(received(1).release).toEqual({ state: "released" }); }); + it("gives an unreleased game no release pill off Want to play", async () => { + getGameLibraryPage.mockResolvedValue(page([row(1)])); + withGames(game(1, { releaseDate: null })); + await renderSection("upcoming", { type: "game" }); + + expect(received(1).release).toEqual({ state: "released" }); + }); + it("gives Playing a Started line and Finished a Finished line", async () => { getGameLibraryPage.mockResolvedValue(page([row(1, { rating: 8 })])); withGames(game(1)); diff --git a/components/library/game-library-tab.tsx b/components/library/game-library-tab.tsx index 3a70bbbd..02bf9d90 100644 --- a/components/library/game-library-tab.tsx +++ b/components/library/game-library-tab.tsx @@ -68,7 +68,6 @@ export async function gameLibraryTab({ return (
    string | null; + } +> = { + watchlist: { scored: false, releasePill: true, dateLine: () => null }, + upcoming: { + scored: true, + releasePill: false, + dateLine: (row) => monthLine(row.startedAt, GAME_DATE_LINES.started), + }, + watched: { + scored: true, + releasePill: false, + dateLine: (row) => monthLine(row.finishedAt, GAME_DATE_LINES.finished), + }, +}; + /** * A game card's plain data, with its release pill and date line formatted * here, beside the `today` that decided them. A game IGDB did not return is @@ -111,30 +138,30 @@ function gameItem( }; } + const card = GAME_CARDS[list]; return { gameId: row.gameId, name: game.name, coverUrl: game.coverUrl, communityRating: game.communityRating, - rating: list === "watchlist" ? null : row.rating, + rating: card.scored ? row.rating : null, hidden: game.hidden, - release: isGameReleased(game.releaseDate, today) - ? { state: "released" } - : { - state: "unreleased", - date: formatReleaseDate(game.releaseDate, today), - }, - dateLine: dateLine(list, row), + release: + card.releasePill && !isGameReleased(game.releaseDate, today) + ? { + state: "unreleased", + date: formatReleaseDate(game.releaseDate, today), + } + : { state: "released" }, + dateLine: card.dateLine(row), }; } -/** `Started Oct 2026` on Playing, `Finished Oct 2026` on Finished (AC-9). */ -function dateLine(list: LibraryList, row: GameLibraryRow): string | null { - if (list === "watchlist") return null; - const iso = list === "upcoming" ? row.startedAt : row.finishedAt; +/** `Started Oct 2026` or `Finished Oct 2026`, or none without the date. */ +function monthLine( + iso: string | null, + line: (month: string) => string, +): string | null { const month = iso === null ? null : formatMonthYear(iso); - if (month === null) return null; - return list === "upcoming" - ? GAME_DATE_LINES.started(month) - : GAME_DATE_LINES.finished(month); + return month === null ? null : line(month); } diff --git a/components/library/types.ts b/components/library/types.ts index 06fe6c81..f4739623 100644 --- a/components/library/types.ts +++ b/components/library/types.ts @@ -61,7 +61,7 @@ export type LibraryGameItem = { * (spec 0025, AC-7), so the card has no link (AC-10). */ hidden: boolean; - /** Only Want to play cares; an unreleased `date: null` is Date TBA. */ + /** Unreleased only on Want to play; an unreleased `date: null` is Date TBA. */ release: { state: "released" } | { state: "unreleased"; date: ReleaseDate }; /** `Started Oct 2026` or `Finished Oct 2026`, already formatted. */ dateLine: string | null; From c0cc64494f4da5232254ef5e31bae7b92422a440 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:41:00 +0100 Subject: [PATCH 15/19] fix(library): claim no release state for a game IGDB no longer has Co-Authored-By: Claude Opus 5.5 --- app/showcase/game-library-card-demo.tsx | 7 +++---- components/library/game-library-card.tsx | 6 +++--- components/library/game-library-grid.test.tsx | 7 +++---- components/library/game-library-tab.test.tsx | 13 ++++++------- components/library/game-library-tab.tsx | 11 ++++------- components/library/types.ts | 9 +++++++-- docs/specs/0027-games-library-pages/index.md | 2 +- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/app/showcase/game-library-card-demo.tsx b/app/showcase/game-library-card-demo.tsx index cd4e0d3c..d659555a 100644 --- a/app/showcase/game-library-card-demo.tsx +++ b/app/showcase/game-library-card-demo.tsx @@ -18,7 +18,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { communityRating: 8.4, rating: null, hidden: false, - release: { state: "released" }, + unreleased: null, dateLine: null, }; const cards: { item: LibraryGameItem }[] = [ @@ -28,8 +28,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { ...base, name: "Want to play, coming soon", communityRating: null, - release: { - state: "unreleased", + unreleased: { date: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" }, }, }, @@ -40,7 +39,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) { name: "Want to play, Date TBA", coverUrl: null, communityRating: null, - release: { state: "unreleased", date: null }, + unreleased: { date: null }, }, }, { diff --git a/components/library/game-library-card.tsx b/components/library/game-library-card.tsx index 5bc832f6..61c1c1fc 100644 --- a/components/library/game-library-card.tsx +++ b/components/library/game-library-card.tsx @@ -51,8 +51,6 @@ function GameLibraryCard({ ); } - const unreleased = item.release.state === "unreleased" ? item.release : null; - return ( - {unreleased ? : null} + {item.unreleased ? ( + + ) : null} } diff --git a/components/library/game-library-grid.test.tsx b/components/library/game-library-grid.test.tsx index 55efbaa1..096187bb 100644 --- a/components/library/game-library-grid.test.tsx +++ b/components/library/game-library-grid.test.tsx @@ -53,7 +53,7 @@ function item( communityRating: 8.1, rating: null, hidden: false, - release: { state: "released" }, + unreleased: null, dateLine: null, ...overrides, }; @@ -116,12 +116,11 @@ describe("GameLibraryCard through the grid", () => { render( grid([ item(1, { - release: { - state: "unreleased", + unreleased: { date: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" }, }, }), - item(2, { release: { state: "unreleased", date: null } }), + item(2, { unreleased: { date: null } }), item(3), ]), ); diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx index 536bba51..6629243e 100644 --- a/components/library/game-library-tab.test.tsx +++ b/components/library/game-library-tab.test.tsx @@ -279,12 +279,11 @@ describe("the cards (AC-8 to AC-11)", () => { ); await renderSection("watchlist", { type: "game" }); - expect(received(1).release).toEqual({ state: "released" }); - expect(received(2).release).toEqual({ - state: "unreleased", + expect(received(1).unreleased).toBeNull(); + expect(received(2).unreleased).toEqual({ date: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" }, }); - expect(received(3).release).toEqual({ state: "unreleased", date: null }); + expect(received(3).unreleased).toEqual({ date: null }); expect(received(1).dateLine).toBeNull(); }); @@ -293,7 +292,7 @@ describe("the cards (AC-8 to AC-11)", () => { withGames(game(1, { releaseDate: "2026-10-24" })); await renderSection("watchlist", { type: "game" }); - expect(received(1).release).toEqual({ state: "released" }); + expect(received(1).unreleased).toBeNull(); }); it("gives an unreleased game no release pill off Want to play", async () => { @@ -301,7 +300,7 @@ describe("the cards (AC-8 to AC-11)", () => { withGames(game(1, { releaseDate: null })); await renderSection("upcoming", { type: "game" }); - expect(received(1).release).toEqual({ state: "released" }); + expect(received(1).unreleased).toBeNull(); }); it("gives Playing a Started line and Finished a Finished line", async () => { @@ -343,7 +342,7 @@ describe("the cards (AC-8 to AC-11)", () => { communityRating: null, rating: null, hidden: false, - release: { state: "released" }, + unreleased: null, dateLine: null, }); expect(received(2).hidden).toBe(true); diff --git a/components/library/game-library-tab.tsx b/components/library/game-library-tab.tsx index 02bf9d90..b3ef7f92 100644 --- a/components/library/game-library-tab.tsx +++ b/components/library/game-library-tab.tsx @@ -133,7 +133,7 @@ function gameItem( communityRating: null, rating: null, hidden: false, - release: { state: "released" }, + unreleased: null, dateLine: null, }; } @@ -146,13 +146,10 @@ function gameItem( communityRating: game.communityRating, rating: card.scored ? row.rating : null, hidden: game.hidden, - release: + unreleased: card.releasePill && !isGameReleased(game.releaseDate, today) - ? { - state: "unreleased", - date: formatReleaseDate(game.releaseDate, today), - } - : { state: "released" }, + ? { date: formatReleaseDate(game.releaseDate, today) } + : null, dateLine: card.dateLine(row), }; } diff --git a/components/library/types.ts b/components/library/types.ts index f4739623..a207b082 100644 --- a/components/library/types.ts +++ b/components/library/types.ts @@ -61,8 +61,13 @@ export type LibraryGameItem = { * (spec 0025, AC-7), so the card has no link (AC-10). */ hidden: boolean; - /** Unreleased only on Want to play; an unreleased `date: null` is Date TBA. */ - release: { state: "released" } | { state: "unreleased"; date: ReleaseDate }; + /** + * The release pill of a Want to play game that is not out yet, where + * `date: null` is Date TBA. Null for no pill: a released game, a Playing or + * Finished card, or a game IGDB no longer has, of which nothing is known + * (AC-11). + */ + unreleased: { date: ReleaseDate } | null; /** `Started Oct 2026` or `Finished Oct 2026`, already formatted. */ dateLine: string | null; }; diff --git a/docs/specs/0027-games-library-pages/index.md b/docs/specs/0027-games-library-pages/index.md index 4e96391f..7669dd6b 100644 --- a/docs/specs/0027-games-library-pages/index.md +++ b/docs/specs/0027-games-library-pages/index.md @@ -92,7 +92,7 @@ No index beyond the primary key (spec 0024, AC-12). One user's games fit a prima | `GameLibraryGrid` (`components/library/game-library-grid.tsx`, client) | client grid | `items: LibraryGameItem[]`, `list`, `page`, `returnPath`, `label` | the cards, removal, Undo toast | calls `removeGame` / `restoreRemovedGame` (spec 0026, session checked there) | the spec 0026 error toasts | | `GameLibraryCard` (`components/library/game-library-card.tsx`) | card on `PosterCard` | `LibraryGameItem`, `list`, `onRemove` | the card states of AC-8 to AC-11 | none | none | -`GameLibraryRow` is `{ gameId, rating, addedAt, startedAt, finishedAt }`. `LibraryGameItem` (plain data, in `components/library/types.ts`) is `{ gameId, name: string | null, coverUrl, communityRating, rating, hidden, release: { state: "released" } | { state: "unreleased"; date: ReleaseDate }, dateLine: string | null }`, where `name: null` means missing and an unreleased `date: null` is Date TBA. `gameHref(id)` lives in `lib/catalog/media-type.ts` beside `titleHref`. `showGameUndo` lives in `components/tracking/use-game-undo.ts`. +`GameLibraryRow` is `{ gameId, rating, addedAt, startedAt, finishedAt }`. `LibraryGameItem` (plain data, in `components/library/types.ts`) is `{ gameId, name: string | null, coverUrl, communityRating, rating, hidden, unreleased: { date: ReleaseDate } | null, dateLine: string | null }`, where `name: null` means missing, `unreleased` is set only on a Want to play game that is not out yet (null on a missing game, of which nothing is known), and its `date: null` is Date TBA. `gameHref(id)` lives in `lib/catalog/media-type.ts` beside `titleHref`. `showGameUndo` lives in `components/tracking/use-game-undo.ts`. **Value sourcing**: From ad33ca38cba1587578bde47302a933a58b52ad8f Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:41:29 +0100 Subject: [PATCH 16/19] refactor(library): render every catalog failure through CatalogFailed Co-Authored-By: Claude Opus 5.5 --- components/library/library-panels.tsx | 15 ++++++--------- components/library/movie-library-tab.tsx | 6 ++++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx index cd0ddab4..de6357aa 100644 --- a/components/library/library-panels.tsx +++ b/components/library/library-panels.tsx @@ -81,7 +81,7 @@ export function settleTab( if (tab.kind === "tmdb_failed") { return { kind: "panel", - panel: , + panel: , }; } const lastPage = libraryLastPage(tab.total); @@ -211,6 +211,9 @@ export function ListFailed({ ); } +/** What each catalog supplies, for its failure line. */ +const CATALOG_ITEMS = { TMDB: "titles", IGDB: "games" } as const; + /** * A systemic catalog failure: a rejected credential or a rate limit (spec * 0020, AC-17), from TMDB for shows and movies or IGDB for games (spec 0027, @@ -220,24 +223,18 @@ export function CatalogFailed({ source, href, }: { - source: "TMDB" | "IGDB"; + source: keyof typeof CATALOG_ITEMS; href: string; }) { - const what = source === "TMDB" ? "titles" : "games"; return ( ); } -/** A systemic TMDB failure (spec 0020, AC-17). */ -export function TmdbFailed({ href }: { href: string }) { - return ; -} - /** The error panel every failed library read shows, with Retry. */ function LoadFailed({ title, diff --git a/components/library/movie-library-tab.tsx b/components/library/movie-library-tab.tsx index a9e8e44f..00027cdb 100644 --- a/components/library/movie-library-tab.tsx +++ b/components/library/movie-library-tab.tsx @@ -16,12 +16,12 @@ import { LIBRARY_COPY } from "@/lib/tracking/messages"; import { LibraryGrid } from "./library-grid"; import { + CatalogFailed, EmptyPanel, ListFailed, pageHref, settleTab, TabNotes, - TmdbFailed, } from "./library-panels"; import type { LibraryList, LibraryMovieItem } from "./types"; @@ -94,7 +94,9 @@ export async function watchedMoviesTab({ result.rows.map((row) => row.tmdbId), ); if (titles.kind === "failed") { - return ; + return ( + + ); } const items = result.rows.map((row): LibraryMovieItem => { From 44830054b051637b037cc27abbb1e6f4bba50e96 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:42:06 +0100 Subject: [PATCH 17/19] refactor(catalog): write the library paths once and key the library copy by LibraryList Co-Authored-By: Claude Opus 5.5 --- lib/catalog/media-type.ts | 24 ++++++++++++++---------- lib/tracking/messages.ts | 3 ++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts index 2be966b8..9e7a3dbb 100644 --- a/lib/catalog/media-type.ts +++ b/lib/catalog/media-type.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import type { LibraryList } from "./library-list"; + /** The two catalogs: TV shows and movies. */ export type MediaType = "tv" | "movie"; @@ -22,23 +24,25 @@ export const CATALOG_PATHS = { } as const satisfies Record; /** - * The pages whose media type is their `type` parameter rather than their - * path (feature 22). On each, the navbar tabs switch `type` in place instead - * of leaving for `/shows` or `/movies`. + * The three library pages, one per `LibraryList`, and the only typed pages a + * game tab exists on (spec 0027, AC-2). */ -export const TYPED_PATHS = [ +const LIBRARY_PATHS = [ "/watchlist", "/upcoming", "/watched", - "/search", -] as const; +] as const satisfies readonly `/${LibraryList}`[]; -export type TypedPath = (typeof TYPED_PATHS)[number]; +export type LibraryPath = (typeof LIBRARY_PATHS)[number]; -/** The three library pages, the only typed pages a game tab exists on. */ -const LIBRARY_PATHS = ["/watchlist", "/upcoming", "/watched"] as const; +/** + * The pages whose media type is their `type` parameter rather than their + * path (feature 22): the library pages and `/search`. On each, the navbar + * tabs switch `type` in place instead of leaving for `/shows` or `/movies`. + */ +export const TYPED_PATHS = [...LIBRARY_PATHS, "/search"] as const; -export type LibraryPath = (typeof LIBRARY_PATHS)[number]; +export type TypedPath = (typeof TYPED_PATHS)[number]; /** The `/search` parameters a tab switch keeps; genres and page are dropped. */ const SEARCH_KEPT = ["q", "year", "rating"] as const; diff --git a/lib/tracking/messages.ts b/lib/tracking/messages.ts index f48edd8a..dd76a617 100644 --- a/lib/tracking/messages.ts +++ b/lib/tracking/messages.ts @@ -1,3 +1,4 @@ +import type { LibraryList } from "@/lib/catalog/library-list"; import type { LibraryType, MediaType } from "@/lib/catalog/media-type"; import type { @@ -205,7 +206,7 @@ type ListCopy = { * show and movie empty state offers the one catalog its tab lists. */ export const LIBRARY_COPY: Record< - "watchlist" | "upcoming" | "watched", + LibraryList, Record & { failed: string } > = { watchlist: { From 1e590be2e84070e1fceba83e30b0c625e9584ef3 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:43:07 +0100 Subject: [PATCH 18/19] refactor(tracking): pass one GameToastTarget to every game toast Co-Authored-By: Claude Opus 5.5 --- components/library/game-library-grid.tsx | 4 +-- components/tracking/game-status-control.tsx | 4 +-- components/tracking/tracking-toast.ts | 36 +++++++++++++-------- components/tracking/use-game-undo.ts | 13 +++----- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/components/library/game-library-grid.tsx b/components/library/game-library-grid.tsx index 3121a34e..800f15d0 100644 --- a/components/library/game-library-grid.tsx +++ b/components/library/game-library-grid.tsx @@ -6,7 +6,6 @@ import { startTransition, useOptimistic, useRef } from "react"; import { removeGame } from "@/app/games/actions"; import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; import { - gameTrackingToastId, settleResultCall, showGameTrackingError, } from "@/components/tracking/tracking-toast"; @@ -72,7 +71,8 @@ function GameLibraryGrid({ if (!result.ok) { removalFailed(key); showGameTrackingError(result.error, { - id: gameTrackingToastId(item.gameId, "status"), + gameId: item.gameId, + control: "status", returnPath, navigate: router.push, }); diff --git a/components/tracking/game-status-control.tsx b/components/tracking/game-status-control.tsx index f9d4d0d5..80da1369 100644 --- a/components/tracking/game-status-control.tsx +++ b/components/tracking/game-status-control.tsx @@ -33,7 +33,6 @@ import { WatchedIcon } from "./tracking-icons"; import { TRACKING_PILL, TRACKING_PILL_UNAVAILABLE } from "./tracking-pill"; import { type GameControl, - gameTrackingToastId, settleResultCall, showGameTrackingError, } from "./tracking-toast"; @@ -114,7 +113,8 @@ function GameStatusControl({ return; } showGameTrackingError(result.error, { - id: gameTrackingToastId(gameId, control), + gameId, + control, returnPath, navigate: router.push, }); diff --git a/components/tracking/tracking-toast.ts b/components/tracking/tracking-toast.ts index c18bc48a..8f19b000 100644 --- a/components/tracking/tracking-toast.ts +++ b/components/tracking/tracking-toast.ts @@ -210,37 +210,45 @@ export function showShowTrackingError( /** Which game control failed, so each keeps one toast of its own (AC-11). */ export type GameControl = "status" | "rating"; +/** + * The game a toast is about and where its session expired Sign in comes + * back to: what every game failure and Undo toast needs, from the game page + * and the games library grid alike (spec 0026; spec 0027, AC-12). + */ +export type GameToastTarget = { + gameId: number; + returnPath: string; + /** The router push, for the Sign in action. */ + navigate: (href: string) => void; +}; + /** * The id of a game's failure toast, one per game and control, so repeated - * failures replace each other (spec 0026, AC-11). The status control and the - * Undo hook both build it here, so a failed Undo replaces a failed click. + * failures replace each other (spec 0026, AC-11). Built only here, so a + * failed Undo replaces a failed click on the same control. */ -export function gameTrackingToastId( - gameId: number, - control: GameControl, -): string { +function gameTrackingToastId(gameId: number, control: GameControl): string { return `game-tracking-${gameId}-${control}`; } /** - * Shows the toast for a failed game write (spec 0026, AC-11). The caller - * names the toast id with `gameTrackingToastId`. + * Shows the toast for a failed game write (spec 0026, AC-11), under the one + * id its game and control share. * * @param error The error class the action returned. - * @param options.id The toast id. - * @param options.returnPath The page to come back to after signing in. - * @param options.navigate The router push, for the Sign in action. + * @param target.control The control whose write failed. */ export function showGameTrackingError( error: GameTrackingError, { - id, + gameId, + control, returnPath, navigate, - }: { id: string; returnPath: string; navigate: (href: string) => void }, + }: GameToastTarget & { control: GameControl }, ): void { toast(GAME_TRACKING_MESSAGES[error], { - id, + id: gameTrackingToastId(gameId, control), description: undefined, action: signInAction(error, returnPath, navigate), }); diff --git a/components/tracking/use-game-undo.ts b/components/tracking/use-game-undo.ts index 12644acf..7db8ce71 100644 --- a/components/tracking/use-game-undo.ts +++ b/components/tracking/use-game-undo.ts @@ -8,7 +8,7 @@ import { GAME_UNDO_MESSAGES, UNDO_ACTION_LABEL } from "@/lib/tracking/messages"; import type { GameUndo } from "@/lib/tracking/types"; import { - gameTrackingToastId, + type GameToastTarget, runUndoInToast, showGameTrackingError, UNDO_TOAST_MS, @@ -26,9 +26,6 @@ import { * so its Undo still works after the grid that showed it is gone. * * @param options.undo The action's `undo`; nothing shows when it is absent. - * @param options.returnPath Where the session expired toast's Sign in comes - * back to. - * @param options.navigate The router push, for that Sign in action. * @param options.restoring Runs inside the Undo's transition, for an * optimistic update that shows the old state back at once. */ @@ -38,11 +35,8 @@ export function showGameUndo({ returnPath, navigate, restoring, -}: { - gameId: number; +}: GameToastTarget & { undo: GameUndo | undefined; - returnPath: string; - navigate: (href: string) => void; restoring?: () => void; }): void { if (!undo) return; @@ -68,7 +62,8 @@ export function showGameUndo({ restoring, onError: (error) => showGameTrackingError(error, { - id: gameTrackingToastId(gameId, "status"), + gameId, + control: "status", returnPath, navigate, }), From b3a97e52795127e979177e826312a728d6c8589a Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 10:43:30 +0100 Subject: [PATCH 19/19] docs(specs): write LibraryErrorBoundary and the review refactors into spec 0027 Co-Authored-By: Claude Opus 5.5 --- docs/specs/0027-games-library-pages/index.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/specs/0027-games-library-pages/index.md b/docs/specs/0027-games-library-pages/index.md index 7669dd6b..fa381514 100644 --- a/docs/specs/0027-games-library-pages/index.md +++ b/docs/specs/0027-games-library-pages/index.md @@ -1,7 +1,7 @@ # 0027. Games tab and library pages: Want to play, Playing and Finished on the three library routes **Date**: 2026-10-24 -**Status**: Accepted +**Status**: Accepted (amended 2026-10-25 after code review: `LibraryErrorBoundary` written in, the card decisions moved to the tab, the shared focus hook and counted page read) Scope feature: [G5. Games tab and library pages](../../scope/games.md) ยท GA tier @@ -89,8 +89,9 @@ No index beyond the primary key (spec 0024, AC-12). One user's games fit a prima | `getGameLibraryPage({ userId, tab, page })` (`lib/tracking/game-library.ts`, `server-only`) | Supabase read | verified `userId`, `LibraryList`, parsed page | `{ kind: "ok", rows: GameLibraryRow[], total }` or `{ kind: "failed" }` | the request's session client under RLS | `failed` on any Postgres or network error | | `getLibraryGames(ids)` (same file) | IGDB read | at most 24 ids | `{ kind: "ok", games: Map, missingIds }` or `{ kind: "failed" }` | none (public catalog) | `failed` on an `IgdbError`; any other error throws | | `gameLibraryTab({ tab, userId, page, today })` (`components/library/game-library-tab.tsx`) | Server Component | the above | grid, empty panel, or a failure panel; redirect past the end | `requireUser()` already ran in `LibrarySection` | the panels of AC-7 | -| `GameLibraryGrid` (`components/library/game-library-grid.tsx`, client) | client grid | `items: LibraryGameItem[]`, `list`, `page`, `returnPath`, `label` | the cards, removal, Undo toast | calls `removeGame` / `restoreRemovedGame` (spec 0026, session checked there) | the spec 0026 error toasts | -| `GameLibraryCard` (`components/library/game-library-card.tsx`) | card on `PosterCard` | `LibraryGameItem`, `list`, `onRemove` | the card states of AC-8 to AC-11 | none | none | +| `GameLibraryGrid` (`components/library/game-library-grid.tsx`, client) | client grid | `items: LibraryGameItem[]`, `page`, `returnPath`, `label` | the cards, removal, Undo toast | calls `removeGame` / `restoreRemovedGame` (spec 0026, session checked there) | the spec 0026 error toasts | +| `GameLibraryCard` (`components/library/game-library-card.tsx`) | card on `PosterCard` | `LibraryGameItem`, `onRemove`; which score, pill and date line a tab shows is decided once in `gameLibraryTab` (`GAME_CARDS`) | the card states of AC-8 to AC-11 | none | none | +| `LibraryErrorBoundary` (`components/library/library-error-boundary.tsx`, client, `catchError`) | error boundary around each library page's streamed section | `list` | the `LIBRARY_COPY[list].failed` panel with a Try again that refetches the section | none | catches what a section throws (an invalid flag value, AC-5, or any unsettled read on any tab, shows and movies included); lets `redirect()` through | `GameLibraryRow` is `{ gameId, rating, addedAt, startedAt, finishedAt }`. `LibraryGameItem` (plain data, in `components/library/types.ts`) is `{ gameId, name: string | null, coverUrl, communityRating, rating, hidden, unreleased: { date: ReleaseDate } | null, dateLine: string | null }`, where `name: null` means missing, `unreleased` is set only on a Want to play game that is not out yet (null on a missing game, of which nothing is known), and its `date: null` is Date TBA. `gameHref(id)` lives in `lib/catalog/media-type.ts` beside `titleHref`. `showGameUndo` lives in `components/tracking/use-game-undo.ts`. @@ -148,7 +149,7 @@ Tracer Bullet: first one thin real thread from the flag through the tab, the rea 3. **All three tabs.** Add `GAME_LIBRARY_TABS` with the three orders, paging past the end, the `PGRST103` count read and the `game_id` tie break, with tests on the reader, satisfies **AC-6**. 4. **Cards.** Build `GameLibraryCard` with the per tab badges, `ReleasePill` on unreleased Want to play, `formatMonthYear` and the Started / Finished lines, `gameHref`, the hidden (no link) and missing states, and the `LibraryGameItem` mapping, satisfies **AC-8**, **AC-9**, **AC-10**, **AC-11**. 5. **Removal and Undo.** Build `GameLibraryGrid` with `useOptimistic`, `removeGame`, the Undo toast through spec 0026's toast (factor the toast out of `useGameUndo` into a plain function both callers share, since a grid holds many games), the error toasts and the focus rules, satisfies **AC-12**, **AC-13**. -6. **States and copy.** Add the game entries to `LIBRARY_COPY` (with `browse` optional and the text only empty panel), the "Couldn't reach IGDB" panel (`CatalogFailed` over the shared `LoadFailed`), and the logging, satisfies **AC-7**, **AC-14**. +6. **States and copy.** Add the game entries to `LIBRARY_COPY` (with `browse` optional and the text only empty panel), the "Couldn't reach IGDB" panel (`CatalogFailed` over the shared `LoadFailed`), `LibraryErrorBoundary` around each page's section (the boundary AC-5 relies on, none existed before), and the logging, satisfies **AC-5**, **AC-7**, **AC-14**. 7. **Showcase, sizes and checks.** Add the `/showcase` card states, check 375px and 1280px and keyboard use in the running app (`next-dev-loop`), confirm no write on load, set the Vercel Preview variable and the `docs/deploy.md` line, then run `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm test:db` and `pnpm build`, satisfies **AC-15**, **AC-16**, **AC-17**, **AC-18**, **AC-1**. ## Consequences @@ -167,7 +168,8 @@ Tracer Bullet: first one thin real thread from the flag through the tab, the rea - On a games tab the navbar search placeholder flashes once while `?type` resolves. - IGDB data on a card (name, cover, hidden) comes from the `hours` cached batch, so a change at IGDB can take a few hours to show. - The flag is inlined at build time: a browser still running an older bundle can show GAMES while the server answers "doesn't exist" until it reloads. -- A second client grid (games) sits beside the movie grid and shares only its helpers, so a removal fix may need making in both. +- A second client grid (games) sits beside the movie grid. They share the focus order after a removal (`use-removal-focus.ts`) and the counted page read (`readCountedPage`), but each keeps its own `useOptimistic` removal, so a fix there may need making in both. +- `LibraryErrorBoundary` wraps the show and movie tabs too, so a throw there now shows the list's failed panel with Try again instead of the default error page. **Neutral**: - No migration, so no `supabase db push` for this feature.