@@ -187,29 +199,43 @@ export function ListFailed({
page,
}: {
list: LibraryList;
- type: MediaType;
+ type: LibraryType;
page: number;
}) {
return (
);
}
-/** A systemic TMDB failure: a rejected credential or a rate limit (AC-17). */
-export function TmdbFailed({ href }: { href: string }) {
+/** 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,
+ * AC-7). Never the empty state, which would tell the user their list is gone.
+ */
+export function CatalogFailed({
+ source,
+ href,
+}: {
+ source: keyof typeof CATALOG_ITEMS;
+ 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/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 => {
diff --git a/components/library/types.ts b/components/library/types.ts
index 7a4f6c0c..a207b082 100644
--- a/components/library/types.ts
+++ b/components/library/types.ts
@@ -41,3 +41,38 @@ 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;
+ /**
+ * 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;
+};
+
+/** One key per game card, apart from the movie keys. */
+export function gameItemKey(item: Pick) {
+ return `game-${item.gameId}`;
+}
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 };
+}
diff --git a/components/search/navbar-search.tsx b/components/search/navbar-search.tsx
index 07f8ac34..37bbb238 100644
--- a/components/search/navbar-search.tsx
+++ b/components/search/navbar-search.tsx
@@ -20,7 +20,8 @@ type Layout = "desktop" | "mobile";
* under `/shows`, the `type` parameter on `/watchlist`, `/upcoming`,
* `/watched` and `/search`, read through `WithTypedPage`. A page about
* neither catalog renders no search at all
- * (prompts/hide-tabs-without-type.md). `usePathname` suspends on a route
+ * (prompts/hide-tabs-without-type.md), and neither does a games tab, since
+ * there is no game search until G7 (spec 0027, AC-4). `usePathname` suspends on a route
* with a dynamic param at prerender time, so `Navbar` wraps this in Suspense
* with `NavbarSearchFallback`.
*
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/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/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.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..7db8ce71 100644
--- a/components/tracking/use-game-undo.ts
+++ b/components/tracking/use-game-undo.ts
@@ -8,17 +8,73 @@ 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,
} 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.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,
+}: GameToastTarget & {
+ undo: GameUndo | undefined;
+ 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, {
+ gameId,
+ control: "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 +87,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,
});
};
}
diff --git a/docs/deploy.md b/docs/deploy.md
index 357b2c51..3dc5ac24 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -70,10 +70,11 @@ Set them in the Vercel project, under Settings, Environment Variables. `.env.exa
| `TMDB_READ_ACCESS_TOKEN` | your TMDB read token (sensitive) | the same token |
| `NEXT_PUBLIC_SITE_URL` | `https://bestats-sable.vercel.app`, no trailing slash | not set |
| `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` | `off` | `off` |
+| `NEXT_PUBLIC_GAMES_LIBRARY` | not set (off) | `on` |
Never add a Supabase service role key, a secret key or a database password to any environment, and never give the TMDB token a `NEXT_PUBLIC_` prefix.
-`NEXT_PUBLIC_` values are inlined at build time, so changing one (the email flag, for example) takes a redeploy, not just an edit. You can check the list with `vercel env ls`.
+`NEXT_PUBLIC_` values are inlined at build time, so changing one (the email flag, for example) takes a redeploy, not just an edit. `NEXT_PUBLIC_GAMES_LIBRARY` stays unset in Production until a games browse page or game search (G6 or G7) gives people a way to find games; with it off, `?type=game` on the library pages shows "That page doesn't exist" (spec 0027, AC-1). You can check the list with `vercel env ls`.
**Previews** have no Supabase variables on purpose. They show the catalog, a signed out navbar, `Sign in isn't available on this deployment.` on `/sign-in` and `/sign-up`, and a `robots.txt` that disallows everything. Vercel's deployment protection stays on, so only you (signed in to Vercel) or someone holding the protection bypass token can open one. `vercel curl --deployment ` opens a preview with the bypass for a quick check. No preview origin ever goes into the Supabase redirect list.
diff --git a/docs/reviews/2026-10-25-feat-games-library-pages.md b/docs/reviews/2026-10-25-feat-games-library-pages.md
new file mode 100644
index 00000000..42a7dfd3
--- /dev/null
+++ b/docs/reviews/2026-10-25-feat-games-library-pages.md
@@ -0,0 +1,38 @@
+# Review, feat/games-library-pages, 2026-10-25
+
+**Reviewed by**: Sonnet 5.5 (author on unknown model)
+**Scope**: 42 files (27 tracked, 15 new), branch vs main (all uncommitted on merge base b37c56e)
+**Verdict**: Approve with nits
+
+## Summary
+Adds the games tabs to /watchlist, /upcoming and /watched behind `NEXT_PUBLIC_GAMES_LIBRARY`: one counted, user-filtered Postgres page per tab, one IGDB batch for that page, a game card/grid with optimistic remove and Undo, a `catchError` boundary around the streamed section, and `LibraryType`/`typedHref` overloads that keep `game` off /search and catalog pages. The logic is sound: session user id, no cached private reads, no writes on load, failures never fall to the empty state. Only minors and nits found; nothing blocks merge.
+
+## Minor
+### 🟡 Root AGENTS.md not updated for the new flag, `.env.example:62`
+**Problem**: The Games section still says the GAMES tab "stays hidden until G5 ships" and the env facts list only `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY`; `NEXT_PUBLIC_GAMES_LIBRARY`, `getGamesLibrary()` and `gamesLibraryEnabled()` are documented only in the spec, deploy.md and JSDoc.
+**Why it matters**: The next agent will not know the flag is the gate or that `type=game` is valid on the three library paths.
+**Suggested fix**: Add one line to Commands and repo facts (and amend the Navigation bullet) in /sync.
+
+### 🟡 Non-IGDB errors blow up the whole section, `lib/tracking/game-library.ts:144`
+**Problem**: Anything that is not an `IgdbError` (including `IgdbValidationError`, or a cache layer failure) is rethrown and lands in the generic boundary, which says "Your list didn't load" with a Try again that re-runs the same read.
+**Why it matters**: Acceptable as documented ("a bug"), but the user loses the per-tab Retry/ListFailed copy and the failure is not logged via `logTrackingEvent`.
+**Suggested fix**: Optionally log an outcome class before rethrowing so the bug is visible in logs.
+
+### 🟡 Month line is UTC, `lib/format.ts:222`
+**Problem**: "Started Oct 2026" can be a month off near month boundaries for users far from UTC.
+**Why it matters**: Documented as accepted; noting only because the card is the only place a user sees these timestamps.
+**Suggested fix**: None required; keep the documented tradeoff.
+
+## Nits
+- ⚪ `components/layout/page-media-type.tsx:38`, JSDoc line runs past the surrounding wrap width after the edit; re-wrap.
+- ⚪ `components/library/game-library-grid.tsx:91`, the `data-item-key` selector interpolates the key into a CSS selector; safe here (numeric id) but `CSS.escape` would be future proof.
+- ⚪ `components/library/game-library-card.tsx:46`, a missing game keeps a `released` release state in `gameItem`; harmless since the card ignores it, but a `null` release would say so.
+
+## Strengths
+- Reads are explicit about ownership (`.eq("user_id", userId)` plus RLS), stable ordering ends in `game_id`, exact counts, and the past-the-end page redirects to the real last page.
+- Failure states are distinct (Postgres failed, IGDB unreachable, boundary) and never render as the empty state, so users are not invited to remove healthy rows.
+- `typedHref` overloads make `typedHref("/search", "game")` a compile error; the invalid flag value throws only in `LibrarySection`, while the navbar degrades to off.
+- `showGameUndo` extraction keeps one toast id per game across surfaces.
+
+## Test coverage
+Good. New unit/component tests exist for `game-library.ts` (past-the-end, no count, db error, IGDB failure vs bug), the tab via `LibrarySection` (flag on/off dispatch), grid, card demo, error boundary, undo helper, env parsing, media-type parsing, and navbar/tabs/library-nav with the flag. Gaps are small: no test of focus movement after the last card is removed on page 1, and no pgTAP change is needed since no schema changed.
diff --git a/docs/scope/games.md b/docs/scope/games.md
index bea6b14e..6f54890f 100644
--- a/docs/scope/games.md
+++ b/docs/scope/games.md
@@ -27,8 +27,8 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md
| G1 | IGDB integration module | Foundation | done |
| G2 | Game data model and security policies | Foundation | done |
| G3 | Game page (tracer) | Slice G1 | done |
-| G4 | Game status and rating | Slice G1 | in-progress |
-| G5 | Games tab and library pages | Slice G2 | planned |
+| G4 | Game status and rating | Slice G1 | done |
+| G5 | Games tab and library pages | Slice G2 | done |
| G6 | Games home and list pages | Slice G3 | planned |
| G7 | Game search and filters | Slice G4 | planned |
| G8 | Game cards everywhere | Slice G4 | planned |
@@ -82,7 +82,7 @@ The thinnest real `/games/{id}`: hero only (cover, title, release date, platform
- [x] Test it: `/test game page`
spec [0025](../specs/0025-game-page/index.md) · code in [app/games/](../../app/games/)
-### G4. Game status and rating · in-progress · GA
+### G4. Game status and rating · done · GA
The segmented control (Want to play · Playing · Finished) and the score on the game page, saved through Server Actions.
**Done when:** a signed in user can set any status, remove the game by clicking the active option, and rate it 1 to 10 on Playing or Finished; every change survives reload and another session; the release gate disables Playing, Finished and rating before release, also in the Server Action; moving back to Want to play clears the score and Undo restores it; a signed out user is sent to sign in; failed writes never show false success.
- [x] Design it (spec): `/architect game status and rating`
@@ -99,10 +99,21 @@ spec [0026](../specs/0026-game-status-rating/index.md) · code in [app/games/](.
## Slice G2: The games library
-### G5. Games tab and library pages · needs a decision · GA
+### G5. Games tab and library pages · done · GA
GAMES joins the navbar tabs, and the three library pages show Want to play, Playing and Finished for games.
-**Done when:** `lib/catalog/media-type.ts` accepts `game` and `typedHref` writes it; the GAMES tab shows; `/watchlist`, `/upcoming` and `/watched` with `?type=game` show the right label, count, sort and pages, with empty states; each tab loads up to 500 games through batched IGDB reads and no page writes when it loads; unreleased games on Want to play show their release badge; an invalid `type` still shows the "That page doesn't exist" panel.
-- [ ] Design it (spec): `/architect games tab and library pages`
+**Done when:** `lib/catalog/media-type.ts` accepts `game` on the library pages only (`LibraryType`) and `typedHref` writes it; with `NEXT_PUBLIC_GAMES_LIBRARY` on, the GAMES tab shows on the three library pages; `/watchlist`, `/upcoming` and `/watched` with `?type=game` show the right label, count, sort and pages, with empty states; each tab reads one counted Postgres page and one IGDB batch for that page's games, and no page writes when it loads; unreleased games on Want to play show their release badge; an invalid `type` still shows the "That page doesn't exist" panel.
+- [x] Design it (spec): `/architect games tab and library pages`
+- [x] Build it: `/develop games tab and library pages`
+ - [x] Thin thread: the flag readers, `LibraryType`, `parseLibraryTypeParam`, `typedHref` overloads and a first Want to play page with real IGDB cards, AC-1, AC-2, AC-5, AC-6
+ - [x] Navbar: `libraryType` on `PageMediaType`, three tabs on library pages, `LibraryNav` carrying `game`, search and logo untouched by games, AC-3, AC-4
+ - [x] Tabs and cards: the three orders and paging, per tab badges, release pill, Started and Finished lines, hidden and missing cards, AC-6, AC-8 to AC-11
+ - [x] Removal and states: `GameLibraryGrid`, `showGameUndo`, focus, empty copy, the IGDB failure panel, AC-7, AC-12 to AC-14
+ - [x] Showcase and checks: `/showcase` states, 375px and 1280px, the Preview variable and `docs/deploy.md`, typecheck, lint, test, build, AC-15 to AC-18
+- [x] Verify it: `/check verify games tab and library pages`
+- [x] Test it: `/test games tab and library pages`
+- [x] Review it (fresh model): `/check review games tab and library pages`
+- [x] Document it: `/document games tab and library pages`
+spec [0027](../specs/0027-games-library-pages/index.md) · code in [components/library/](../../components/library/)
## Slice G3: Discovery
diff --git a/docs/specs/0023-igdb-integration-module/index.md b/docs/specs/0023-igdb-integration-module/index.md
index 36de9591..34faec38 100644
--- a/docs/specs/0023-igdb-integration-module/index.md
+++ b/docs/specs/0023-igdb-integration-module/index.md
@@ -1,7 +1,7 @@
# 0023. IGDB integration module
**Date**: 2026-10-17
-**Status**: In Progress
+**Status**: Accepted
Scope feature: [G1. IGDB integration module](../../scope/games.md#g1-igdb-integration-module) · Beta tier
diff --git a/docs/specs/0024-game-data-model-security/index.md b/docs/specs/0024-game-data-model-security/index.md
index 23f0499a..c407b266 100644
--- a/docs/specs/0024-game-data-model-security/index.md
+++ b/docs/specs/0024-game-data-model-security/index.md
@@ -1,7 +1,7 @@
# 0024. Game data model and security policies: one private row per user and game
**Date**: 2026-10-18
-**Status**: In Progress
+**Status**: Accepted
Scope feature: [G2. Game data model and security policies](../../scope/games.md) · GA tier
diff --git a/docs/specs/0025-game-page/index.md b/docs/specs/0025-game-page/index.md
index 9f3e13fa..d63b862b 100644
--- a/docs/specs/0025-game-page/index.md
+++ b/docs/specs/0025-game-page/index.md
@@ -1,7 +1,7 @@
# 0025. Game page (tracer)
**Date**: 2026-10-23
-**Status**: In Progress
+**Status**: Accepted
Scope feature: [G3. Game page (tracer)](../scope/games.md#g3-game-page-tracer) · Beta tier
diff --git a/docs/specs/0026-game-status-rating/index.md b/docs/specs/0026-game-status-rating/index.md
index c9fbed68..b6f8037f 100644
--- a/docs/specs/0026-game-status-rating/index.md
+++ b/docs/specs/0026-game-status-rating/index.md
@@ -1,7 +1,7 @@
# 0026. Game status and rating: the status pills, the score and their Undo on the game page
**Date**: 2026-10-24
-**Status**: In Progress
+**Status**: Accepted
Scope feature: [G4. Game status and rating](../../scope/games.md) · GA tier
diff --git a/docs/specs/0027-games-library-pages/index.md b/docs/specs/0027-games-library-pages/index.md
new file mode 100644
index 00000000..fa381514
--- /dev/null
+++ b/docs/specs/0027-games-library-pages/index.md
@@ -0,0 +1,185 @@
+# 0027. Games tab and library pages: Want to play, Playing and Finished on the three library routes
+
+**Date**: 2026-10-24
+**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
+
+Builds on: [0020](../0020-progress-based-library-pages/index.md) (the library pages, `LibrarySection`, the panels and the page rules), [0024](../0024-game-data-model-security/index.md) (`user_game_state`, its dates and its RLS), [0025](../0025-game-page/index.md) (the game page and its soft 404 for a hidden game) and [0026](../0026-game-status-rating/index.md) (`removeGame`, `restoreRemovedGame`, `useGameUndo`, `isGameReleased`). Settled in an `/architect` session on 2026-10-24.
+
+## Summary
+
+This adds a GAMES tab to `/watchlist`, `/upcoming` and `/watched`. On it, those pages list your Want to play, Playing and Finished games, each with a cover, a rating badge, a date line and a remove button with Undo. A game's status is stored, not worked out, so each tab is one plain Postgres page (24 rows with an exact count) plus one IGDB read for just those 24 games. Until a games browse page or game search exists, nothing in production leads to a game, so a new on/off value (`NEXT_PUBLIC_GAMES_LIBRARY`) keeps the tab off in production and on locally and in previews. No database change.
+
+## Requirements
+
+**User stories**:
+- As a signed in user, I want to see the games I want to play, am playing and have finished, each on its own page, so that my games list lives next to my shows and movies.
+- As a signed in user, I want each card to show my score or the community rating and when I started or finished, so that the list tells me something at a glance.
+- As a signed in user, I want to remove a game from its card with Undo, so that cleaning up the list is quick and a slip costs nothing.
+- As the operator, I want the games tab hidden in production until people have a way to find games, so that production never shows half a feature.
+
+**Acceptance criteria**:
+- **AC-1**: `NEXT_PUBLIC_GAMES_LIBRARY` holds `on` or `off`. Unset or empty means `off`. It is read only in `lib/env.ts`, which writes `process.env.NEXT_PUBLIC_GAMES_LIBRARY` literally so the value is inlined into the client bundle, in two functions written the way `getAuthEmailDelivery()` is (separate from `getPublicEnv()`, so a build with no Supabase values still works). `getGamesLibrary()` throws on any other value, with an error that names the variable and never its value; only `LibrarySection` calls it. `gamesLibraryEnabled()` returns a boolean and treats an invalid value as off without throwing; the navbar's client components call it at render (no prop drilling), so a bad value never takes down a page outside the library, the same split as `publicEnvProblems()` and `getPublicEnv()`. Tests set it with `vi.stubEnv`. `.env.example` lists it with a description. It is `on` in `.env.local` and in Vercel Preview, and unset in Vercel Production; `docs/deploy.md` records both. Because it is a `NEXT_PUBLIC_` value, a change takes a redeploy.
+- **AC-2**: `lib/catalog/media-type.ts` adds `LibraryType = MediaType | "game"` and `parseLibraryTypeParam(value, gamesEnabled)`. With the flag on, this parser accepts `tv`, `movie` and `game`; with it off, only `tv` and `movie`. Absent still means `tv`, and an empty, repeated or unknown value is null, as in `parseMediaTypeParam`. `MediaType` stays `"tv" | "movie"`, so `/search`, `/shows`, `/movies`, the title pages, the proxy and `CATALOG_PATHS` can never receive `game`. A new `LibraryPath = "/watchlist" | "/upcoming" | "/watched"` (a subtype of `TypedPath`) carries the rule: `typedHref` accepts `game` only with a `LibraryPath`. A `game` for `/search` is a type error, proven by a `@ts-expect-error` line in its test. `pageHref`, `NoSuchPage`, `ListFailed`, `EmptyPanel` and `LibraryNavView` take `LibraryType`. `LIBRARY_NOTES` and `TabNotes` stay `MediaType` only, because only the classified show and movie tabs use them.
+- **AC-3**: With the flag on, the navbar's media tabs (in the bar at every width; the mobile menu sheet holds no tabs) read SHOWS | MOVIES | GAMES on `/watchlist`, `/upcoming` and `/watched` only. GAMES links to the same page with `?type=game` and is lit (`aria-current="page"`) when the page's `type` is `game`. `MediaTypeTabs`' own fallback on those paths (before `?type` is known) renders the same three tabs with none lit, at the same footprint. The outer `Navbar` fallback, used only on dynamic routes, stays two tabs, since no library path is dynamic. On every other page the tabs stay SHOWS | MOVIES, and with the flag off GAMES never renders. `MediaTypeTabsView` takes its tab list and `hrefs` keyed by `LibraryType`. Every tab stays 40px tall with its 44px `hit-area-tab` target.
+- **AC-4**: `PageMediaType` (`components/layout/page-media-type.tsx`) keeps `type: MediaType | null` for `NavbarSearch` and `BrandLink`, and gains `libraryType: LibraryType | null` (from `libraryTypeForLocation`) for `MediaTypeTabs` and `LibraryNav`. On a games tab `type` is null for search and the logo, and `libraryType` is `game`. The Library links (Watchlist, Upcoming, Watched), in the bar and in the mobile sheet, carry `type=game`, so moving between the three pages keeps you on games. The page heading stays Watchlist, Upcoming or Watched (it stays in the prerendered shell). The navbar search does not render (neither the desktop field nor the mobile search icon in the bar), because there is no game search until G7. Its Suspense fallback cannot know `?type`, so on a games tab the search field placeholder shows once, then goes away when the type resolves; this one time flash is accepted. The logo renders as on a page with no type: the catalog last viewed (`/shows` or `/movies`), and `game` is never written to its `localStorage` key.
+- **AC-5**: `LibrarySection` parses `type` with `parseLibraryTypeParam(raw.type, getGamesLibrary() === "on")`. A null type, which includes `?type=game` with the flag off, shows the existing "That page doesn't exist" panel with its Back link to the shows tab. A malformed `page` shows the page number panel and keeps `type=game` in its Back to page 1 link. Both happen before any read, after `requireUser()`. An invalid flag value makes `getGamesLibrary()` throw here, so a misconfigured deploy shows the library error boundary instead of guessing.
+- **AC-6**: Each games tab reads one page through `getGameLibraryPage({ userId, tab, page })` in `lib/tracking/game-library.ts`. It runs one query on `user_game_state` with `user_id = userId` (explicit, as well as RLS), `status` set by the tab (`watchlist` gives `want_to_play`, `upcoming` gives `playing`, `watched` gives `finished`), an exact count, and `range` over `LIBRARY_PAGE_SIZE` (24). Want to play is ordered by `added_at` desc, Playing by `started_at` desc, Finished by `finished_at` desc, each then by `game_id` asc. A page past the end redirects to the last page (page 1 when the tab is empty). PostgREST's `PGRST103` (an offset past the end) is answered with a count only read, as `getWatchedMoviesPage` does. The read never runs inside `use cache`.
+- **AC-7**: The page's games come from one `getGamesByIds` call with only that page's ids (at most 24). There is no 500 game limit and no "checked" or "failed titles" note on a games tab. `getLibraryGames` turns IGDB's `found` array into a `Map` by id and passes `missingIds` through. Only an `IgdbError` (a systemic failure) becomes `failed`; an `IgdbValidationError` cannot happen with database ids and still throws as a bug. A failure replaces the grid with a "Couldn't reach IGDB" error panel (`IGDB didn't respond, so the games couldn't load. Try again in a moment.`) with Retry to the same URL, never the empty state. `TmdbFailed`'s inner `LoadFailed` becomes the shared panel, with a `CatalogFailed({ source: "TMDB" | "IGDB", href })` over it. A failed Postgres read shows the existing `ListFailed` panel for that list. Each failure is logged once with `logTrackingEvent(TRACKING_EVENT.listRead, …)`, outcome class only.
+- **AC-8**: A Want to play card shows the cover, the name linking to `/games/{id}`, and the amber `IgdbRatingBadge` (the community rating, labelled as such). When `isGameReleased(releaseDate, today)` is false, with `today` from `requestTodayUtc()` read once per request, the card also carries `ReleasePill` with `formatReleaseDate(releaseDate, today)`: the short date, or Date TBA when IGDB has none.
+- **AC-9**: A Playing or Finished card's badge is the cyan `PersonalScoreBadge` when the game has a score, else the amber `IgdbRatingBadge`. Its `meta` line reads `Started {Mon YYYY}` from `started_at` on Playing and `Finished {Mon YYYY}` from `finished_at` on Finished. The month is the UTC calendar month, formatted on the server by a new `formatMonthYear(iso)` in `lib/format.ts` (`en`, short month, numeric year, `timeZone: "UTC"`), for example `Finished Oct 2026`. A null date (impossible by the table checks, but nullable in the type) shows no line. The card picks its badge and line from its `list` prop. A score on a Want to play row cannot exist (spec 0024, AC-2) and is ignored.
+- **AC-10**: A game IGDB returns with `hidden: true` renders as a normal card for its tab (cover, name, badge, date line, remove button), but its name is plain text, not a link, because its game page answers a soft 404 (spec 0025, AC-7).
+- **AC-11**: A game id in IGDB's `missingIds` renders as a "No longer on IGDB" card at its normal place in the tab's order: the missing poster tile, that caption, no link, no badge, no date line, and its remove button. Nothing about the game is invented.
+- **AC-12**: Every card, missing ones included, has one round remove button, labelled `Remove {name} from your games` (`Remove missing game from your games` on a missing card). A tap hides the card at once through `useOptimistic`, then calls `removeGame(gameId)`. On success with an `undo`, the toast `Removed from your games.` shows with Undo for `UNDO_TOAST_MS`. `removeGame` itself calls `refresh()` on success, so the hidden card is simply absent from the next `items`. The grid follows the movie grid's `useOptimistic`, transition and settle order exactly, so the card never flashes back. Undo calls `restoreRemovedGame` with the returned payload, and its `refresh()` brings the card back in its old place, since its dates come back too. The toast is shown by a plain `showGameUndo({ gameId, undo, returnPath, navigate, restoring })` factored out of `useGameUndo` (which stays as a thin wrapper for the game page). Its toast id stays `game-undo-{id}`, so a status Undo and a removal Undo for the same game replace each other. Undo still works after you leave the grid, as on the movie grid, because the toast holds only the action and `navigate`. A removal that finds no row (another tab got there first) is a quiet success with no toast. On failure the card reappears in place and the toast comes from `showGameTrackingError`. A `session_expired` toast carries Sign in back to this page's URL, `type` and `page` included. Each removal is its own transition and toast, so several in a row never interfere.
+- **AC-13**: Removing a card moves focus first to the next card's title link, then to the previous one's, then to the heading. A card with no link (a hidden or missing game) takes focus on its remove button. Emptying a page past page 1 redirects, and the heading takes focus again after the remount. This uses the same rules and helpers as the movie grid (`card-focus.ts`, `focusLibraryHeading`).
+- **AC-14**: Each empty games tab shows text only, with no button (there is no games browse page until G6). Want to play says `No games to play yet` / `Games you mark Want to play show up here.`; Playing says `Nothing in progress` / `Games you mark Playing show up here.`; Finished says `No finished games yet` / `Games you mark Finished show up here.` The grid labels are `Games you want to play, page {n}`, `Games you're playing, page {n}` and `Games you finished, page {n}`. All of this copy lives in `LIBRARY_COPY` in `lib/tracking/messages.ts`.
+- **AC-15**: Nothing on these pages writes when it loads (spec 0020, AC-21). The rows are read per request under the user's session and never enter a shared cache. The IGDB batch is the existing `hours` cached public read, keyed on the ids alone. `/watchlist`, `/upcoming` and `/watched` stay private, through both the proxy redirect and `requireUser()`. A signed out visitor never receives game data.
+- **AC-16**: At 375px and 1280px the games grid uses the `catalog` `PosterGrid` preset (2, 3, 4, 6 columns), the existing `LibrarySkeleton` is the loading state, nothing scrolls sideways, every control keeps a 44px tap target with visible focus, and keyboard alone can open a game, remove one and use Undo from the toast.
+- **AC-17**: `/showcase` renders the game library card in every state: Want to play released, Want to play with a dated release pill, Want to play with Date TBA, Playing with and without a score, Finished with a score, hidden (no link) and missing.
+- **AC-18**: No migration and no `database.types.ts` change. `user_game_state` keeps only its primary key (spec 0024, AC-12). `pnpm typecheck`, `pnpm lint`, `pnpm test` and `pnpm build` pass, and `pnpm test:db` passes unchanged.
+
+## Decision
+
+**Chosen option**: Option 1: a library only `LibraryType`, a stored status read as one SQL page plus one IGDB batch, behind a production off flag.
+
+The three library routes accept `?type=game` (behind `NEXT_PUBLIC_GAMES_LIBRARY`) through a separate `LibraryType`. Each games tab is one ordered, counted Postgres page plus one IGDB read for that page, rendered by a new games grid that reuses the movie grid's removal, focus and panel patterns.
+
+**Implementation skills**: `supabase` (`supabase/agent-skills`, `.agents/skills/supabase/`) for the read under RLS · `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) for checking the tabs and pages in the running app.
+
+## Rationale
+
+Reasoning and options: see [rationale.md](rationale.md).
+
+## Feature design
+
+**Data model sketch**: no change. The read uses `user_game_state` (spec 0024) as it is:
+
+| Column | Type | Used for |
+|---|---|---|
+| `user_id` | uuid, not null, FK `auth.users`, PK part | the owner filter |
+| `game_id` | integer, not null, PK part | the IGDB id, the card's key, the tie break |
+| `status` | `game_status`, not null | which tab |
+| `rating` | smallint, null | the personal score badge |
+| `added_at` | timestamptz, not null | Want to play order |
+| `started_at` | timestamptz, null (not null on Playing) | Playing order, the Started line |
+| `finished_at` | timestamptz, null (not null on Finished) | Finished order, the Finished line |
+
+No index beyond the primary key (spec 0024, AC-12). One user's games fit a primary key prefix scan and a sort in memory.
+
+**State transitions**: none new. The only write here is the removal (row deleted) and its Undo (row restored), both spec 0026's.
+
+**Tab mapping** (one place, `GAME_LIBRARY_TABS` in `lib/tracking/game-library.ts`):
+
+| Route | `?type=game` tab | `status` | Order | Date line |
+|---|---|---|---|---|
+| `/watchlist` | Want to play | `want_to_play` | `added_at` desc, `game_id` asc | release pill when unreleased |
+| `/upcoming` | Playing | `playing` | `started_at` desc, `game_id` asc | `Started {Mon YYYY}` |
+| `/watched` | Finished | `finished` | `finished_at` desc, `game_id` asc | `Finished {Mon YYYY}` |
+
+**API surface**:
+
+| Surface | Kind | Key inputs | Key outputs | Auth | Key errors |
+|---|---|---|---|---|---|
+| `getGamesLibrary()` (`lib/env.ts`) | pure read of the env, `LibrarySection` only | `NEXT_PUBLIC_GAMES_LIBRARY` | `"on" \| "off"` | none | throws on an invalid value, naming the variable |
+| `gamesLibraryEnabled()` (`lib/env.ts`) | pure read of the env, navbar | `NEXT_PUBLIC_GAMES_LIBRARY` | `boolean` (invalid is false) | none | never throws |
+| `parseLibraryTypeParam(value, gamesEnabled)` (`lib/catalog/media-type.ts`) | pure | `string \| string[] \| null \| undefined`, `boolean` | `LibraryType \| null` | none | null for an invalid value |
+| `typedHref(path, type, options)` | pure, overloads | `"/search"` takes `MediaType`; the library paths take `LibraryType` | URL with `type` always written | none | compile error for `game` on `/search` |
+| `libraryTypeForLocation(pathname, typeParam, gamesEnabled)` | pure | as `mediaTypeForLocation` | `LibraryType \| null` (`game` only on the library paths) | none | none |
+| `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[]`, `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`.
+
+**Value sourcing**:
+
+| Action | Value produced / displayed | Source |
+|---|---|---|
+| media tabs | whether GAMES shows | `gamesLibraryEnabled()` and the pathname being a `LibraryPath` |
+| media tabs, Library links | which tab is lit, which type the links carry | the `type` search param, through `libraryTypeForLocation` into `PageMediaType.libraryType` |
+| navbar search, logo | whether search shows, where the logo goes | `PageMediaType.type`, null on a games tab |
+| `LibrarySection` | the user | `requireUser()`, never a client value |
+| `getGameLibraryPage` | the status to filter on | the route's `list`, through `GAME_LIBRARY_TABS` |
+| `getGameLibraryPage` | the order column | `GAME_LIBRARY_TABS` (`added_at`, `started_at`, `finished_at`) |
+| `getGameLibraryPage` | the total and last page | PostgREST's exact count, `libraryLastPage(total)` |
+| card | name, cover, community rating, hidden | `getGamesByIds` → `GameSummary.name`, `coverUrl`, `communityRating`, `hidden` |
+| card | missing | the id is in `BatchResult.missingIds` |
+| card | personal score | `user_game_state.rating` |
+| Want to play card | released or not | `isGameReleased(GameSummary.releaseDate, today)`, `today` = `requestTodayUtc()` once per request |
+| Want to play card | the pill text | `formatReleaseDate(GameSummary.releaseDate, today)` |
+| Playing / Finished card | the month in the date line | `started_at` / `finished_at`, UTC month through `formatMonthYear` |
+| card | the link | `/games/{gameId}`, through a new `gameHref(id)` beside `titleHref`, omitted when hidden or missing |
+| removal | the Undo payload | `removeGame`'s `undo` (`{ kind: "removed", previous, removedAt }`, spec 0026) |
+| session expired toast | where Sign in returns | `pageHref(list, "game", page)` |
+| failure panels | the Retry URL | `pageHref(list, "game", page)` |
+
+**Key invariants**:
+- A game is on exactly one games tab: its stored `status`. Nothing is derived or classified.
+- `game` reaches only the three library routes. `MediaType` never contains it (enforced by the compiler).
+- With the flag off, `?type=game` is a "doesn't exist" panel and GAMES renders nowhere.
+- Every order ends in `game_id`, so two games with the same timestamp never swap places between pages.
+- The page reads at most 24 rows and makes at most one IGDB request.
+- No page writes on load. No user row enters `use cache`.
+
+**Security model**: The pages are private. The proxy sends a signed out visitor to sign in, and `LibrarySection` calls `requireUser()` before parsing anything, the real check. The read runs under the user's JWT, so RLS on `user_game_state` (spec 0024, AC-9, AC-10) limits it to the owner, and the query also filters on `user_id` from the verified session. Removal and Undo are spec 0026's Server Actions, which check the session and use `auth.uid()` in SQL. IGDB receives only game ids, never user data (AGENTS.md, Games). The flag is public by design: it decides only whether a control renders, never who may read what.
+
+**Configuration required**:
+- `NEXT_PUBLIC_GAMES_LIBRARY`: `on` shows the GAMES tab and accepts `?type=game` on the library pages. Unset or `off` hides both. Set `on` in `.env.local` and Vercel Preview, leave it unset in Vercel Production until G6 or G7 gives people a way to find games.
+
+**Critical test scenarios**:
+- Happy path: a signed in user with games in all three statuses opens `/watchlist?type=game`, `/upcoming?type=game` and `/watched?type=game` and sees each game once, in its tab's order, with the right badge and date line, verifies **AC-3**, **AC-6**, **AC-8**, **AC-9**.
+- Paging: 30 Finished games give 24 on page 1 and 6 on page 2, and `?page=5` redirects to page 2, verifies **AC-6**.
+- Removal: remove a Playing game, Undo brings it back in the same place with its score and dates, and a second tab's removal is a quiet success, verifies **AC-12**, **AC-13**.
+- Failure case: an `IgdbError` shows "Couldn't reach IGDB" with Retry, never the empty state; a Postgres failure shows `ListFailed`, verifies **AC-7**.
+- Hidden and missing: a hidden game has no link, a missing id shows "No longer on IGDB" in place, and both can be removed, verifies **AC-10**, **AC-11**.
+- Flag off: no GAMES tab anywhere, and `?type=game` shows "That page doesn't exist", verifies **AC-1**, **AC-3**, **AC-5**.
+- Type boundary: `parseMediaTypeParam("game")` stays null, `/search?type=game` shows its existing invalid type panel, and `typedHref("/search", "game")` fails to compile, verifies **AC-2**.
+- Auth/permission: a signed out request to `/upcoming?type=game` is sent to sign in and renders no game data; user A never sees user B's games (the existing pgTAP isolation tests), verifies **AC-15**.
+
+## Build plan
+
+Tracer Bullet: first one thin real thread from the flag through the tab, the read and IGDB to a card on screen, then thicken one strand at a time, each end to end.
+
+1. **Thin thread.** Add `getGamesLibrary()`, `gamesLibraryEnabled()` and `.env.example`, `LibraryType`, `LibraryPath`, `parseLibraryTypeParam` and the `typedHref` overloads. Make `LibrarySection` route `game` to a first `gameLibraryTab` that reads one Want to play page with `getGameLibraryPage`, calls `getLibraryGames`, and renders plain `PosterCard`s (cover, name, link). Prove it at `/watchlist?type=game` with a real game, satisfies **AC-1**, **AC-2**, **AC-5**, **AC-6**.
+2. **The tab in the navbar.** Add `libraryTypeForLocation` and `PageMediaType.libraryType`, the three tab `MediaTypeTabs` on library paths (and its own fallback), `LibraryNav` carrying `game` in the bar and the sheet, `NavbarSearch` and `BrandLink` reading `type` (null on games). Update `navbar.test.tsx` and the tab tests, satisfies **AC-3**, **AC-4**.
+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`), `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
+
+**Positive**:
+- The games library works end to end in local and preview builds, with one SQL query and at most one IGDB request per page.
+- `MediaType` stays narrow, so G6 and G7 each widen the type on purpose, with the compiler listing every place that must handle `game`.
+- Production shows nothing half built; turning it on is one variable and a redeploy.
+
+**Negative / tradeoffs**:
+- Two media type unions (`MediaType` and `LibraryType`) and two parsers now live side by side until G6 or G7 merges them.
+- With the flag off in production, saved games stay invisible there, as they already are since spec 0026.
+- The navbar search disappears on a games tab, which may surprise you until G7 ships.
+- Headings read Watchlist, Upcoming and Watched over game lists titled Want to play, Playing and Finished. The words differ, though the grid labels and empty states say the games words.
+- A UTC month can be a month off near midnight for someone far from UTC.
+- 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. 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.
+- A new Vercel environment variable in Preview, recorded in `docs/deploy.md`.
+- `LIBRARY_COPY` becomes keyed by `LibraryType`, and its `browse` becomes optional.
+
+## Follow-up
+
+- [ ] Before G6 or G7 turns the flag on in production, decide whether the flag then goes away. A short note in that spec is enough.
+- [ ] G6: add `/games` to `CATALOG_PATHS`, show GAMES on every page, give the games empty states a Browse games button, and let the logo remember `game`.
+- [ ] G7: let the navbar search run on the games tab, and widen `/search`'s parser.
+- [ ] G8: replace `GameLibraryCard`'s remove button with the full status chip control, keeping the same item shape.
+- [ ] `/sync` after merge: repo fact lines in `AGENTS.md` for `NEXT_PUBLIC_GAMES_LIBRARY` and `getGamesLibrary()`, `LibraryType` versus `MediaType`, and the games tabs' read shape (SQL page plus one IGDB batch, no 500 limit); a `components/AGENTS.md` line for the new library files.
diff --git a/docs/specs/0027-games-library-pages/rationale.md b/docs/specs/0027-games-library-pages/rationale.md
new file mode 100644
index 00000000..e08f16b9
--- /dev/null
+++ b/docs/specs/0027-games-library-pages/rationale.md
@@ -0,0 +1,76 @@
+# 0027. Games tab and library pages: rationale
+
+The decision record behind [index.md](index.md). `/develop` builds from the index; this file explains why.
+
+## Context
+
+Signed in users can set a game's status and score on `/games/{id}` (spec 0026), but they cannot see their games anywhere else. The library routes `/watchlist`, `/upcoming` and `/watched` already serve shows and movies, picking the media type from `?type=` (feature 22). The games scope settled that these same routes carry games, with Want to play on `/watchlist`, Playing on `/upcoming` and Finished on `/watched`.
+
+Three forces shape how. First, a game's status is stored by hand, unlike a show's page, which is worked out from episodes and air dates on every request. The show and movie tabs read up to 500 titles and classify them because they must; a games tab does not have to. Second, `MediaType` (`"tv" | "movie"`) runs through about twenty files: `/search`, the catalog landings, the proxy, the logo's memory and the copy tables. Most of those have no games version yet. The games home and list pages are G6 and game search is G7, so a value of `game` reaching them would be a dead end or a crash. Third, nothing in production links to a game page yet (spec 0025 kept it out of links and the sitemap), so a GAMES tab shipped visible would show almost everyone an empty list they cannot fill.
+
+The library pages also carry rules this feature must keep: the heading and skeleton are a prerendered shell, `requireUser()` runs before any read, nothing writes on load, user rows never enter a shared cache, and a failure is a panel with Retry, never an empty state.
+
+## Options considered
+
+### Option 1: A library only type, a SQL page plus one IGDB batch, behind a flag
+
+`LibraryType = MediaType | "game"` is accepted only on the three library routes, gated by `NEXT_PUBLIC_GAMES_LIBRARY`. Each tab reads 24 rows with an exact count, ordered by its own date, then one `getGamesByIds` for those ids.
+
+**Pros**:
+- The compiler keeps `game` out of every surface that has no games version.
+- The cheapest possible read, with a truthful count and no limit note.
+- Production stays clean until a way to find games exists.
+
+**Cons**:
+- Two type unions and two parsers live together for a while.
+- A flag adds a variable to manage and a branch to test.
+
+### Option 2: Widen `MediaType` to include `game`
+
+Add `game` to the one union and give every switch over it a games branch or an explicit "not yet" rejection.
+
+**Pros**:
+- One type, one parser, and the final shape G6 and G7 need anyway.
+
+**Cons**:
+- About twenty files change now for features that do not exist yet, and each "not yet" branch is code to delete later.
+- A missed branch is a runtime dead end on a public page, not a compile error.
+
+### Option 3: Reuse the classified tab reader (up to 500 rows, one batch, slice in memory)
+
+Mirror `getMovieLibraryTab`: read up to 500 rows per tab, one IGDB batch for all of them, slice the page in memory, and show the limit note.
+
+**Pros**:
+- The same shape as the show and movie tabs, so `classifyTab` and `TabNotes` are reused.
+
+**Cons**:
+- Up to 500 IGDB lookups per page view where 24 are shown, and a limit with nothing to justify it, since there is no classification.
+- A heavy user's oldest games would fall past the limit for no reason.
+
+### Option 4: Ship the GAMES tab visible in production now
+
+No flag. GAMES is live at merge, and people fill it from direct `/games/{id}` links.
+
+**Pros**:
+- No flag to remove later.
+
+**Cons**:
+- Almost every user sees an empty tab with no way to fill it, which breaks the scope's rule that production never shows half a feature.
+
+## Rationale
+
+Option 1 follows the stored status. Since the database already knows which tab a game is on, the cheapest correct read is a page of rows with an exact count, the same shape Watched movies has used since spec 0008. Classifying up to 500 rows (Option 3) answers a question games do not ask, and its cost is an IGDB batch twenty times larger than the page.
+
+A separate `LibraryType` (over Option 2) puts the boundary where the product boundary is. In G5 only the library routes have a games version, so only they accept `game`, and the compiler proves the rest cannot see it. G6 and G7 then widen on purpose, each with a full list of what to handle. The cost is a second union for a few features, which is small and visible.
+
+The flag (over Option 4) keeps the scope's promise that production never shows half a feature. It follows the existing `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` pattern, so it adds no new mechanism. Keeping the headings in the shell, showing the tab only on library pages, hiding the navbar search on games, and leaving the empty state text only all follow from one rule: no link, button or control may lead to a page that does not exist yet.
+
+Calls made within the decision, with the runner up:
+- **One `GameLibraryGrid` beside the movie grid**, sharing `card-focus.ts`, the heading focus and the toast helpers, over generalising `LibraryGrid` across media types. The game actions, Undo payload and errors differ enough that one generic grid would branch on every line. Runner up: one generic grid with a media adapter.
+- **Factor the game Undo toast out of `useGameUndo` into a plain function**, since a hook bound to one `gameId` cannot serve a grid of many. Runner up: one client component per card, which would split the optimistic hide from the grid.
+- **`ReleasePill` (short date or Date TBA) on an unreleased Want to play card**, over the game page's sentence (`Releases {date}`), so the badge matches the movie Upcoming card's pill. Runner up: a text line in `meta`.
+- **A short month in the date line (`Finished Oct 2026`)**, which fits a two column card at 375px. Runner up: the long month.
+- **The remove button uses one remove glyph for every games tab**, labelled with the game's name. Runner up: a different status glyph per tab, which would need three new marks in the icon key for a control G8 replaces anyway.
+- **One `CatalogFailed({ source })` over the shared `LoadFailed`**, used for TMDB and IGDB, instead of a second copy of the panel. Runner up: a separate `IgdbFailed`.
+- **A second `libraryType` field on `PageMediaType`**, beside `type`, over widening `type` itself (from the cross check). Widening would hand `game` to `NavbarSearch` and `BrandLink`, which have no games version. Runner up: a small `useGamesTab()` hook read only by the tabs and `LibraryNav`.
+- **Two flag readers, throwing and not throwing** (from the cross check). The navbar renders on every page, so a typo in the variable must never take the public catalog down; only `LibrarySection` fails loudly, the `publicEnvProblems()` and `getPublicEnv()` split.
diff --git a/docs/specs/0027-games-library-pages/verify.md b/docs/specs/0027-games-library-pages/verify.md
new file mode 100644
index 00000000..c7ce8ad3
--- /dev/null
+++ b/docs/specs/0027-games-library-pages/verify.md
@@ -0,0 +1,45 @@
+# Verify: Games tab and library pages · spec 0027 · updated 2026-10-24
+_Steps derived from spec 0027 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._
+
+Setup: start the local stack, then run the app with `NEXT_PUBLIC_GAMES_LIBRARY=on` (for example `NEXT_PUBLIC_GAMES_LIBRARY=on pnpm dev:docker`). Sign in as `user-a@example.test` / `password-a`. Give user A games in all three statuses (the seed has The Witcher 3 on Playing with a score and Elden Ring on Finished; add a Want to play game, an unreleased Want to play game, and a made up id such as `999999991` for the missing card). Run `pnpm exec supabase db reset` when you finish, before `pnpm test:db`.
+
+## UI / manual
+- [x] Open `/watchlist?type=game` → the navbar reads SHOWS | MOVIES | GAMES with GAMES lit (`aria-current="page"`), the Library links carry `type=game`, and there is no navbar search field or mobile search icon → AC-3, AC-4
+- [x] On `/shows`, `/movies`, `/movies/550` and `/search` → only SHOWS | MOVIES, never GAMES → AC-3
+- [x] On a games tab, the logo links to the catalog last viewed (`/shows` by default), and `localStorage["bestats:last-media-type"]` never becomes `game` → AC-4
+- [x] `/watchlist?type=game` lists Want to play games, newest added first, each with its cover, a name linking to `/games/{id}` and the amber IGDB community rating → AC-6, AC-8
+- [x] An unreleased Want to play game shows its release pill (`Feb 3, 2027` style), or Date TBA with no date; a game out today or earlier shows no pill → AC-8
+- [x] `/upcoming?type=game` lists Playing games, newest started first: the cyan score when scored, else the amber community rating, and a `Started {Mon YYYY}` line → AC-6, AC-9
+- [x] `/watched?type=game` lists Finished games, newest finished first, with `Finished {Mon YYYY}` → AC-6, AC-9
+- [x] The month line uses the UTC month: a `finished_at` of `2026-11-01T01:00+02:00` reads `Finished Oct 2026` → AC-9 (value sourcing: `finished_at` through `formatMonthYear`)
+- [x] A game IGDB hides keeps its card but its name is plain text, not a link → AC-10
+- [x] A made up id shows a "No longer on IGDB" card at its place in the order, with no badge, no date line, and a working remove button → AC-11
+- [x] With 30 Finished games: page 1 shows 24, page 2 shows 6, and `?type=game&page=5` redirects to `?type=game&page=2` → AC-6
+- [x] Remove a Playing game → the card goes at once, focus moves to the next card's link (or the previous, or the heading), and the toast `Removed from your games.` offers Undo → AC-12, AC-13
+- [x] Undo within 10 seconds → the card comes back in its old place with its score and its Started month unchanged → AC-12
+- [x] Remove the same game in a second tab after the first → a quiet success, no toast → AC-12
+- [x] Sign out in another tab, then remove a game → the card comes back and the toast's Sign in returns to this page's URL with `type` and `page` → AC-12
+- [x] Remove the last card of page 2 → the page redirects to page 1 and the heading takes the focus → AC-13
+- [x] Each empty games tab shows its text only empty state (`No games to play yet`, `Nothing in progress`, `No finished games yet`) with no button → AC-14
+- [x] Block IGDB (for example a bad `IGDB_CLIENT_SECRET`) → "Couldn't reach IGDB" with Retry to the same URL, never the empty state → AC-7
+- [x] Stop the local database while signed in → the list's own "Couldn't load your …" panel with Retry → AC-7
+- [x] At 375px and 1280px: 2 and 6 columns, nothing scrolls sideways, the three tabs fit, every button keeps a 44px tap target; keyboard alone can open a game, remove one and reach Undo → AC-16
+- [x] `/showcase` → the Game library card section shows every state of AC-17 → AC-17
+- [x] Signed out, open `/upcoming?type=game` → sent to sign in, no game data in the response → AC-15
+- [x] Load each games tab and confirm in the Supabase logs that no write ran → AC-15
+
+## Flag off
+- [x] Run without `NEXT_PUBLIC_GAMES_LIBRARY` (or `off`) → no GAMES tab anywhere, and `/watchlist?type=game` shows "That page doesn't exist" with Back to Watchlist → AC-1, AC-3, AC-5
+- [x] Set it to `maybe` → the navbar still renders on every page without GAMES, and the library pages show the error boundary → AC-1, AC-5
+
+## Commands
+- [x] `pnpm typecheck` → passes, including the `@ts-expect-error` on `typedHref("/search", "game")` → AC-2, AC-18
+- [x] `pnpm lint:ci` → no errors → AC-18
+- [x] `pnpm test` → passes → AC-18
+- [x] `pnpm build` → passes, the three library routes still partial prerender → AC-18
+- [x] `pnpm exec supabase db reset && pnpm test:db` → passes unchanged → AC-18
+- [x] `git diff main -- supabase/ lib/supabase/database.types.ts` → empty → AC-18
+- [x] `vercel env ls preview` lists `NEXT_PUBLIC_GAMES_LIBRARY`; `vercel env ls production` does not → AC-1
+
+## Acceptance-criteria coverage
+- AC-1 flag off steps, Commands (Vercel) · AC-2 Commands (typecheck), `media-type.test.ts` · AC-3 first two UI steps · AC-4 first and third UI steps · AC-5 flag off steps · AC-6 tab order and paging steps · AC-7 IGDB and database failure steps · AC-8 Want to play steps · AC-9 Playing, Finished and UTC month steps · AC-10 hidden step · AC-11 missing step · AC-12 removal, Undo, second tab and session steps · AC-13 focus and redirect steps · AC-14 empty state step · AC-15 signed out and no write steps · AC-16 sizes step · AC-17 showcase step · AC-18 Commands
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..9e7a3dbb 100644
--- a/lib/catalog/media-type.ts
+++ b/lib/catalog/media-type.ts
@@ -1,8 +1,19 @@
import { z } from "zod";
+import type { LibraryList } from "./library-list";
+
/** 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.
@@ -13,16 +24,23 @@ 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 LibraryPath = (typeof LIBRARY_PATHS)[number];
+
+/**
+ * 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 TypedPath = (typeof TYPED_PATHS)[number];
@@ -36,6 +54,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 +88,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 +114,15 @@ export function titleHref(type: MediaType, id: number): string {
return `${CATALOG_PATHS[type]}/${id}`;
}
+/**
+ * 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}`;
+}
+
/**
* 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 +147,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 +181,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) {
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";
+}
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/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..bfba2e4d
--- /dev/null
+++ b/lib/tracking/game-library.ts
@@ -0,0 +1,131 @@
+import "server-only";
+
+import type { LibraryList } from "@/lib/catalog/library-list";
+import { type GameSummary, getGamesByIds, IgdbError } from "@/lib/igdb";
+
+import { type LibraryPage, readCountedPage } 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 function getGameLibraryPage({
+ userId,
+ tab,
+ page,
+}: {
+ userId: string;
+ tab: LibraryList;
+ page: number;
+}): Promise> {
+ const { status, order } = GAME_LIBRARY_TABS[tab];
+ 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),
+ map: (rows) =>
+ rows.map((row) => ({
+ gameId: row.game_id,
+ rating: row.rating,
+ addedAt: row.added_at,
+ startedAt: row.started_at,
+ finishedAt: row.finished_at,
+ })),
+ });
+}
+
+/** 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" };
+ }
+}
diff --git a/lib/tracking/library-lists.ts b/lib/tracking/library-lists.ts
index 4d3a9c5b..8acc0018 100644
--- a/lib/tracking/library-lists.ts
+++ b/lib/tracking/library-lists.ts
@@ -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
@@ -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 failed();
- return { kind: "ok", rows: [], total: head.count };
- }
- if (error || count === null) return failed();
-
- return {
- kind: "ok",
- total: count,
- rows: data.flatMap((row) =>
+ .not("watched_at", "is", null),
+ map: (rows) =>
+ rows.flatMap((row) =>
row.watched_at === null
? []
: [
@@ -580,9 +572,65 @@ 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 failed();
+ return listReadFailed();
}
}
@@ -612,7 +660,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..dd76a617 100644
--- a/lib/tracking/messages.ts
+++ b/lib/tracking/messages.ts
@@ -1,4 +1,5 @@
-import type { MediaType } from "@/lib/catalog/media-type";
+import type { LibraryList } from "@/lib/catalog/library-list";
+import type { LibraryType, MediaType } from "@/lib/catalog/media-type";
import type {
EpisodeTrackingError,
@@ -188,21 +189,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 }
+ LibraryList,
+ Record & { failed: string }
> = {
watchlist: {
tv: {
@@ -222,6 +227,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 +254,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 +280,31 @@ 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 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}`,
+ finished: (month: string) => `Finished ${month}`,
+} as const;
+
/** "1 show", "4 movies". */
function titles(n: number, type: MediaType): string {
const noun = type === "tv" ? "show" : "movie";