diff --git a/app/showcase/game-card-demo.test.tsx b/app/showcase/game-card-demo.test.tsx
new file mode 100644
index 00000000..3c447806
--- /dev/null
+++ b/app/showcase/game-card-demo.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { GameCardDemo } from "./game-card-demo";
+
+/**
+ * covers: spec 0030, AC-15
+ *
+ * `/showcase` keeps every catalog game card state the spec lists, and its
+ * bookmark is reachable and toggled from the keyboard.
+ */
+function card(name: string) {
+ const heading = screen.getByRole("heading", { name });
+ const item = heading.closest("li");
+ if (item === null) throw new Error(`No card for ${name}`);
+ return within(item);
+}
+
+const POSTER = "https://example.test/poster.jpg";
+
+describe("GameCardDemo", () => {
+ it("renders the seven states", () => {
+ render();
+ expect(screen.getAllByRole("listitem")).toHaveLength(7);
+ });
+
+ it("gives a visitor no control and a signed in card the bookmark", () => {
+ render();
+ expect(card("Visitor").queryByRole("button")).not.toBeInTheDocument();
+ expect(
+ card("No status").getByRole("button", {
+ name: "Want to play: No status",
+ }),
+ ).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("shows the release pill or Date TBA only on an unreleased wanted game", () => {
+ render();
+ expect(
+ card("Want to play, released").queryByText(/^Releases/),
+ ).not.toBeInTheDocument();
+ expect(
+ card("Want to play, unreleased").getByText("Releases Feb 3, 2027"),
+ ).toBeInTheDocument();
+ expect(
+ card("Want to play, Date TBA").getByText("Release date to be announced"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the chip and no button on started games, with the score when set", () => {
+ render();
+ const playing = card("Playing, scored");
+ expect(playing.getByText("Playing")).toBeInTheDocument();
+ expect(playing.getByText("Your score")).toBeInTheDocument();
+ expect(playing.queryByRole("button")).not.toBeInTheDocument();
+ const finished = card("Finished, unscored");
+ expect(finished.getByText("Finished")).toBeInTheDocument();
+ expect(finished.getByText("IGDB community rating")).toBeInTheDocument();
+ });
+
+ it("toggles the bookmark with Enter and Space", async () => {
+ const user = userEvent.setup();
+ render();
+ const button = card("No status").getByRole("button");
+ button.focus();
+ await user.keyboard("{Enter}");
+ expect(button).toHaveAttribute("aria-pressed", "true");
+ await user.keyboard(" ");
+ expect(button).toHaveAttribute("aria-pressed", "false");
+ });
+});
diff --git a/app/showcase/game-card-demo.tsx b/app/showcase/game-card-demo.tsx
new file mode 100644
index 00000000..bed8a195
--- /dev/null
+++ b/app/showcase/game-card-demo.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useState } from "react";
+
+import { ReleasePill } from "@/components/library/dated-pill";
+import { PosterCard } from "@/components/poster-card";
+import { PosterGrid } from "@/components/poster-grid";
+import {
+ IgdbRatingBadge,
+ PersonalScoreBadge,
+} from "@/components/rating-badges";
+import { CardRoundButton } from "@/components/tracking/card-round-button";
+import { GameStatusChip } from "@/components/tracking/game-status-chip";
+import { PlanIcon, PlannedIcon } from "@/components/tracking/tracking-icons";
+import type { ReleaseDate } from "@/lib/format";
+import type { StartedGameStatus } from "@/lib/tracking/types";
+
+type DemoCard = {
+ name: string;
+ /** Undefined is a visitor: no bottom row at all (spec 0030, AC-9). */
+ state?:
+ | { kind: "bookmark"; pressed: boolean; release?: ReleaseDate }
+ | {
+ kind: "started";
+ status: StartedGameStatus;
+ rating: number | null;
+ };
+ year: number | null;
+};
+
+const CARDS: DemoCard[] = [
+ { name: "Visitor", year: 2015 },
+ {
+ name: "No status",
+ state: { kind: "bookmark", pressed: false },
+ year: 2015,
+ },
+ {
+ name: "Want to play, released",
+ state: { kind: "bookmark", pressed: true },
+ year: 2015,
+ },
+ {
+ name: "Want to play, unreleased",
+ state: {
+ kind: "bookmark",
+ pressed: true,
+ release: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" },
+ },
+ year: 2027,
+ },
+ {
+ name: "Want to play, Date TBA",
+ state: { kind: "bookmark", pressed: true, release: null },
+ year: null,
+ },
+ {
+ name: "Playing, scored",
+ state: { kind: "started", status: "playing", rating: 9 },
+ year: 2020,
+ },
+ {
+ name: "Finished, unscored",
+ state: { kind: "started", status: "finished", rating: null },
+ year: 2018,
+ },
+];
+
+/**
+ * Every state of a catalog game card (spec 0030, AC-15), for `/showcase`:
+ * the real badges, pill, chip and round button the slots render, without the
+ * session read or the Server Action. The bookmark toggles here only, so the
+ * keyboard path can be tried; a client module only for that state.
+ */
+export function GameCardDemo({ poster }: { poster: string }) {
+ return (
+
+ {CARDS.map((card) => (
+
+
+ ) : (
+
+ )
+ }
+ controls={
+ card.state === undefined ? undefined : card.state.kind ===
+ "started" ? (
+
+ ) : (
+
+ )
+ }
+ />
+
+ ))}
+
+ );
+}
+
+function DemoBookmark({
+ name,
+ pressed: initial,
+ release,
+}: {
+ name: string;
+ pressed: boolean;
+ release: ReleaseDate | undefined;
+}) {
+ const [pressed, setPressed] = useState(initial);
+ return (
+ <>
+ {pressed && release !== undefined ? (
+
+ ) : null}
+ setPressed(!pressed)}
+ className="ml-auto"
+ >
+ {pressed ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/app/showcase/game-library-card-demo.tsx b/app/showcase/game-library-card-demo.tsx
index d659555a..121fadd1 100644
--- a/app/showcase/game-library-card-demo.tsx
+++ b/app/showcase/game-library-card-demo.tsx
@@ -14,6 +14,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
const base: LibraryGameItem = {
gameId: 1,
name: "",
+ status: "want_to_play",
coverUrl: poster,
communityRating: 8.4,
rating: null,
@@ -46,6 +47,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
item: {
...base,
name: "Playing, scored",
+ status: "playing",
rating: 9,
dateLine: "Started Feb 2026",
},
@@ -54,6 +56,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
item: {
...base,
name: "Playing, not scored",
+ status: "playing",
dateLine: "Started Oct 2026",
},
},
@@ -61,6 +64,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
item: {
...base,
name: "Finished, scored",
+ status: "finished",
rating: 7,
dateLine: "Finished Mar 2026",
},
@@ -69,6 +73,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
item: {
...base,
name: "Hidden by IGDB, no link",
+ status: "finished",
hidden: true,
dateLine: "Finished Jan 2025",
},
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index 65b763e8..5f06c7c9 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -74,6 +74,7 @@ import { catalogList, listPath } from "@/lib/catalog/lists";
import { TMDB_HOME_URL } from "@/lib/legal/operator";
import { CardRoundButtonDemo } from "./card-round-button-demo";
+import { GameCardDemo } from "./game-card-demo";
import { GameLibraryCardDemo } from "./game-library-card-demo";
import { GameSearchDemo } from "./game-search-demo";
@@ -450,9 +451,16 @@ export default function ShowcasePage() {
+
+
diff --git a/app/shows/[id]/season/[number]/page.tsx b/app/shows/[id]/season/[number]/page.tsx
index 42cd4658..d2862680 100644
--- a/app/shows/[id]/season/[number]/page.tsx
+++ b/app/shows/[id]/season/[number]/page.tsx
@@ -15,7 +15,7 @@ import { parseSeasonNumber, parseTmdbId } from "@/lib/catalog/ids";
import { adjacentSeasons, orderSeasons } from "@/lib/catalog/seasons";
import { truncateAtWord } from "@/lib/format";
import { catalogMetadata } from "@/lib/seo/metadata";
-import { episodeIdsKey } from "@/lib/tracking/episode-state";
+import { idsKey } from "@/lib/tracking/ids-key";
import { loadSeason } from "./load-season";
@@ -144,7 +144,7 @@ async function SeasonDetail({ params }: { params: SeasonParams }) {
);
// One key for the whole season, built once, so the header and every row
// share one tracking read (spec 0011, AC-18).
- const idsKey = episodeIdsKey(season.episodes.map((episode) => episode.id));
+ const seasonIdsKey = idsKey(season.episodes.map((episode) => episode.id));
const episodes = season.episodes.map(({ id, episodeNumber, airDate }) => ({
id,
episodeNumber,
@@ -173,7 +173,7 @@ async function SeasonDetail({ params }: { params: SeasonParams }) {
showId={id}
seasonNumber={seasonNumber}
seasonName={season.name}
- idsKey={idsKey}
+ idsKey={seasonIdsKey}
episodes={episodes}
/>
@@ -195,7 +195,7 @@ async function SeasonDetail({ params }: { params: SeasonParams }) {
)}
diff --git a/components/AGENTS.md b/components/AGENTS.md
index 40740a8a..4f0cfd99 100644
--- a/components/AGENTS.md
+++ b/components/AGENTS.md
@@ -45,7 +45,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006).
- `components/catalog/` holds the title page sections both `/movies/{id}` and `/shows/{id}` share (spec 0022): `title-details.tsx` (crew and Facts, from the detail read), and `title-extras.tsx`, `where-to-watch.tsx` and `trailer-button.tsx`, fed by the separate extras read. The extras sections stream in their own Suspense boundary and read the visitor's region there through `getVisitorRegion()` (`lib/region.ts`), never in the shell. The trailer iframe (`youtube-nocookie`) mounts only while its dialog is open.
- `components/catalog/landing/` holds the `/movies` and `/shows` landing sections, all Server Components except `landing-parallax.tsx` (`LandingParallax`), the landing's one client island, outside the shell. It writes only inline transforms to the `data-*` nodes the sections render, so every card and link stays server HTML. It and `layout/page-background-motion.tsx` share `startMotionLoop` (`lib/motion/loop.ts`), which owns the reduced motion stop and the mouse-only lean; a new motion loop uses it too.
-- `/games` renders the same landing (spec 0028). Each media type's card and hero slots come from `LANDING_SLOTS` in `landing/landing-badge.tsx`; the `game` entry has no badge or controls until G8 (null), so add a game slot there, not as a branch in a card. A list page's error and empty panels live in `catalog/list-panels.tsx`, free of any read, so `/showcase` renders the real ones; each names its catalog through `CATALOG_SOURCES` (`lib/catalog/media-type.ts`), and a catalog failure is recognised only by `isCatalogError` (`lib/catalog-errors.ts`).
+- `/games` renders the same landing (spec 0028). Each media type's card and hero slots come from `LANDING_SLOTS` in `landing/landing-badge.tsx`; since G8 the `game` entry carries the status badge, the controls and the year `meta` (spec 0030), so change a game slot there, not as a branch in a card. A list page's error and empty panels live in `catalog/list-panels.tsx`, free of any read, so `/showcase` renders the real ones; each names its catalog through `CATALOG_SOURCES` (`lib/catalog/media-type.ts`), and a catalog failure is recognised only by `isCatalogError` (`lib/catalog-errors.ts`).
- `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo. The games tabs (spec 0027) are `game-library-tab.tsx` (server), `game-library-grid.tsx` (client, the movie grid's `useOptimistic` order copied on purpose, so a removal fix may need making in both; the focus order after a removal is shared through `use-removal-focus.ts`) and `game-library-card.tsx`; their Undo toast is the plain `showGameUndo` in `components/tracking/use-game-undo.ts`. `library-error-boundary.tsx` (`catchError`) wraps each page's streamed section, below the heading, so `redirect()` still passes through.
- `components/layout/site-footer.tsx` renders under every route from the root layout and must read no request state (`app/layout-purity.test.ts`). It is flat (no glass, no border) and carries no email address. Its credit line links `OPERATOR_HANDLE` to `OPERATOR_GITHUB_URL`, both from `lib/legal/operator.ts`. `components/legal/` holds the reading column and helpers `/privacy` and `/terms` share; use `LEGAL_LINK_CLASS` for any link in legal text (spec 0017).
- The card marks are explained once, by `layout/icon-key.tsx`, the footer's first row on every route; there is no per page legend. A new mark adds its entry there, and a new badge must reuse `GlassPill` rather than re-derive it.
diff --git a/components/catalog/catalog-list-cards.tsx b/components/catalog/catalog-list-cards.tsx
index 8de2a9de..84d5c33e 100644
--- a/components/catalog/catalog-list-cards.tsx
+++ b/components/catalog/catalog-list-cards.tsx
@@ -52,10 +52,14 @@ function CatalogListCards({
));
}
if (result.mediaType === "game") {
- return result.paged.results.slice(0, limit).map((game, index) => (
+ const games = result.paged.results.slice(0, limit);
+ const ids = games.map((game) => game.id);
+ return games.map((game, index) => (
({
),
GameListCard: (props: {
game: { name: string };
+ gridGameIds: number[];
priority: boolean;
+ returnPath: string;
releaseDay?: string;
}) => (
{props.game.name}
@@ -408,6 +412,20 @@ describe("a games list page", () => {
).toBeInTheDocument();
});
+ // spec 0030, AC-1: a list page's cards share one read of the page's own
+ // ids, and a session expired toast returns to the page they sit on.
+ it("gives every game card the whole page's ids and the page URL to return to (spec 0030, AC-1)", async () => {
+ read.mockResolvedValue(gamePage(3, 30));
+
+ await renderGrid(catalogList("game", "popular"), { page: "2" });
+
+ for (const name of ["Game 1", "Game 2", "Game 3"]) {
+ const card = screen.getByText(name);
+ expect(card).toHaveAttribute("data-ids", "1,2,3");
+ expect(card).toHaveAttribute("data-return", "/games/popular?page=2");
+ }
+ });
+
it("counts pages from IGDB's count, 24 a page", async () => {
read.mockResolvedValue(gamePage(24, 50));
await renderGrid(COMING_SOON);
diff --git a/components/catalog/landing/landing-badge.test.tsx b/components/catalog/landing/landing-badge.test.tsx
index cb4f16a5..df6c7154 100644
--- a/components/catalog/landing/landing-badge.test.tsx
+++ b/components/catalog/landing/landing-badge.test.tsx
@@ -3,7 +3,8 @@ import { describe, expect, it, vi } from "vitest";
/**
* covers: prompts/landing-card-controls.md, decisions 1 and 2;
- * prompts/landing-review-fixes.md, decisions 3 and 7; spec 0028, AC-7, AC-8
+ * prompts/landing-review-fixes.md, decisions 3 and 7; spec 0028, AC-7;
+ * spec 0030, AC-1, AC-2, AC-7, AC-10
*
* `LandingControls` picks the slot for the card's media type, hands it the
* section's ids so it shares the badge's read, and sends a session expired
@@ -19,6 +20,10 @@ vi.mock("@/components/tracking/show-card-slots", () => ({
ShowCardBadge: () => null,
ShowCardControls: () => null,
}));
+vi.mock("@/components/tracking/game-card-slots", () => ({
+ GameCardBadge: () => null,
+ GameCardControls: () => null,
+}));
vi.mock("@/components/tracking/movie-tracking-slot", () => ({
MovieTrackingSlot: () => null,
@@ -30,9 +35,8 @@ vi.mock("@/components/tracking/game-tracking-slot", () => ({
GameTrackingSlot: () => null,
}));
-const { LandingBadge, LandingControls, LandingTracking } = await import(
- "./landing-badge"
-);
+const { LandingBadge, LandingControls, LandingTracking, landingMeta } =
+ await import("./landing-badge");
const { GameTrackingSlot } = await import(
"@/components/tracking/game-tracking-slot"
);
@@ -49,6 +53,9 @@ const { MovieCardControls } = await import(
const { ShowCardControls } = await import(
"@/components/tracking/show-card-slots"
);
+const { GameCardBadge, GameCardControls } = await import(
+ "@/components/tracking/game-card-slots"
+);
const BASE = {
title: "Dune",
@@ -109,8 +116,9 @@ describe("LandingControls", () => {
});
});
-// spec 0028, AC-7, AC-8: a game card has IGDB's rating, no session read
-// and no controls; the hero gets the game page's own status control.
+// spec 0030, AC-1, AC-2, AC-7, AC-10: a game card gets the game slots, with
+// the section's ids and /games, and IGDB's rating while the read is pending;
+// the hero keeps the game page's own status control (spec 0028, AC-7).
describe("a game", () => {
const GAME = {
...BASE,
@@ -119,20 +127,51 @@ describe("a game", () => {
id: 1942,
href: "/games/1942",
releaseDate: "2015-05-19",
+ year: 2015,
};
- it("badges with IGDB's community rating, outside any Suspense", () => {
- const badge = LandingBadge({ item: GAME, ids: [1942] });
- expect(badge.type).not.toBe(Symbol.for("react.suspense"));
- const inner = (badge.type as (props: object) => React.ReactElement)(
- badge.props as object,
+ it("badges with the game slot, falling back to IGDB's community rating", () => {
+ const badge = LandingBadge({ item: GAME, ids: [1942, 7346] });
+ expect(badge.type).toBe(Symbol.for("react.suspense"));
+ const { fallback, children } = badge.props as {
+ fallback: React.ReactElement;
+ children: React.ReactElement;
+ };
+ const rating = (fallback.type as (props: object) => React.ReactElement)(
+ fallback.props as object,
);
- expect(inner.type).toBe(IgdbRatingBadge);
- expect(inner.props).toEqual({ value: 8.4 });
+ expect(rating.type).toBe(IgdbRatingBadge);
+ expect(rating.props).toEqual({ value: 8.4 });
+ expect(children.type).toBe(GameCardBadge);
+ expect(children.props).toEqual({
+ gameId: 1942,
+ releaseDate: "2015-05-19",
+ communityRating: 8.4,
+ gridGameIds: [1942, 7346],
+ });
+ });
+
+ it("renders the game controls with /games, passing the release pill switch", () => {
+ const child = slot(
+ LandingControls({ item: GAME, ids: [1942], releasePill: false }),
+ );
+ expect(child.type).toBe(GameCardControls);
+ expect(child.props).toEqual({
+ gameId: 1942,
+ name: "Dune",
+ releaseDate: "2015-05-19",
+ gridGameIds: [1942],
+ returnPath: "/games",
+ releasePill: false,
+ });
});
- it("has no controls", () => {
- expect(LandingControls({ item: GAME, ids: [1942] })).toBeNull();
+ it("prints the release year as the meta line, and a show or movie none", () => {
+ expect(landingMeta(GAME)).toBe(2015);
+ expect(landingMeta({ ...GAME, year: null })).toBeUndefined();
+ expect(
+ landingMeta({ ...BASE, mediaType: "movie", id: 1, releaseDate: null }),
+ ).toBeUndefined();
});
it("gets the game status control in the hero", () => {
diff --git a/components/catalog/landing/landing-badge.tsx b/components/catalog/landing/landing-badge.tsx
index 1c0fd7e5..8087e7e5 100644
--- a/components/catalog/landing/landing-badge.tsx
+++ b/components/catalog/landing/landing-badge.tsx
@@ -2,6 +2,10 @@ import type * as React from "react";
import { Suspense } from "react";
import { IgdbRatingBadge, TmdbRatingBadge } from "@/components/rating-badges";
+import {
+ GameCardBadge,
+ GameCardControls,
+} from "@/components/tracking/game-card-slots";
import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";
import {
MovieCardBadge,
@@ -23,17 +27,21 @@ type Ids = readonly number[];
* Each media type's personal slots, the one place the landing branches on a
* title's type to pick a component (prompts/landing-review-fixes.md,
* decision 7). Every entry takes a `LandingItem`, so the cards and the hero
- * never name a show, movie or game slot themselves. A game has no card badge
- * or controls until G8, so those are null (spec 0028, AC-8).
+ * never name a show, movie or game slot themselves. `meta` is the line under
+ * a poster card's name: only a game prints one, its release year (spec 0030,
+ * AC-10).
*/
const LANDING_SLOTS: Record<
LibraryType,
{
- badge: ((item: LandingItem, ids: Ids) => React.ReactNode) | null;
- controls:
- | ((item: LandingItem, ids: Ids, releasePill: boolean) => React.ReactNode)
- | null;
+ badge: (item: LandingItem, ids: Ids) => React.ReactNode;
+ controls: (
+ item: LandingItem,
+ ids: Ids,
+ releasePill: boolean,
+ ) => React.ReactNode;
tracking: (item: LandingItem) => React.ReactNode;
+ meta: ((item: LandingItem) => React.ReactNode) | null;
}
> = {
tv: {
@@ -55,6 +63,7 @@ const LANDING_SLOTS: Record<
tracking: (item) => (
),
+ meta: null,
},
movie: {
badge: (item, ids) => (
@@ -82,10 +91,27 @@ const LANDING_SLOTS: Record<
releaseDate={item.releaseDate}
/>
),
+ meta: null,
},
game: {
- badge: null,
- controls: null,
+ badge: (item, ids) => (
+
+ ),
+ controls: (item, ids, releasePill) => (
+
+ ),
tracking: (item) => (
),
+ meta: (item) => item.year ?? undefined,
},
};
@@ -113,14 +140,14 @@ function CommunityRatingBadge({ rating }: { rating: LandingItem["rating"] }) {
* TMDB rating at once, swapped for the personal state the title's library
* page gives it once the session read arrives, as on every catalog card
* (prompts/catalog-card-status.md). `ids` is the whole section, so its badges
- * share one read. A game shows only IGDB's rating, with no session read and
- * no Suspense, until G8 gives game cards a status (spec 0028, AC-8).
+ * share one read. A game falls back to IGDB's rating (spec 0030, AC-2).
*/
function LandingBadge({ item, ids }: { item: LandingItem; ids: Ids }) {
- const badge = LANDING_SLOTS[item.mediaType].badge;
- const rating = ;
- if (badge === null) return rating;
- return {badge(item, ids)};
+ return (
+ }>
+ {LANDING_SLOTS[item.mediaType].badge(item, ids)}
+
+ );
}
/**
@@ -130,8 +157,8 @@ function LandingBadge({ item, ids }: { item: LandingItem; ids: Ids }) {
* on the session, and the same `ids` as the badge, so both share one read.
* A visitor gets nothing, as everywhere else. The return path is the landing
* the card sits on. `releasePill` is false on a card that already prints a
- * movie's date (prompts/landing-review-fixes.md, decision 3). A game card
- * has no controls yet, so nothing at all (spec 0028, AC-8).
+ * movie's or game's date (prompts/landing-review-fixes.md, decision 3; spec
+ * 0030, AC-7).
*/
function LandingControls({
item,
@@ -142,10 +169,10 @@ function LandingControls({
ids: Ids;
releasePill?: boolean;
}) {
- const controls = LANDING_SLOTS[item.mediaType].controls;
- if (controls === null) return null;
return (
- {controls(item, ids, releasePill)}
+
+ {LANDING_SLOTS[item.mediaType].controls(item, ids, releasePill)}
+
);
}
@@ -163,4 +190,18 @@ function LandingTracking({ item }: { item: LandingItem }) {
);
}
-export { CommunityRatingBadge, LandingBadge, LandingControls, LandingTracking };
+/**
+ * A landing poster card's meta line: a game's release year, as on every
+ * other game card, and nothing for a show or movie (spec 0030, AC-10).
+ */
+function landingMeta(item: LandingItem): React.ReactNode {
+ return LANDING_SLOTS[item.mediaType].meta?.(item);
+}
+
+export {
+ CommunityRatingBadge,
+ LandingBadge,
+ LandingControls,
+ LandingTracking,
+ landingMeta,
+};
diff --git a/components/catalog/landing/landing-hero.test.tsx b/components/catalog/landing/landing-hero.test.tsx
index 9316fa2d..bbb22faa 100644
--- a/components/catalog/landing/landing-hero.test.tsx
+++ b/components/catalog/landing/landing-hero.test.tsx
@@ -42,6 +42,10 @@ vi.mock("@/components/tracking/show-card-slots", () => ({
ShowCardBadge: () => null,
ShowCardControls: () => null,
}));
+vi.mock("@/components/tracking/game-card-slots", () => ({
+ GameCardBadge: () => null,
+ GameCardControls: () => null,
+}));
vi.mock("@/components/tracking/movie-tracking-slot", () => ({
MovieTrackingSlot: ({ movieId }: { movieId: number }) => (
{`movie/${movieId}`}
diff --git a/components/catalog/landing/landing-parts.tsx b/components/catalog/landing/landing-parts.tsx
index 81b723b4..9466cf67 100644
--- a/components/catalog/landing/landing-parts.tsx
+++ b/components/catalog/landing/landing-parts.tsx
@@ -10,7 +10,7 @@ import {
import { PosterCard } from "@/components/poster-card";
import { type CatalogList, listPath } from "@/lib/catalog/lists";
-import { LandingBadge, LandingControls } from "./landing-badge";
+import { LandingBadge, LandingControls, landingMeta } from "./landing-badge";
import {
itemIds,
type LandingItem,
@@ -128,6 +128,7 @@ function LandingPoster({
title={item.title}
posterUrl={item.posterUrl}
href={item.href}
+ meta={landingMeta(item)}
badge={}
controls={}
priority={priority}
diff --git a/components/catalog/landing/landing-sections.test.tsx b/components/catalog/landing/landing-sections.test.tsx
index 4af2e4a4..d86ee08e 100644
--- a/components/catalog/landing/landing-sections.test.tsx
+++ b/components/catalog/landing/landing-sections.test.tsx
@@ -50,6 +50,8 @@ vi.mock("./landing-badge", () => ({
{item.mediaType}:{item.id}
),
+ landingMeta: (item: { mediaType: string; year: number | null }) =>
+ item.mediaType === "game" ? (item.year ?? undefined) : undefined,
}));
const { TmdbError } = await import("@/lib/tmdb/errors");
@@ -491,7 +493,7 @@ describe("the sidebar count (decision 12)", () => {
});
});
-/** covers: spec 0028, AC-3, AC-8, AC-9, AC-11 */
+/** covers: spec 0028, AC-3, AC-8, AC-9, AC-11; spec 0030, AC-1, AC-7, AC-10 */
describe("the games landing sections", () => {
const GAMES = landingSlots("game");
@@ -555,6 +557,32 @@ describe("the games landing sections", () => {
expect(screen.getByText("Jan 8, 2027")).toBeInTheDocument();
});
+ it("gives every game poster its controls with the section's ids, and its year", async () => {
+ read.mockResolvedValue(gamePage(games(24)));
+
+ const { container } = render(await PopularItems({ list: GAMES.popular }));
+
+ const controls = screen.getAllByTestId("controls");
+ expect(controls.length).toBeGreaterThan(0);
+ const ids = controls[0]?.getAttribute("data-ids");
+ for (const control of controls) {
+ expect(control).toHaveAttribute("data-ids", ids ?? "");
+ expect(control.textContent).toMatch(/^game:/);
+ }
+ expect(container.querySelectorAll("p")).not.toHaveLength(0);
+ expect(screen.getAllByText("2026")).toHaveLength(controls.length);
+ });
+
+ it("keeps the slot's release pill off the wide card, which prints the date", async () => {
+ read.mockResolvedValue(gamePage(games(2)));
+
+ render(await UpcomingItems({ list: GAMES.upcoming }));
+
+ for (const control of screen.getAllByTestId("controls")) {
+ expect(control).toHaveAttribute("data-release-pill", "false");
+ }
+ });
+
it("falls back to the cover on a wide card with no art", async () => {
read.mockResolvedValue(gamePage(games(1, { artUrl: null })));
diff --git a/components/catalog/list-cards.test.tsx b/components/catalog/list-cards.test.tsx
index fbe4c7fc..a2cea013 100644
--- a/components/catalog/list-cards.test.tsx
+++ b/components/catalog/list-cards.test.tsx
@@ -2,7 +2,8 @@ import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
- * covers: spec 0021, AC-4, AC-5; spec 0028, AC-8
+ * covers: spec 0021, AC-4, AC-5; spec 0028, AC-8; spec 0030, AC-1, AC-7,
+ * AC-10
*
* A list card is the full grid card: the personal badge, the TV meta line and
* the controls, each handed the row's ids and the page's return path. The
@@ -67,6 +68,32 @@ vi.mock("@/components/tracking/movie-card-slots", () => ({
),
}));
+vi.mock("@/components/tracking/game-card-slots", () => ({
+ GameCardBadge: (props: {
+ gameId: number;
+ communityRating: number | null;
+ gridGameIds: number[];
+ }) => (
+
+ badge {props.gameId} {props.communityRating}
+
+ ),
+ GameCardControls: (props: {
+ name: string;
+ gridGameIds: number[];
+ returnPath: string;
+ releasePill?: boolean;
+ }) => (
+
+ controls {props.name}
+
+ ),
+}));
const { GameListCard, MovieListCard, ShowListCard } = await import(
"./list-cards"
@@ -95,6 +122,7 @@ const GAME = {
coverUrl: null,
communityRating: 9.3,
releaseDate: "2026-11-06",
+ releaseYear: 2026,
} as Parameters[0]["game"];
afterEach(() => {
@@ -254,38 +282,72 @@ describe("MovieListCard (AC-4)", () => {
});
});
-describe("GameListCard (spec 0028, AC-8)", () => {
- it("links the card to the game page with IGDB's rating", () => {
- render();
+describe("GameListCard (spec 0028, AC-8; spec 0030)", () => {
+ const props = {
+ game: GAME,
+ gridGameIds: [1942, 7346],
+ returnPath: "/games/popular",
+ priority: false,
+ };
+
+ it("links the card to the game page and hands the slots the grid's ids", () => {
+ render();
expect(screen.getByRole("link", { name: "The Witcher 3" })).toHaveAttribute(
"href",
"/games/1942",
);
- expect(screen.getByText("9.3")).toBeInTheDocument();
+ expect(screen.getByTestId("badge")).toHaveAttribute(
+ "data-ids",
+ "1942,7346",
+ );
+ expect(screen.getByTestId("badge")).toHaveTextContent("badge 1942 9.3");
+ expect(screen.getByTestId("controls")).toHaveAttribute(
+ "data-return",
+ "/games/popular",
+ );
+ });
+
+ it("shows the release year as its meta line, and none without one (AC-10)", () => {
+ const { unmount } = render();
+ expect(screen.getByText(String(GAME.releaseYear))).toBeInTheDocument();
+ unmount();
+
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("p")).toBeNull();
});
- it("prints no date unless the list dates its cards", () => {
- render();
+ it("prints no date unless the list dates its cards, leaving the pill to the slot", () => {
+ render();
expect(
document.querySelector('[data-slot="release-date"]'),
).not.toBeInTheDocument();
+ expect(screen.getByTestId("controls")).toHaveAttribute(
+ "data-release-pill",
+ "true",
+ );
});
- it("prints the release day, with the year only outside this one", () => {
+ it("prints the release day, with the year only outside this one, and the slot skips its pill (AC-7)", () => {
const { unmount } = render(
- ,
+ ,
);
expect(
document.querySelector('[data-slot="release-date"]'),
).toHaveTextContent("Nov 6");
+ expect(screen.getByTestId("controls")).toHaveAttribute(
+ "data-release-pill",
+ "false",
+ );
unmount();
render(
,
);
diff --git a/components/catalog/list-cards.tsx b/components/catalog/list-cards.tsx
index 22d0ac4a..47679367 100644
--- a/components/catalog/list-cards.tsx
+++ b/components/catalog/list-cards.tsx
@@ -3,6 +3,10 @@ import { Suspense } from "react";
import { ReleasePill } from "@/components/library/dated-pill";
import { PosterCard } from "@/components/poster-card";
import { IgdbRatingBadge, TmdbRatingBadge } from "@/components/rating-badges";
+import {
+ GameCardBadge,
+ GameCardControls,
+} from "@/components/tracking/game-card-slots";
import {
MovieCardBadge,
MovieCardControls,
@@ -131,41 +135,64 @@ function MovieListCard({
}
/**
- * A game's card on a games list page (spec 0028, AC-8): the cover, the name
- * and IGDB's community rating, linking to the game page. No personal badge
- * and no controls yet, so no session read and no Suspense; G8 adds them.
- * On Coming soon the bottom row prints the release day, the pill an
- * Upcoming movie card has.
+ * A game's card on a games list page and on `/search?type=game` (spec 0028,
+ * AC-8; spec 0030, AC-1): the cover, the name, the release year as the meta
+ * line (AC-10), and the personal badge and controls, each in its own
+ * Suspense boundary so the card never waits on the session. On Coming soon
+ * the bottom row also prints the release day for everyone, so the personal
+ * slot leaves out its own release pill (AC-7).
*
+ * @param gridGameIds Every id of the page the card sits in, so the slots
+ * share one read.
* @param releaseDay The request's UTC day, for the date's year rule; left
* out on a list whose cards print no date.
- * @param meta The line under the name, the release year on `/search`
- * (spec 0029, AC-12); left out on a list.
*/
function GameListCard({
game,
+ gridGameIds,
+ returnPath,
priority,
sizes,
releaseDay,
- meta,
-}: Omit & {
+}: ListCardProps & {
game: GameSummary;
+ gridGameIds: readonly number[];
releaseDay?: string;
- meta?: number;
}) {
return (
}
- controls={
- releaseDay === undefined ? undefined : (
- }>
+
- )
+
+ }
+ controls={
+ <>
+ {releaseDay === undefined ? null : (
+
+ )}
+
+
+
+ >
}
priority={priority}
sizes={sizes}
diff --git a/components/layout/icon-key.test.tsx b/components/layout/icon-key.test.tsx
index b7354504..9fde3ad1 100644
--- a/components/layout/icon-key.test.tsx
+++ b/components/layout/icon-key.test.tsx
@@ -21,8 +21,8 @@ describe("IconKey", () => {
).toEqual([
"TMDB rating",
"Your score",
- "Plan",
- "Planned",
+ "Plan / Want to play",
+ "Planned / Want to play",
"Mark watched",
"Watched",
"Next episode",
diff --git a/components/layout/icon-key.tsx b/components/layout/icon-key.tsx
index 87275eaa..64d3dfab 100644
--- a/components/layout/icon-key.tsx
+++ b/components/layout/icon-key.tsx
@@ -26,8 +26,8 @@ const ENTRIES: ReadonlyArray<{ label: string; icon: React.ReactNode }> = [
),
},
- { label: "Plan", icon: },
- { label: "Planned", icon: },
+ { label: "Plan / Want to play", icon: },
+ { label: "Planned / Want to play", icon: },
{
label: "Mark watched",
icon: ,
diff --git a/components/library/game-library-card.tsx b/components/library/game-library-card.tsx
index 61c1c1fc..687c0e2d 100644
--- a/components/library/game-library-card.tsx
+++ b/components/library/game-library-card.tsx
@@ -7,7 +7,10 @@ import {
PersonalScoreBadge,
} from "@/components/rating-badges";
import { CardRoundButton } from "@/components/tracking/card-round-button";
+import { GameStatusChip } from "@/components/tracking/game-status-chip";
+import { PlannedIcon } from "@/components/tracking/tracking-icons";
import { gameHref } from "@/lib/catalog/media-type";
+import { isStartedGame } from "@/lib/tracking/types";
import { ReleasePill } from "./dated-pill";
import type { LibraryGameItem } from "./types";
@@ -28,7 +31,9 @@ const MISSING_GAME = "No longer on IGDB";
* Started or Finished month under the name. A hidden game keeps its card
* but loses its link, because its page answers a soft 404 (AC-10). Every card
* has one remove button, which only reports the tap: `GameLibraryGrid` owns
- * the optimistic hide, the action call and the toast. Which of these a tab
+ * the optimistic hide, the action call and the toast. On Want to play it is
+ * the green bookmark the catalog cards press, and on Playing and Finished
+ * the X, after the read only status chip (spec 0030, AC-11). Which of these a tab
* shows is decided once, on the server, by `gameLibraryTab`: the card shows
* the score, release pill and date line its item carries.
*/
@@ -46,7 +51,9 @@ function GameLibraryCard({
}
+ controls={
+
+ }
/>
);
}
@@ -63,7 +70,20 @@ function GameLibraryCard({
{item.unreleased ? (
) : null}
-
+ {isStartedGame(item) ? (
+ <>
+
+
+ >
+ ) : (
+
+
+
+ )}
>
}
priority={priority}
@@ -82,18 +102,25 @@ function cardBadge(item: LibraryGameItem): ReactNode {
return ;
}
+/**
+ * The X. It sets no margin of its own: after a status chip, which already
+ * pushes the row right, it needs none, and the missing game card, where it
+ * stands alone, passes `ml-auto`.
+ */
function RemoveButton({
name,
onRemove,
+ className,
}: {
name: string | null;
onRemove: () => void;
+ className?: string;
}) {
return (
diff --git a/components/library/game-library-grid.test.tsx b/components/library/game-library-grid.test.tsx
index 096187bb..7b075918 100644
--- a/components/library/game-library-grid.test.tsx
+++ b/components/library/game-library-grid.test.tsx
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { LibraryGameItem } from "./types";
/**
- * covers: spec 0027, AC-8 to AC-13
+ * covers: spec 0027, AC-8 to AC-13; spec 0030, AC-11
*
* `removeGame`, `restoreRemovedGame`, the router and Sonner are the
* boundaries. Each action returns a promise the test settles by hand, so a
@@ -49,6 +49,7 @@ function item(
return {
gameId,
name: `Game ${gameId}`,
+ status: "playing",
coverUrl: null,
communityRating: 8.1,
rating: null,
@@ -97,8 +98,10 @@ afterEach(() => {
});
describe("GameLibraryCard through the grid", () => {
- it("links a game to its page with the IGDB community rating on Want to play", () => {
- render(grid([item(1942, { name: "The Witcher 3" })]));
+ it("links a game to its page with the IGDB community rating and the green bookmark on Want to play", () => {
+ render(
+ grid([item(1942, { name: "The Witcher 3", status: "want_to_play" })]),
+ );
expect(screen.getByRole("link", { name: "The Witcher 3" })).toHaveAttribute(
"href",
@@ -107,9 +110,44 @@ describe("GameLibraryCard through the grid", () => {
expect(screen.getByText("IGDB community rating")).toBeInTheDocument();
expect(
screen.getByRole("button", {
- name: "Remove The Witcher 3 from your games",
+ name: "Remove The Witcher 3 from Want to play",
}),
).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /from your games/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("Playing")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["playing", "Playing"],
+ ["finished", "Finished"],
+ ] as const)(
+ "shows the read only %s chip beside the X (spec 0030, AC-11)",
+ (status, label) => {
+ render(grid([item(1, { status })]));
+
+ expect(screen.getByText(label)).toBeInTheDocument();
+ expect(screen.getAllByRole("button")).toHaveLength(1);
+ expect(
+ screen.getByRole("button", { name: "Remove Game 1 from your games" }),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it("removes a Want to play game through the bookmark, with the same Undo", async () => {
+ const user = userEvent.setup();
+ render(grid([item(1, { status: "want_to_play" })]));
+
+ await user.click(
+ screen.getByRole("button", { name: "Remove Game 1 from Want to play" }),
+ );
+ expect(removeGame).toHaveBeenCalledWith(1);
+ await settleNext({ ok: true, undo: UNDO });
+ expect(toast).toHaveBeenCalledWith(
+ "Removed from your games.",
+ expect.objectContaining({ id: "game-undo-1" }),
+ );
});
it("shows the release pill or Date TBA only on an unreleased Want to play game", () => {
@@ -155,7 +193,17 @@ describe("GameLibraryCard through the grid", () => {
});
it("gives a hidden game no link but keeps its remove button", () => {
- render(grid([item(1, { name: "Hidden Game", hidden: true })]));
+ render(
+ grid([
+ item(1, { name: "Hidden Game", hidden: true }),
+ item(2, { name: "Hidden Wish", hidden: true, status: "want_to_play" }),
+ ]),
+ );
+ expect(
+ screen.getByRole("button", {
+ name: "Remove Hidden Wish from Want to play",
+ }),
+ ).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Hidden Game" }),
@@ -170,7 +218,14 @@ describe("GameLibraryCard through the grid", () => {
it("shows a missing game as No longer on IGDB, removable, with nothing invented", () => {
render(
- grid([item(1, { name: null, communityRating: null, dateLine: null })]),
+ grid([
+ item(1, {
+ name: null,
+ communityRating: null,
+ dateLine: null,
+ status: "want_to_play",
+ }),
+ ]),
);
expect(screen.getAllByText("No longer on IGDB").length).toBeGreaterThan(0);
diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx
index 6c53f8cd..c28e266c 100644
--- a/components/library/game-library-tab.test.tsx
+++ b/components/library/game-library-tab.test.tsx
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { LibraryGameItem } from "./types";
+import type { LibraryGameItem, LibraryList } from "./types";
/**
- * covers: spec 0027, AC-5 to AC-11, AC-14
+ * covers: spec 0027, AC-5 to AC-11, AC-14; spec 0030, AC-11
*
* The games tabs through `LibrarySection`, as the page renders them. The
* session, the flag, the Postgres page and the IGDB batch are the
@@ -22,10 +22,25 @@ vi.mock("server-only", () => ({}));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
vi.mock("@/lib/tmdb", () => ({ TmdbError: class extends Error {} }));
vi.mock("@/lib/igdb", () => ({ IgdbError: class extends Error {} }));
-vi.mock("@/lib/tracking/game-library", () => ({
- getGameLibraryPage: (...args: unknown[]) => getGameLibraryPage(...args),
- getLibraryGames: (...args: unknown[]) => getLibraryGames(...args),
-}));
+vi.mock("@/lib/tracking/game-library", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ // The real query filters on the tab's status, so a fixture row with no
+ // status of its own is given that one.
+ getGameLibraryPage: async (args: { tab: LibraryList }) => {
+ const result = await getGameLibraryPage(args);
+ if (result?.kind !== "ok") return result;
+ const status = actual.GAME_LIBRARY_TABS[args.tab].status;
+ return {
+ ...result,
+ rows: result.rows.map((row: object) => ({ status, ...row })),
+ };
+ },
+ getLibraryGames: (...args: unknown[]) => getLibraryGames(...args),
+ };
+});
vi.mock("@/lib/tracking/library-lists", async (importOriginal) => ({
...(await importOriginal()),
getShowLibraryTab: (...args: unknown[]) => getShowLibraryTab(...args),
@@ -314,6 +329,7 @@ describe("the cards (AC-8 to AC-11)", () => {
withGames(game(1));
const playing = await renderSection("upcoming", { type: "game" });
expect(received(1)).toMatchObject({
+ status: "playing",
dateLine: "Started Feb 2026",
rating: 8,
});
@@ -324,12 +340,21 @@ describe("the cards (AC-8 to AC-11)", () => {
expect(received(1).dateLine).toBe("Finished Mar 2026");
});
+ it("takes the card's status from the stored row, not the tab (AC-8)", async () => {
+ getGameLibraryPage.mockResolvedValue(page([row(1, { status: "playing" })]));
+ withGames(game(1));
+ await renderSection("watchlist", { type: "game" });
+
+ expect(received(1).status).toBe("playing");
+ });
+
it("ignores a score on a Want to play row", async () => {
getGameLibraryPage.mockResolvedValue(page([row(1, { rating: 7 })]));
withGames(game(1));
await renderSection("watchlist", { type: "game" });
expect(received(1).rating).toBeNull();
+ expect(received(1).status).toBe("want_to_play");
});
it("passes hidden through, and invents nothing for a missing game", async () => {
@@ -344,6 +369,7 @@ describe("the cards (AC-8 to AC-11)", () => {
expect(received(1)).toEqual({
gameId: 1,
name: null,
+ status: "finished",
coverUrl: null,
communityRating: null,
rating: null,
@@ -352,6 +378,7 @@ describe("the cards (AC-8 to AC-11)", () => {
dateLine: null,
});
expect(received(2).hidden).toBe(true);
+ expect(received(2).status).toBe("finished");
expect(
screen.getAllByRole("listitem").map((item) => item.dataset.testid),
).toEqual(["game-1", "game-2"]);
diff --git a/components/library/game-library-tab.tsx b/components/library/game-library-tab.tsx
index b3ef7f92..160a144d 100644
--- a/components/library/game-library-tab.tsx
+++ b/components/library/game-library-tab.tsx
@@ -125,10 +125,12 @@ function gameItem(
game: GameSummary | undefined,
today: string,
): LibraryGameItem {
+ const { status } = row;
if (game === undefined) {
return {
gameId: row.gameId,
name: null,
+ status,
coverUrl: null,
communityRating: null,
rating: null,
@@ -142,6 +144,7 @@ function gameItem(
return {
gameId: row.gameId,
name: game.name,
+ status,
coverUrl: game.coverUrl,
communityRating: game.communityRating,
rating: card.scored ? row.rating : null,
diff --git a/components/library/types.ts b/components/library/types.ts
index a207b082..abc02612 100644
--- a/components/library/types.ts
+++ b/components/library/types.ts
@@ -7,6 +7,7 @@
export type { LibraryList } from "@/lib/catalog/library-list";
import type { ReleaseDate } from "@/lib/format";
+import type { GameStatus } from "@/lib/tracking/types";
export type { ReleaseDate };
@@ -51,6 +52,12 @@ export type LibraryGameItem = {
gameId: number;
/** Null when IGDB no longer has the game (`missingIds`, AC-11). */
name: string | null;
+ /**
+ * The stored status the tab lists, which picks the card's button: the
+ * green bookmark on Want to play, the chip and the X on Playing and
+ * Finished (spec 0030, AC-11).
+ */
+ status: GameStatus;
coverUrl: string | null;
/** IGDB's community rating out of 10; the badge with no personal score. */
communityRating: number | null;
diff --git a/components/search/search-results.test.tsx b/components/search/search-results.test.tsx
index e22bce70..e55bfe9d 100644
--- a/components/search/search-results.test.tsx
+++ b/components/search/search-results.test.tsx
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GameSummary, Paged } from "@/lib/igdb";
/**
- * covers: spec 0029, AC-1, AC-2, AC-4, AC-5, AC-8, AC-11, AC-12, AC-13
+ * covers: spec 0029, AC-1, AC-2, AC-4, AC-5, AC-8, AC-11, AC-12, AC-13;
+ * spec 0030, AC-1
*
* The games branch of `SearchResults`, as `/search` renders it. The flag,
* IGDB's reference lists and the one results read are the boundaries and are
@@ -56,6 +57,11 @@ vi.mock("@/components/tracking/show-card-slots", () => ({
ShowCardControls: () => null,
ShowCardMeta: () => null,
}));
+const gameControls = vi.fn((_props: Record) => null);
+vi.mock("@/components/tracking/game-card-slots", () => ({
+ GameCardBadge: () => null,
+ GameCardControls: (props: Record) => gameControls(props),
+}));
const { SearchResults } = await import("./search-results");
@@ -111,6 +117,21 @@ afterEach(() => {
});
describe("SearchResults for games", () => {
+ it("hands every card's slots the page's ids and the search URL, so one read serves the grid (spec 0030, AC-1)", async () => {
+ readGameSearchPage.mockResolvedValue(paged([game(1), game(2)], 2));
+
+ await renderResults({ type: "game", q: "zelda" });
+
+ expect(gameControls).toHaveBeenCalledTimes(2);
+ for (const [props] of gameControls.mock.calls) {
+ expect(props).toMatchObject({
+ gridGameIds: [1, 2],
+ returnPath: "/search?type=game&q=zelda",
+ releasePill: true,
+ });
+ }
+ });
+
it("shows IGDB's count, one card per game with its year and a link to the game page (AC-8, AC-12)", async () => {
readGameSearchPage.mockResolvedValue(
paged([game(1, { name: "Zelda" }), game(2)], 1234),
diff --git a/components/search/search-results.tsx b/components/search/search-results.tsx
index 409702f7..240ec8f4 100644
--- a/components/search/search-results.tsx
+++ b/components/search/search-results.tsx
@@ -176,6 +176,8 @@ async function GameResults({
}
const lastPage = lastGamePage(paged);
+ const ids = paged.results.map((game) => game.id);
+ const returnPath = searchHref(params);
if (paged.results.length === 0) {
if (params.page > 1) return ;
return mode === "browse" ? (
@@ -205,7 +207,8 @@ async function GameResults({
diff --git a/components/tracking/episode-tracking-slot.tsx b/components/tracking/episode-tracking-slot.tsx
index 0aff712f..bccf3c53 100644
--- a/components/tracking/episode-tracking-slot.tsx
+++ b/components/tracking/episode-tracking-slot.tsx
@@ -18,7 +18,7 @@ import { EpisodeTrackingControls } from "./episode-tracking-controls";
* season's `idsKey` (AC-18). Today is read only after the session read, never
* in a cached scope, so the air status is always today's (AC-3).
*
- * @param idsKey The whole season's ids, from `episodeIdsKey`; never this
+ * @param idsKey The whole season's ids, from `idsKey`; never this
* row's own id, or each row would make its own query.
*/
async function EpisodeTrackingSlot({
diff --git a/components/tracking/game-card-bookmark-button.test.tsx b/components/tracking/game-card-bookmark-button.test.tsx
new file mode 100644
index 00000000..8fd654b1
--- /dev/null
+++ b/components/tracking/game-card-bookmark-button.test.tsx
@@ -0,0 +1,165 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: spec 0030, AC-3, AC-4, AC-5
+ *
+ * The Server Actions and the toast are the boundaries. Each action call
+ * waits on `settle`, so a test can look at the button while the write is in
+ * flight.
+ */
+let settle: (value: unknown) => void = () => {};
+function pending() {
+ return new Promise((resolve) => {
+ settle = resolve;
+ });
+}
+const setGameStatus = vi.fn((..._args: unknown[]) => pending());
+const removeGame = vi.fn((..._args: unknown[]) => pending());
+const restoreRemovedGame = vi.fn(async (..._args: unknown[]) => ({
+ ok: true,
+}));
+const restoreGameStatus = vi.fn(async (..._args: unknown[]) => ({
+ ok: true,
+}));
+vi.mock("@/app/games/actions", () => ({
+ setGameStatus: (...args: unknown[]) => setGameStatus(...args),
+ removeGame: (...args: unknown[]) => removeGame(...args),
+ restoreRemovedGame: (...args: unknown[]) => restoreRemovedGame(...args),
+ restoreGameStatus: (...args: unknown[]) => restoreGameStatus(...args),
+}));
+const push = vi.fn();
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
+const toast = vi.fn();
+vi.mock("sonner", () => ({
+ toast: Object.assign((...args: unknown[]) => toast(...args), {
+ dismiss: vi.fn(),
+ }),
+}));
+
+const { GameCardBookmarkButton } = await import("./game-card-bookmark-button");
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+const NAME = "Want to play: The Witcher 3";
+const PREVIOUS = {
+ status: "want_to_play" as const,
+ rating: null,
+ addedAt: "2026-10-01T10:00:00Z",
+ startedAt: null,
+ finishedAt: null,
+};
+
+function renderButton(inList: boolean, returnPath = "/games") {
+ render(
+ ,
+ );
+ return screen.getByRole("button", { name: NAME });
+}
+
+describe("GameCardBookmarkButton", () => {
+ it("carries the accessible name, pressed state and a 44px mobile target (AC-3)", () => {
+ const button = renderButton(true);
+ expect(button).toHaveAttribute("aria-pressed", "true");
+ expect(button).toHaveClass("size-11", "md:size-9");
+ });
+
+ it("adds an unowned game with no toast (AC-3, AC-5)", async () => {
+ const user = userEvent.setup();
+ const button = renderButton(false);
+ await user.click(button);
+ expect(setGameStatus).toHaveBeenCalledWith(1942, "want_to_play");
+ expect(button).toHaveAttribute("aria-pressed", "true");
+
+ await act(async () => settle({ ok: true }));
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("refuses a second tap while the write is in flight (AC-4)", async () => {
+ const user = userEvent.setup();
+ const button = renderButton(false);
+ await user.click(button);
+ expect(button).toBeDisabled();
+ await user.click(button);
+ expect(setGameStatus).toHaveBeenCalledTimes(1);
+ expect(removeGame).not.toHaveBeenCalled();
+
+ await act(async () => settle({ ok: true }));
+ await waitFor(() => expect(button).not.toBeDisabled());
+ });
+
+ it("rolls back on a failure and offers Sign in back to the grid when the session expired (AC-4)", async () => {
+ const user = userEvent.setup();
+ const button = renderButton(false, "/search?type=game&q=witcher");
+ await user.click(button);
+ await act(async () => settle({ ok: false, error: "session_expired" }));
+
+ await waitFor(() =>
+ expect(button).toHaveAttribute("aria-pressed", "false"),
+ );
+ const [, options] = toast.mock.calls.at(-1) ?? [];
+ expect(options.id).toBe("game-tracking-1942-status");
+ options.action.onClick();
+ expect(push).toHaveBeenCalledWith(
+ `/sign-in?next=${encodeURIComponent("/search?type=game&q=witcher")}`,
+ );
+ });
+
+ it("removes a wanted game with the shared Undo, which restores the row (AC-5)", async () => {
+ const user = userEvent.setup();
+ const button = renderButton(true);
+ await user.click(button);
+ expect(removeGame).toHaveBeenCalledWith(1942);
+ expect(button).toHaveAttribute("aria-pressed", "false");
+
+ const removedAt = "2026-10-26T10:00:00Z";
+ await act(async () =>
+ settle({
+ ok: true,
+ undo: { kind: "removed", previous: PREVIOUS, removedAt },
+ }),
+ );
+ const [message, options] = toast.mock.calls.at(-1) ?? [];
+ expect(message).toBe("Removed from your games.");
+ expect(options.id).toBe("game-undo-1942");
+ await act(async () => options.action.onClick({ preventDefault: () => {} }));
+ expect(restoreRemovedGame).toHaveBeenCalledWith(1942, PREVIOUS, removedAt);
+ });
+
+ it("adding over a stale scored card shows the score cleared Undo (AC-5)", async () => {
+ const user = userEvent.setup();
+ renderButton(false);
+ await user.click(screen.getByRole("button", { name: NAME }));
+ const previous = { ...PREVIOUS, status: "playing" as const, rating: 8 };
+ await act(async () =>
+ settle({ ok: true, undo: { kind: "status", previous } }),
+ );
+ const [message, options] = toast.mock.calls.at(-1) ?? [];
+ expect(message).toBe("Moved to Want to play. Your score was removed.");
+ await act(async () => options.action.onClick({ preventDefault: () => {} }));
+ expect(restoreGameStatus).toHaveBeenCalledWith(1942, previous);
+ });
+
+ it("toggles from the keyboard with Enter and Space (spec 0030, AC-15)", async () => {
+ const user = userEvent.setup();
+ const button = renderButton(false);
+ await user.tab();
+ expect(button).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(setGameStatus).toHaveBeenCalledTimes(1);
+ await act(async () => settle({ ok: true }));
+ await waitFor(() => expect(button).not.toBeDisabled());
+ // No refresh here, so the card is unpressed again and Space adds again.
+ button.focus();
+ await user.keyboard(" ");
+ expect(setGameStatus).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/components/tracking/game-card-bookmark-button.tsx b/components/tracking/game-card-bookmark-button.tsx
new file mode 100644
index 00000000..fef36d24
--- /dev/null
+++ b/components/tracking/game-card-bookmark-button.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useOptimistic, useTransition } from "react";
+
+import { removeGame, setGameStatus } from "@/app/games/actions";
+
+import { CardRoundButton } from "./card-round-button";
+import { PlanIcon, PlannedIcon } from "./tracking-icons";
+import { settleResultCall, showGameTrackingError } from "./tracking-toast";
+import { showGameUndo } from "./use-game-undo";
+
+/**
+ * The Want to play bookmark on a game card (spec 0030, AC-3 to AC-5), the
+ * game twin of `CardBookmarkButton`.
+ *
+ * Empty, a tap sets Want to play; filled, a tap removes the game, with the
+ * Undo every game surface shares (`game-undo-{id}`). The icon flips at once
+ * and the button refuses a second tap until the write settles, so two quick
+ * taps can never land out of order (AC-4). A failed write rolls the icon back
+ * as the transition ends. Every `undo` an action returns is shown, which is
+ * how adding over a stale card that cleared a score is never silent (AC-5).
+ */
+function GameCardBookmarkButton({
+ gameId,
+ name,
+ inList,
+ returnPath,
+}: {
+ gameId: number;
+ name: string;
+ inList: boolean;
+ returnPath: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [optimistic, setOptimistic] = useOptimistic(inList);
+
+ function toggle() {
+ const value = !optimistic;
+ startTransition(async () => {
+ setOptimistic(value);
+ const result = await settleResultCall(() =>
+ value ? setGameStatus(gameId, "want_to_play") : removeGame(gameId),
+ );
+ if (!result.ok) {
+ showGameTrackingError(result.error, {
+ gameId,
+ control: "status",
+ returnPath,
+ navigate: router.push,
+ });
+ return;
+ }
+ const undo = result.undo;
+ showGameUndo({
+ gameId,
+ undo,
+ returnPath,
+ navigate: router.push,
+ restoring: () =>
+ setOptimistic(undo?.previous.status === "want_to_play"),
+ });
+ });
+ }
+
+ return (
+
+ {optimistic ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export { GameCardBookmarkButton };
diff --git a/components/tracking/game-card-slots.test.tsx b/components/tracking/game-card-slots.test.tsx
new file mode 100644
index 00000000..ec4c7480
--- /dev/null
+++ b/components/tracking/game-card-slots.test.tsx
@@ -0,0 +1,142 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { GameTrackingState } from "@/lib/tracking/types";
+
+/**
+ * covers: spec 0030, AC-1, AC-2, AC-3, AC-6, AC-7, AC-9
+ *
+ * The two slots of a game card show the stored status as the library tabs
+ * do. The grid read is the boundary, so it is what is replaced.
+ */
+const getGameGridStates = vi.fn();
+vi.mock("@/lib/tracking/game-state", () => ({
+ getGameGridStates: (...args: unknown[]) => getGameGridStates(...args),
+}));
+vi.mock("@/lib/tracking/episode-state", () => ({
+ requestTodayUtc: () => "2026-10-26",
+}));
+vi.mock("@/app/games/actions", () => ({}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
+vi.mock("sonner", () => ({ toast: vi.fn() }));
+
+const { GameCardBadge, GameCardControls } = await import("./game-card-slots");
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("GameCardBadge and GameCardControls", () => {
+ const props = {
+ gameId: 1942,
+ name: "The Witcher 3",
+ releaseDate: "2015-05-19" as string | null,
+ gridGameIds: [7346, 1942],
+ returnPath: "/games",
+ };
+
+ function grid(state?: GameTrackingState) {
+ getGameGridStates.mockResolvedValue({
+ kind: "ok",
+ state: new Map(state ? [[1942, state]] : []),
+ });
+ }
+
+ async function renderCard(
+ overrides: Partial & { releasePill?: boolean } = {},
+ ) {
+ const card = { ...props, ...overrides };
+ return render(
+ <>
+ {await GameCardBadge({ ...card, communityRating: 9.3 })}
+ {await GameCardControls(card)}
+ >,
+ );
+ }
+
+ function bookmark() {
+ return screen.getByRole("button", { name: "Want to play: The Witcher 3" });
+ }
+
+ it("asks for the whole grid under one shared key and offers the unpressed bookmark (AC-1, AC-3)", async () => {
+ grid();
+ await renderCard();
+ expect(getGameGridStates).toHaveBeenCalledWith("1942,7346");
+ expect(bookmark()).toHaveAttribute("aria-pressed", "false");
+ expect(screen.getByText("IGDB community rating")).toBeInTheDocument();
+ });
+
+ it("presses the bookmark on a released Want to play game, with no pill (AC-3, AC-7)", async () => {
+ grid({ status: "want_to_play", rating: null });
+ await renderCard();
+ expect(bookmark()).toHaveAttribute("aria-pressed", "true");
+ expect(
+ document.querySelector('[data-slot="release-date"]'),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows the release pill beside a wanted unreleased game, and Date TBA with no date (AC-7)", async () => {
+ grid({ status: "want_to_play", rating: null });
+ const { unmount } = await renderCard({ releaseDate: "2026-11-20" });
+ expect(screen.getByText("Releases Nov 20, 2026")).toBeInTheDocument();
+ unmount();
+
+ await renderCard({ releaseDate: null });
+ expect(
+ screen.getByText("Release date to be announced"),
+ ).toBeInTheDocument();
+ });
+
+ it("never shows the pill on an unpressed card or when the card prints the date itself (AC-7)", async () => {
+ grid();
+ const { unmount } = await renderCard({ releaseDate: "2026-11-20" });
+ expect(screen.queryByText("Releases Nov 20, 2026")).not.toBeInTheDocument();
+ unmount();
+
+ grid({ status: "want_to_play", rating: null });
+ await renderCard({ releaseDate: "2026-11-20", releasePill: false });
+ expect(screen.queryByText("Releases Nov 20, 2026")).not.toBeInTheDocument();
+ expect(bookmark()).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("shows a scored Playing game's score and a read only chip, no button (AC-2, AC-6)", async () => {
+ grid({ status: "playing", rating: 8 });
+ await renderCard({ releaseDate: null });
+ expect(screen.getByText("Your score")).toBeInTheDocument();
+ expect(screen.queryByText("IGDB community rating")).not.toBeInTheDocument();
+ expect(screen.getByText("Playing")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(
+ document.querySelector('[data-slot="release-date"]'),
+ ).not.toBeInTheDocument();
+ });
+
+ it("never badges a Want to play game with a score, even if one were stored (AC-2)", async () => {
+ grid({ status: "want_to_play", rating: 7 });
+ await renderCard();
+ expect(screen.getByText("IGDB community rating")).toBeInTheDocument();
+ expect(screen.queryByText("Your score")).not.toBeInTheDocument();
+ expect(bookmark()).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("keeps IGDB's rating on an unscored Finished game (AC-2, AC-6)", async () => {
+ grid({ status: "finished", rating: null });
+ await renderCard();
+ expect(screen.getByText("IGDB community rating")).toBeInTheDocument();
+ expect(screen.getByText("Finished")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["a visitor", { kind: "signed_out" }],
+ ["a failed read", { kind: "failed" }],
+ ])("gives %s the catalog card (AC-9)", async (_, read) => {
+ getGameGridStates.mockResolvedValue(read);
+ await renderCard({ releaseDate: null });
+ expect(screen.getByText("IGDB community rating")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("Release date to be announced"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/components/tracking/game-card-slots.tsx b/components/tracking/game-card-slots.tsx
new file mode 100644
index 00000000..c6cfe283
--- /dev/null
+++ b/components/tracking/game-card-slots.tsx
@@ -0,0 +1,130 @@
+/**
+ * The two personal slots of a game card on `/games`, the `/games/{slug}`
+ * lists and `/search?type=game` (spec 0030), the game twin of
+ * `movie-card-slots.tsx`.
+ *
+ * Every slot on a grid passes the same ids, so `getGameGridStates` runs once
+ * per render for the whole grid (AC-1). Each slot is its own Suspense
+ * boundary, so the covers never wait on the session (AC-13). A visitor, a
+ * clone with no Supabase configuration and a failed read all get the plain
+ * catalog card; the failure is logged inside the read (AC-9). The card shows
+ * the stored status as it is, so it always matches the library tab (AC-8).
+ */
+import { ReleasePill } from "@/components/library/dated-pill";
+import {
+ IgdbRatingBadge,
+ PersonalScoreBadge,
+} from "@/components/rating-badges";
+import { type GameCardStatus, gameCardStatus } from "@/lib/catalog/game-page";
+import { formatReleaseDate } from "@/lib/format";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { getGameGridStates } from "@/lib/tracking/game-state";
+import { idsKey } from "@/lib/tracking/ids-key";
+import { isStartedGame } from "@/lib/tracking/types";
+
+import { GameCardBookmarkButton } from "./game-card-bookmark-button";
+import { GameStatusChip } from "./game-status-chip";
+
+type GameCardProps = {
+ gameId: number;
+ /** The IGDB release date already on the card. */
+ releaseDate: string | null;
+ /** Every game id on the grid, the same array for each card. */
+ gridGameIds: readonly number[];
+};
+
+/**
+ * What the grid read says about this card. `unknown` is a visitor or a failed
+ * read, which get the plain catalog card (AC-9); `known` carries the stored
+ * status, where null is a game the user has no row for.
+ */
+type CardRead =
+ | { kind: "unknown" }
+ | { kind: "known"; card: GameCardStatus | null };
+
+async function readCard({
+ gameId,
+ releaseDate,
+ gridGameIds,
+}: GameCardProps): Promise {
+ const result = await getGameGridStates(idsKey(gridGameIds));
+ if (result.kind !== "ok") return { kind: "unknown" };
+ return {
+ kind: "known",
+ card: gameCardStatus(
+ result.state.get(gameId),
+ releaseDate,
+ requestTodayUtc(),
+ ),
+ };
+}
+
+/**
+ * The top right badge (AC-2): the cyan score on a scored Playing or Finished
+ * game, IGDB's community rating otherwise. The page renders the IGDB rating
+ * as the fallback, so only a scored game swaps.
+ *
+ * @param communityRating The card's IGDB rating out of 10.
+ */
+async function GameCardBadge({
+ communityRating,
+ ...props
+}: GameCardProps & { communityRating: number | null }) {
+ const read = await readCard(props);
+ if (
+ read.kind === "known" &&
+ isStartedGame(read.card) &&
+ read.card.rating !== null
+ ) {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * The bottom row: the read only chip on a Playing or Finished game (AC-6),
+ * and the Want to play bookmark otherwise (AC-3), with the release pill
+ * beside it while a wanted game is not out (AC-7). Nothing for a visitor or
+ * a failed read (AC-9).
+ *
+ * @param name The IGDB name, for the accessible names.
+ * @param returnPath The grid page, for the session expired toast.
+ * @param releasePill False on a card that already prints the release date
+ * for everyone, so the date is not shown twice (AC-7).
+ */
+async function GameCardControls({
+ name,
+ returnPath,
+ releasePill = true,
+ ...props
+}: GameCardProps & {
+ name: string;
+ returnPath: string;
+ releasePill?: boolean;
+}) {
+ const read = await readCard(props);
+ if (read.kind === "unknown") return null;
+ const { card } = read;
+
+ if (isStartedGame(card)) {
+ return ;
+ }
+
+ return (
+ <>
+ {releasePill && card?.unreleased && (
+
+ )}
+
+ >
+ );
+}
+
+export { GameCardBadge, GameCardControls };
diff --git a/components/tracking/game-status-chip.tsx b/components/tracking/game-status-chip.tsx
new file mode 100644
index 00000000..0a307f3d
--- /dev/null
+++ b/components/tracking/game-status-chip.tsx
@@ -0,0 +1,21 @@
+import { GlassPill } from "@/components/glass-pill";
+import { GAME_STATUS_LABELS } from "@/lib/tracking/messages";
+import type { StartedGameStatus } from "@/lib/tracking/types";
+
+/**
+ * The read only Playing or Finished chip in a game card's bottom row (spec
+ * 0030, AC-6, AC-11). A plain component with no `server-only` import, so the
+ * catalog cards' server slot and the client library grid render the same
+ * chip. It is not a button: a started game changes status on its page.
+ *
+ * @param status The stored status of a started game.
+ */
+function GameStatusChip({ status }: { status: StartedGameStatus }) {
+ return (
+
+ {GAME_STATUS_LABELS[status]}
+
+ );
+}
+
+export { GameStatusChip };
diff --git a/components/tracking/movie-card-slots.test.tsx b/components/tracking/movie-card-slots.test.tsx
index d4ae8ba4..046ac8d8 100644
--- a/components/tracking/movie-card-slots.test.tsx
+++ b/components/tracking/movie-card-slots.test.tsx
@@ -13,7 +13,6 @@ import type { MovieTrackingState } from "@/lib/tracking/types";
const getMovieGridStates = vi.fn();
vi.mock("@/lib/tracking/movie-state", () => ({
getMovieGridStates: (...args: unknown[]) => getMovieGridStates(...args),
- movieIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
}));
vi.mock("@/lib/tracking/episode-state", () => ({
requestTodayUtc: () => "2026-10-10",
diff --git a/components/tracking/movie-card-slots.tsx b/components/tracking/movie-card-slots.tsx
index aad76c26..d7a8a6ba 100644
--- a/components/tracking/movie-card-slots.tsx
+++ b/components/tracking/movie-card-slots.tsx
@@ -15,7 +15,8 @@ import { PersonalScoreBadge } from "@/components/rating-badges";
import { movieCardStatus } from "@/lib/catalog/movie-page";
import { formatReleaseDate } from "@/lib/format";
import { requestTodayUtc } from "@/lib/tracking/episode-state";
-import { getMovieGridStates, movieIdsKey } from "@/lib/tracking/movie-state";
+import { idsKey } from "@/lib/tracking/ids-key";
+import { getMovieGridStates } from "@/lib/tracking/movie-state";
import { CardBookmarkButton } from "./card-bookmark-button";
import { CardRatingBadge } from "./card-rating-badge";
@@ -35,7 +36,7 @@ async function readStatus({
releaseDate,
gridMovieIds,
}: MovieCardProps) {
- const result = await getMovieGridStates(movieIdsKey(gridMovieIds));
+ const result = await getMovieGridStates(idsKey(gridMovieIds));
if (result.kind !== "ok") return undefined;
const state = result.state.get(movieId);
return {
diff --git a/components/tracking/show-card-slots.test.tsx b/components/tracking/show-card-slots.test.tsx
index 44d5c0f2..a3f88b75 100644
--- a/components/tracking/show-card-slots.test.tsx
+++ b/components/tracking/show-card-slots.test.tsx
@@ -10,9 +10,6 @@ const getShowGridStatus = vi.fn();
vi.mock("@/lib/tracking/show-grid-status", () => ({
getShowGridStatus: (...args: unknown[]) => getShowGridStatus(...args),
}));
-vi.mock("@/lib/tracking/show-state", () => ({
- showIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
-}));
vi.mock("@/lib/tracking/episode-state", () => ({
requestTodayUtc: () => "2026-10-10",
}));
diff --git a/components/tracking/show-card-slots.tsx b/components/tracking/show-card-slots.tsx
index d53f339a..6cd5db22 100644
--- a/components/tracking/show-card-slots.tsx
+++ b/components/tracking/show-card-slots.tsx
@@ -11,9 +11,9 @@
import { UpcomingShowPill } from "@/components/library/show-cards";
import { CalculatedRatingBadge } from "@/components/rating-badges";
import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { idsKey } from "@/lib/tracking/ids-key";
import { WATCHED_SHOW_LABELS } from "@/lib/tracking/messages";
import { getShowGridStatus } from "@/lib/tracking/show-grid-status";
-import { showIdsKey } from "@/lib/tracking/show-state";
import { CardRatingBadge } from "./card-rating-badge";
import { ShowCardBookmarkButton } from "./show-card-bookmark-button";
@@ -29,7 +29,7 @@ type ShowCardProps = {
* visitor or a failed read.
*/
async function readTrackedStatuses(gridShowIds: readonly number[]) {
- const result = await getShowGridStatus(showIdsKey(gridShowIds));
+ const result = await getShowGridStatus(idsKey(gridShowIds));
return result.kind === "ok" ? result.state : undefined;
}
diff --git a/components/tracking/show-progress-slot.test.tsx b/components/tracking/show-progress-slot.test.tsx
index 5c253e61..cc9a1d3b 100644
--- a/components/tracking/show-progress-slot.test.tsx
+++ b/components/tracking/show-progress-slot.test.tsx
@@ -12,7 +12,6 @@ const getWatchedEpisodeIds = vi.fn();
vi.mock("@/lib/tracking/show-state", () => ({
getShowTracking: (...args: unknown[]) => getShowStatus(...args),
getWatchedEpisodeIds: (...args: unknown[]) => getWatchedEpisodeIds(...args),
- showIdsKey: (ids: number[]) => ids.join(","),
}));
const getShowEpisodes = vi.fn();
vi.mock("@/lib/tmdb", () => ({
diff --git a/components/tracking/show-progress-slot.tsx b/components/tracking/show-progress-slot.tsx
index 4a579041..52fa2caf 100644
--- a/components/tracking/show-progress-slot.tsx
+++ b/components/tracking/show-progress-slot.tsx
@@ -1,11 +1,11 @@
import { RetryLink } from "@/components/retry-link";
import { getShowEpisodes, type ShowEpisodes, TmdbError } from "@/lib/tmdb";
import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { idsKey } from "@/lib/tracking/ids-key";
import { SHOW_PROGRESS_MESSAGES } from "@/lib/tracking/messages";
import {
getShowTracking,
getWatchedEpisodeIds,
- showIdsKey,
} from "@/lib/tracking/show-state";
import { type ShowProgress, showProgress } from "@/lib/tv/progress";
@@ -32,7 +32,7 @@ import { type ShowProgress, showProgress } from "@/lib/tv/progress";
async function ShowProgressSlot({ showId }: { showId: number }) {
const [tracking, watched] = await Promise.all([
getShowTracking(showId),
- getWatchedEpisodeIds(showIdsKey([showId])),
+ getWatchedEpisodeIds(idsKey([showId])),
]);
if (tracking.kind === "signed_out" || watched.kind === "signed_out") {
return null;
diff --git a/docs/scope/games.md b/docs/scope/games.md
index a4b3ae8f..754bd2b2 100644
--- a/docs/scope/games.md
+++ b/docs/scope/games.md
@@ -31,7 +31,7 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md
| G5 | Games tab and library pages | Slice G2 | done |
| G6 | Games home and list pages | Slice G3 | done |
| G7 | Game search and filters | Slice G4 | done |
-| G8 | Game cards everywhere | Slice G4 | planned |
+| G8 | Game cards everywhere | Slice G4 | done |
| G9 | Richer game page | Slice G5 | planned |
| G10 | Games launch | Slice G6 | planned |
@@ -147,10 +147,18 @@ Games in `/search?type=game` with a title query and filters for genre, release y
- [x] Test it: `/test game search and filters`
spec [0029](../specs/0029-game-search-filters/index.md) · code in `app/search/`, `components/search/`, `lib/search/`, `lib/igdb/`
-### G8. Game cards everywhere · needs a decision
+### G8. Game cards everywhere · done
Game cards match the movie card: IGDB rating badge, your score when set, release year, and a status chip with a small control.
**Done when:** home rows, list pages, `/search` and the library pages all use the full game card; each grid reads the signed in user's statuses once, like `getMovieGridStates`; a card's status always matches its library page; the release gate applies on the card too.
-- [ ] Design it (spec): `/architect game cards everywhere`
+- [x] Design it (spec): `/architect game cards everywhere`
+- [x] Build it: `/develop game cards everywhere`
+ - [x] Thin thread: the grid read, `gameCardStatus`, the badge and controls slots and the Want to play bookmark with its Undo on `/search?type=game`, AC-1 to AC-5, AC-9
+ - [x] Started and unreleased cards: the status chip, the release pill and its switch, AC-6, AC-7
+ - [x] Every other grid: list pages, the landing slots (badge, controls, year meta) on every section card, AC-1, AC-8, AC-10, AC-13, AC-14
+ - [x] Library tabs and proof: `LibraryGameItem.status`, the green bookmark and the chip, icon key labels, `/showcase`, 375px and 1280px, keyboard, all checks, AC-11, AC-12, AC-13, AC-15
+- [x] Verify it: `/check verify game cards everywhere`
+- [x] Test it: `/test game cards everywhere`
+spec [0030](../specs/0030-game-cards-everywhere/index.md) · code in `components/tracking/`, `components/catalog/`, `components/library/`, `lib/tracking/`, `lib/catalog/`
## Slice G5: Richer game page
diff --git a/docs/specs/0030-game-cards-everywhere/index.md b/docs/specs/0030-game-cards-everywhere/index.md
new file mode 100644
index 00000000..18e1a7c6
--- /dev/null
+++ b/docs/specs/0030-game-cards-everywhere/index.md
@@ -0,0 +1,137 @@
+# 0030. Game cards everywhere
+
+**Date**: 2026-10-26
+**Status**: Accepted
+
+## Summary
+
+Game cards on the games landing, the list pages and `/search` stop being plain covers. A signed in user sees their own state on every one: their score as the badge when they have one, a Want to play bookmark they can tap, a release pill on a wanted game that is not out yet, and a small read only Playing or Finished chip on a game they have started. Each grid reads the user's games once, in one query, the way movie grids do. On the library tabs the Want to play card gets the same green bookmark (which removes the game), and Playing and Finished cards get the chip next to their remove button.
+
+## Requirements
+
+**User stories**:
+- As a signed in user browsing games, I want to see which games are already mine and how I scored them, so I never have to open a game to find out.
+- As a signed in user, I want to add a game to Want to play straight from its card, and take it off again, so building a list is quick.
+- As a visitor, I want game cards to stay fast and clean, with no controls I cannot use.
+
+**Acceptance criteria** (the contract):
+- **AC-1**: Every game card on `/games` (every landing section card: `LandingPoster`, the Coming soon `WideCard` and the `TopRatedList` rows; the hero keeps its `GameTrackingSlot`), on the five `/games/{slug}` list pages and on `/search?type=game` gets its personal state from one grid read: `getGameGridStates(gameIdsKey(ids))`, wrapped in React `cache()`, so each grid (one landing section, one list page, one results page) runs one `user_game_state` query for all its ids. `/games` therefore runs one query per section (about five), as the movie landing does. Never inside `use cache`, never in a page shell.
+- **AC-2**: The badge follows the games library card rule: the cyan `PersonalScoreBadge` when the game is Playing or Finished and has a score, otherwise the amber `IgdbRatingBadge` (nothing when IGDB has no rating). While the read is pending, and for a visitor or a failed read, the card shows the IGDB rating.
+- **AC-3**: A signed in user's card with no status, or with Want to play, shows the round bookmark in the bottom row: `PlanIcon` unpressed, the green `PlannedIcon` pressed, `aria-pressed` set, accessible name `Want to play: {name}`. Tapping it unpressed calls `setGameStatus(id, "want_to_play")`; tapping it pressed calls `removeGame(id)`.
+- **AC-4**: The bookmark is optimistic: it flips at once and is `disabled` while its write is in flight, so two quick taps can never land out of order. A failed write rolls it back (the `useOptimistic` value ends with the transition) and shows the existing game error toast (`showGameTrackingError`, control `status`), including the session expired Sign in action that returns to the grid's page. The release pill and the badge follow the confirmed state the action's `refresh()` delivers, not the optimistic flip.
+- **AC-5**: Every bookmark result's `undo` goes to `showGameUndo` (toast id `game-undo-{id}`), with `restoring` setting the bookmark back at once. Removing shows the removed message and Undo restores the row. Adding from no row returns no Undo and shows no toast. Adding over a stale card (another tab made the game Playing or Finished with a score) returns the score cleared Undo, so the score is never lost silently. A removal another tab already made is a quiet success with no Undo.
+- **AC-6**: A Playing or Finished game's card shows a read only glass chip reading `Playing` or `Finished` (from `GAME_STATUS_LABELS`) in the bottom row and no button. The card offers no way to change a started game's status or score.
+- **AC-7**: The release gate shows on the card: a Want to play game whose UTC release day (`isGameReleased(releaseDate, requestTodayUtc())`) has not come shows the `ReleasePill` with its date, or Date TBA when IGDB has none, next to the pressed bookmark. Only a Want to play card shows this pill, never an unpressed or started one. Cards that already print the date for everyone (the landing Coming soon `WideCard`, whose controls already get `releasePill={false}`, and the `/games/coming-soon` list, whose `GameListCard` keeps printing the `releaseDay` pill for visitors too) do not print it twice: the slot skips its pill whenever the card already prints one. `requestTodayUtc()` is read only inside the slot's Suspense boundary. The bookmark itself is never gated, because Want to play is allowed before release.
+- **AC-8**: A card's status always matches its library page: Want to play on `/watchlist?type=game`, Playing on `/upcoming?type=game`, Finished on `/watched?type=game`. Both read the same stored row, and no card derives a status from anything else.
+- **AC-9**: A signed out visitor, a clone with no Supabase configuration (`publicEnvProblems()`), and a failed grid read all get the plain catalog card: IGDB badge, no bookmark, no chip, no pill from the personal slot. A failed read is logged once inside the read, never shown on every card.
+- **AC-10**: Every game card in a `PosterCard` shows the release year as its meta line (`releaseYear`, left out when IGDB has none) on the landing (`LandingPoster`), the list pages and `/search`. The `WideCard` and `TopRatedList` rows get the badge and controls (AC-1) but no meta line, keeping their own layouts.
+- **AC-11**: On the library tabs, the Want to play card (hidden games included) replaces its X with the green `PlannedIcon` round button, accessible name `Remove {name} from Want to play`, running the same optimistic removal, Undo and removal focus `GameLibraryGrid` already owns. Playing and Finished cards show the read only status chip left of the X, which keeps `Remove {name} from your games`. The missing game card keeps the X. The card learns the status from a new `status` field on `LibraryGameItem`, set in `gameLibraryTab`.
+- **AC-12**: The icon key's two bookmark entries read `Plan / Want to play` and `Planned / Want to play`. No new mark is added.
+- **AC-13**: The page shells stay prerendered: `/games`, every `/games/{slug}` and `/search` keep their static shell, and each personal slot sits in its own Suspense boundary (badge fallback: the IGDB badge; controls fallback: nothing), so no cover waits on the session.
+- **AC-14**: No page writes when it loads, and the cards add no `NEXT_PUBLIC_GAMES_LIBRARY` gate of their own, exactly as the game page's status control does (the routes keep the gates they have, such as `/search?type=game`).
+- **AC-15**: `/showcase` renders the game card states (visitor, no status, Want to play released, Want to play unreleased, Want to play TBA, Playing scored, Finished unscored), the bookmark keyboard path works (Tab reaches it, Enter and Space toggle, focus ring visible), and the cards hold at 375px and 1280px.
+
+## Decision
+
+**Chosen option**: Option 1: Mirror the movie card slots for games
+
+Give games the same shape movie cards have: one cached grid read, a pure card status function, a server badge slot and controls slot, and one small client bookmark, then plug those slots into `LANDING_SLOTS`, `GameListCard` and `GameLibraryCard`.
+
+## Feature design
+
+**Data model sketch**: no schema change. The read uses `user_game_state` (`user_id`, `game_id`, `status`, `rating`, timestamps) from spec 0024 as it is.
+
+**State transitions on the card** (the card can make only these two; everything else stays on the game page):
+- none → Want to play (`setGameStatus`)
+- Want to play → none (`removeGame`, with Undo)
+- Playing and Finished: no transition from the card.
+
+**Surface** (no new endpoints; Server Actions from spec 0026 are reused as they are):
+
+| Piece | Kind | Inputs | Output | Auth | Errors |
+|---|---|---|---|---|---|
+| `getGameGridStates(idsKey)` in `lib/tracking/game-state.ts` | server read, `cache()`, `publicEnvProblems()` first | sorted, deduped id string from `gameIdsKey`; `""` returns an empty map with no query | `TrackingRead