Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
64725d7
docs(specs): add spec 0030 for game cards everywhere
kris1027 Oct 26, 2026
76fb643
feat(tracking): read a game grid's statuses in one query
kris1027 Oct 26, 2026
8f281bb
feat(tracking): add the game card badge, controls and bookmark
kris1027 Oct 26, 2026
a6f594a
feat(catalog): give game cards on search and the list pages their status
kris1027 Oct 26, 2026
85654ae
feat(catalog): light the game cards on the games landing
kris1027 Oct 26, 2026
d0e1967
feat(library): show the game status on the games tab cards
kris1027 Oct 26, 2026
4a0fdbb
feat(layout): name Want to play in the bookmark icon key
kris1027 Oct 26, 2026
5a90c33
feat(showcase): render every game card state
kris1027 Oct 26, 2026
a037e3f
docs(scope): tick the game cards build
kris1027 Oct 26, 2026
b13c3ad
docs(specs): add the verify steps for spec 0030
kris1027 Oct 26, 2026
2299ee6
test(tracking): lock the game card score and grid id rules
kris1027 Oct 26, 2026
4519273
docs(scope): tick the game cards verify and test steps
kris1027 Oct 26, 2026
b4ff975
docs(scope): mark game cards everywhere done
kris1027 Oct 26, 2026
9b62c90
refactor(tracking): build every grid and season ids key in one place
kris1027 Oct 26, 2026
e251757
fix(tracking): answer an empty game grid before the session read
kris1027 Oct 26, 2026
8413d6c
refactor(tracking): name a started game once
kris1027 Oct 26, 2026
c5b67d3
refactor(tracking): say when a game card's status is unknown
kris1027 Oct 26, 2026
fe5535a
refactor(library): let the missing game card place its own X
kris1027 Oct 26, 2026
6aad57d
fix(library): read a game card's status from its stored row
kris1027 Oct 26, 2026
5d47bac
docs(components): describe the game landing slots G8 filled
kris1027 Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions app/showcase/game-card-demo.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GameCardDemo poster={POSTER} />);
expect(screen.getAllByRole("listitem")).toHaveLength(7);
});

it("gives a visitor no control and a signed in card the bookmark", () => {
render(<GameCardDemo poster={POSTER} />);
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(<GameCardDemo poster={POSTER} />);
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(<GameCardDemo poster={POSTER} />);
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(<GameCardDemo poster={POSTER} />);
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");
});
});
140 changes: 140 additions & 0 deletions app/showcase/game-card-demo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PosterGrid>
{CARDS.map((card) => (
<li key={card.name}>
<PosterCard
title={card.name}
posterUrl={card.year === null ? null : poster}
href="/games"
meta={card.year ?? undefined}
badge={
card.state?.kind === "started" && card.state.rating !== null ? (
<PersonalScoreBadge value={card.state.rating} />
) : (
<IgdbRatingBadge value={8.4} />
)
}
controls={
card.state === undefined ? undefined : card.state.kind ===
"started" ? (
<GameStatusChip status={card.state.status} />
) : (
<DemoBookmark
name={card.name}
pressed={card.state.pressed}
release={card.state.release}
/>
)
}
/>
</li>
))}
</PosterGrid>
);
}

function DemoBookmark({
name,
pressed: initial,
release,
}: {
name: string;
pressed: boolean;
release: ReleaseDate | undefined;
}) {
const [pressed, setPressed] = useState(initial);
return (
<>
{pressed && release !== undefined ? (
<ReleasePill release={release} />
) : null}
<CardRoundButton
label={`Want to play: ${name}`}
pressed={pressed}
onClick={() => setPressed(!pressed)}
className="ml-auto"
>
{pressed ? (
<PlannedIcon className="size-4" />
) : (
<PlanIcon className="size-4" />
)}
</CardRoundButton>
</>
);
}
5 changes: 5 additions & 0 deletions app/showcase/game-library-card-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,6 +47,7 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
item: {
...base,
name: "Playing, scored",
status: "playing",
rating: 9,
dateLine: "Started Feb 2026",
},
Expand All @@ -54,13 +56,15 @@ export function GameLibraryCardDemo({ poster }: { poster: string }) {
item: {
...base,
name: "Playing, not scored",
status: "playing",
dateLine: "Started Oct 2026",
},
},
{
item: {
...base,
name: "Finished, scored",
status: "finished",
rating: 7,
dateLine: "Finished Mar 2026",
},
Expand All @@ -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",
},
Expand Down
10 changes: 9 additions & 1 deletion app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -450,9 +451,16 @@ export default function ShowcasePage() {
</PosterGrid>
</Section>

<Section
title="Game card"
note="A game on /games, the /games lists and /search (spec 0030): the release year under the name; a visitor gets no bottom row; a signed in user gets the Want to play bookmark (green when pressed, with the release date or Date TBA while a wanted game is not out), or a read only Playing or Finished chip on a game they started. Your score replaces the IGDB rating only on a scored Playing or Finished game. The bookmark here toggles locally."
>
<GameCardDemo poster={LIGHT_POSTER} />
</Section>

<Section
title="Game library card"
note="The games tabs of /watchlist, /upcoming and /watched (spec 0027): Want to play shows the IGDB community rating and, before release, its date or Date TBA; Playing and Finished show your score when there is one, else the community rating, with the month under the name. A game IGDB hides keeps its card but has no link, and a game IGDB no longer has keeps only its removal."
note="The games tabs of /watchlist, /upcoming and /watched (spec 0027, spec 0030): Want to play shows the IGDB community rating, the green bookmark that removes it and, before release, its date or Date TBA; Playing and Finished show the status chip and the X, and your score when there is one, else the community rating, with the month under the name. A game IGDB hides keeps its card but has no link, and a game IGDB no longer has keeps only its removal."
>
<GameLibraryCardDemo poster={LIGHT_POSTER} />
</Section>
Expand Down
8 changes: 4 additions & 4 deletions app/shows/[id]/season/[number]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -173,7 +173,7 @@ async function SeasonDetail({ params }: { params: SeasonParams }) {
showId={id}
seasonNumber={seasonNumber}
seasonName={season.name}
idsKey={idsKey}
idsKey={seasonIdsKey}
episodes={episodes}
/>
</Suspense>
Expand All @@ -195,7 +195,7 @@ async function SeasonDetail({ params }: { params: SeasonParams }) {
<EpisodeList
seasonName={season.name}
episodes={season.episodes}
tracking={{ showId: id, idsKey }}
tracking={{ showId: id, idsKey: seasonIdsKey }}
/>
)}

Expand Down
2 changes: 1 addition & 1 deletion components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion components/catalog/catalog-list-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<li key={game.id} className={itemClassName}>
<GameListCard
game={game}
gridGameIds={ids}
returnPath={returnPath}
priority={index < eagerCount}
sizes={sizes}
releaseDay={releaseDay}
Expand Down
18 changes: 18 additions & 0 deletions components/catalog/catalog-list-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,16 @@ vi.mock("@/components/catalog/list-cards", () => ({
),
GameListCard: (props: {
game: { name: string };
gridGameIds: number[];
priority: boolean;
returnPath: string;
releaseDay?: string;
}) => (
<span
data-priority={String(props.priority)}
data-release-day={props.releaseDay ?? "none"}
data-return={props.returnPath}
data-ids={props.gridGameIds.join(",")}
>
{props.game.name}
</span>
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading