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>` (`{ status, rating }` per game id; at most one section or page of ids, 24 to 40) | optional session; `signed_out` with none | `failed` on a db error, logged once as `TRACKING_EVENT.gameRead` | +| `gameIdsKey(ids)` | pure | ids | key string | none | none | +| `gameCardStatus(state, releaseDate, today)` in `lib/catalog/game-page.ts` | pure | stored state or undefined, IGDB release date, UTC day | `null` (no row), `{ status: "want_to_play", unreleased: boolean }`, `{ status: "playing" or "finished", rating: number or null }` | none | none | +| `GameCardBadge`, `GameCardControls` in `components/tracking/game-card-slots.tsx` | async Server Components sharing one `readStatus`, as `movie-card-slots.tsx` does | `gameId`, `name`, `releaseDate`, `communityRating`, `gridGameIds`, `returnPath`, `releasePill` (default true) | badge or bottom row; the controls format the pill with `formatReleaseDate(releaseDate, requestTodayUtc())` | session through the read | catalog card on `failed` | +| `GameCardBookmarkButton` in `components/tracking/game-card-bookmark-button.tsx` | client | `gameId`, `name`, `inList`, `returnPath` | optimistic toggle, disabled while pending | Server Action rechecks the session | `showGameTrackingError` | +| `GameStatusChip` in `components/tracking/game-status-chip.tsx` | plain component (no `server-only` import, so the client library grid can render it), `GlassPill` | status | chip | none | none | +| `GameListCard` (new props) | server | adds `gridGameIds`, `returnPath`; keeps `releaseDay` (the list prints dates for everyone) and passes `releasePill={releaseDay === undefined}` to the controls | card | none | none | +| `LANDING_SLOTS` (new field) | config | `meta: ((item: LandingItem) => ReactNode) or null` | `LandingPoster` meta line | none | none; `tv` and `movie` set `null`, `game` returns `item.year ?? undefined` | +| `setGameStatus`, `removeGame`, `restoreRemovedGame` | existing Server Actions | as spec 0026 | `GameTrackingResult` | session, RLS | spec 0026 errors | + +**Value sourcing**: + +| Action | Value produced or displayed | Source | +|---|---|---| +| Grid read | the user's status and score per game | `user_game_state.status`, `.rating`, filtered by the session user and `game_id in ids` | +| Grid read | the ids | the grid's own results: `paged.results` on list and search pages, the section's `ids` on the landing | +| Badge | personal score | `rating` from the grid read, only on Playing or Finished | +| Badge | community rating | `GameSummary.communityRating` or `LandingItem.rating.value` | +| Bookmark | pressed | `status === "want_to_play"` from the grid read | +| Bookmark | accessible name | IGDB `name` already on the card | +| Release pill | released or not | `gameCardStatus(...).unreleased`, from `isGameReleased(releaseDate, requestTodayUtc())`, the UTC day rule of spec 0026 | +| Release pill | date text | `formatReleaseDate(releaseDate, requestTodayUtc())`; `null` date gives Date TBA | +| Release pill | shown at all | only on Want to play, and only when `releasePill` is true: false on the landing `WideCard` (already passed) and whenever `GameListCard` has a `releaseDay` (the `/games/coming-soon` list, `list.datedCards`) | +| Add over a stale card | score cleared Undo | `setGameStatus`'s `undo` (`kind: "status"`) | +| Chip | label | `GAME_STATUS_LABELS[status]` | +| Meta line | year | `GameSummary.releaseYear` or `LandingItem.year` (already `game.releaseYear`) | +| Toasts | return path | the grid's page: `CATALOG_PATHS.game` on the landing, the list page path, the current `searchHref(params)` on `/search` | +| Undo | previous row and `removedAt` | `removeGame`'s `undo` result | +| Library card | which status | new `LibraryGameItem.status`, set in `gameLibraryTab` from the stored row | + +**Key invariants**: +- One grid read per grid; every card in it passes the same id array. +- The card never sets Playing or Finished and never sets a score, so the release gate and the score rule are enforced by never offering them; the Server Actions still check both. +- A visitor never gets a skeleton or a control they cannot use. +- The bookmark and the library Want to play button use the same glyphs and the same `removeGame` path, so Undo and toast ids match on every surface. + +**Security model**: reads run under the user's session with owner only RLS (spec 0024); no elevated key. The read is per request and never shared (`AGENTS.md` section 11). Writes are the spec 0026 actions, which derive the user from the session and validate the id with Zod. + +**Configuration required**: none. + +**Critical test scenarios**: +- Happy path: signed in, a grid of 24 games with three of yours runs one query; tapping the bookmark on an unowned game presses it, and the game appears on `/watchlist?type=game`. Verifies AC-1, AC-3, AC-8. +- Remove and Undo: unpress a wanted game, see the toast, Undo, the bookmark is pressed again and the row is back. Verifies AC-5. +- Stale card: make a game Playing with a score in another tab, tap the unpressed bookmark here, see the score cleared toast, Undo restores Playing and the score. Verifies AC-5. +- Failure: the action fails (or the session expired), the bookmark rolls back and the toast offers Sign in; a second tap while pending does nothing. Verifies AC-4. +- Gate: an unreleased wanted game shows its pill; a TBA game shows Date TBA; on Coming soon the pill is not doubled. Verifies AC-7. +- Started games: Playing scored shows the cyan score and the chip, no button. Verifies AC-2, AC-6. +- Visitor and failed read: plain cards, one log line. Verifies AC-9. +- Shell: the build still prerenders the shells. Verifies AC-13. + +## Build plan + +Tracer Bullet: one real thread on one grid first, then the other grids, then the library. + +1. [x] Thin thread: `gameIdsKey`, `getGameGridStates`, `gameCardStatus`, `GameCardBadge`, `GameCardControls` with `GameCardBookmarkButton` (disabled while pending, every `undo` to `showGameUndo`), wired into `GameListCard` on `/search?type=game` only, where `GameResults` passes `paged.results` ids itself and `returnPath = searchHref(params)`; unit tests for the read and the pure status, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-5**, **AC-9** +2. [x] The started and unreleased cases: `GameStatusChip`, the release pill branch and the `releasePill` switch, satisfies **AC-6**, **AC-7** +3. [x] Every other grid: `GameListCard` on the five list pages through `CatalogListCards` (pass `gridGameIds`, `returnPath`, and keep `releaseDay` on coming soon so the slot skips its pill), the `game` entry in `LANDING_SLOTS` (badge, controls, `meta`), which also lights the `WideCard` and `TopRatedList` rows, and the `meta` field read by `LandingPoster`; update `landing-badge.test.tsx` (it asserts games have no slots) and the games cases in `landing-sections.test.tsx`, satisfies **AC-1**, **AC-8**, **AC-10**, **AC-13**, **AC-14** +4. [x] Library tabs: `LibraryGameItem.status` set in `gameLibraryTab`; Want to play gets the green bookmark remove button, Playing and Finished get the chip left of the X; update the accessible names `game-library-grid.test.tsx` and `game-library-tab.test.tsx` assert, satisfies **AC-11** +5. [x] Icon key labels and `/showcase` states, satisfies **AC-12**, **AC-15** +6. [x] Proof: build (the `/games`, `/games/{slug}` and `/search` shells still prerender), typecheck, lint, tests, 375px (chip plus X fits two columns) and 1280px, keyboard, signed out and signed in, satisfies **AC-13**, **AC-15** + +## Consequences + +**Positive**: +- Games reach parity with movies on every grid, and the scope's "cards everywhere" promise is met without new tables or actions. +- One query per grid, so a 24 card page costs one small indexed read. + +**Negative / tradeoffs**: +- The card cannot move a game you have started; you open the game page or the library tab for that. Some users will look for it on the card. +- The Playing and Finished chip on the library tabs repeats what the tab heading already says. +- A Coming soon section on the landing and its list page now share a status read path with the rest, so a Supabase outage quietly drops personal state from every game card (by design, AC-9). + +**Neutral**: +- `LANDING_SLOTS` gains a game `meta` entry; movie and show entries leave it empty. +- `GameListCard` keeps `releaseDay` for the visitor pill on Coming soon and derives the slot's `releasePill` from it. +- A stale card's bookmark can still demote a started game, guarded only by the score cleared Undo toast. + +## Follow-up + +- [ ] `/sync` after merge: the `components/AGENTS.md` lines that say the `game` landing entry is null until G8, and the root `AGENTS.md` card status line, should name `getGameGridStates`. +- [ ] Specs 0027, 0028 and 0029 carry G8 follow-ups that this spec closes; tick them when it ships. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). diff --git a/docs/specs/0030-game-cards-everywhere/rationale.md b/docs/specs/0030-game-cards-everywhere/rationale.md new file mode 100644 index 00000000..3e3cc8c3 --- /dev/null +++ b/docs/specs/0030-game-cards-everywhere/rationale.md @@ -0,0 +1,58 @@ +# 0030. Game cards everywhere: rationale + +## Context + +Since G5 to G7, games appear on four kinds of grid: the `/games` landing sections, the five `/games/{slug}` list pages, `/search?type=game` and the three library tabs. Only the library tabs know anything about the user. Everywhere else a game card is a cover, a name and IGDB's rating, while a movie card next door shows your score, a Plan bookmark and a release pill. Specs 0027, 0028 and 0029 each left a G8 follow up saying so. + +The forces: `cacheComponents` is on, so the personal parts must stream in their own Suspense boundaries and never enter a shell or a `use cache` scope. A grid holds up to 24 cards, so a read per card would be 24 queries. The games rules say statuses are set by hand, any to any in one click, and that a score clears on a move back to Want to play; a card has room for one 40px control at most. The release gate allows Want to play before release but nothing else. And a card's status must match its library page. + +Not deciding leaves games half finished next to movies, and leaves three specs with open follow ups. + +## Options considered + +### Option 1: Mirror the movie card slots for games + +A cached grid read, a pure status function, a server badge slot, a server controls slot and one client bookmark, the same pieces `movie-card-slots.tsx` uses. + +**Pros**: +- Proven shape: the movie version already passes the shell, Suspense and one read per grid rules. +- Small: no new actions, no schema, one client component. + +**Cons**: +- The card cannot change a started game's status; that stays on the game page. + +### Option 2: A status chip that opens a menu of all three statuses + +The chip opens a popover with Want to play, Playing, Finished and Remove, reusing `applyGameIntent`. + +**Pros**: +- Matches the games rule of any status to any other in one click, on every surface. + +**Cons**: +- A popover per card, with the release gate, the score clearing Undo and focus return to handle inside a 180px card; much more client code on every grid. +- Moving a game back to Want to play from a card would clear a score with very little context. + +### Option 3: Reuse the full `GameStatusControl` on the card + +**Pros**: +- No new control at all. + +**Cons**: +- Three pills and a score pill do not fit a poster card at any width; it would redesign the card. + +## Rationale + +The engineer chose the bookmark only control in the design conversation, and it fits the forces best. The only move a card can make is between nothing and Want to play, and both are always allowed (before release too), so the card needs no gate logic and can never clear a score by accident. The started states still show, as a read only chip and the personal score, so the card tells the truth about where a game sits, which is what "a card's status always matches its library page" needs. Option 2 would satisfy the "one click" rule on cards too, but the game page and the library tabs already offer that, and the cost (a popover with Undo and gating per card) is high for a browsing surface. + +Reusing `PlanIcon` and `PlannedIcon` keeps the green token's single meaning: Want to play is the games version of Planned. The badge follows the games library card (score on Playing and Finished) rather than the movie card (score only on Watched), because Playing is where many scored games sit and the movie rule would hide those scores. The year meta line was the engineer's pick, giving the landing and list pages the line `/search` already had. The wide cards and top rated rows keep their own layouts, since they are not `PosterCard` grids. + +The flag question was settled by consistency: the game page's status control is not gated by `NEXT_PUBLIC_GAMES_LIBRARY`, so gating the card would make the same write available on one surface and not the other. + +### Decisions made while writing (recommend items) + +- **Read placement**: `getGameGridStates` sits in `lib/tracking/game-state.ts` next to `getGameTracking`, keyed by `gameIdsKey` the way `movieIdsKey` keys the movie read. Runner up: a generic grid read for all media types, which would couple three tables for no gain. +- **Pure status in `lib/catalog/game-page.ts`**: beside `isGameReleased`, so the release day rule has one home. Runner up: inline in the slot, which would be untestable without a session. +- **Bookmark add path**: `setGameStatus(id, "want_to_play")` rather than a new action. From no row it returns no Undo, so the add shows no toast. Over a stale card (another tab started and scored the game) it clears the score and returns the score cleared Undo, which the bookmark shows, so nothing is lost silently (found by the cross check). Runner up: a dedicated `addGame` that only inserts and refuses an existing row, which would avoid the demotion but duplicate the RPC and add an error state. +- **Disabled while pending**: unlike the movie bookmark, the game bookmark sends two different actions for its two directions, so two writes in flight could land in either order; disabling it for the short write is the simplest guard. Runner up: a target value queue, which is more code for a rare double tap. +- **Library status from the item**: `LibraryGameItem.status` rather than a tab prop on the grid, so each card is self describing and hidden games need no special case. +- **Library Want to play button**: same glyph and accessible name pattern as the movie Watchlist card, reusing `GameLibraryGrid`'s existing removal, so no new client state. diff --git a/docs/specs/0030-game-cards-everywhere/verify.md b/docs/specs/0030-game-cards-everywhere/verify.md new file mode 100644 index 00000000..828f2ed8 --- /dev/null +++ b/docs/specs/0030-game-cards-everywhere/verify.md @@ -0,0 +1,48 @@ +# Verify: game cards everywhere · spec 0030 · updated 2026-10-26 +_Steps derived from spec 0030 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +Run against `pnpm dev:docker` (the local stack), signed in as `user-a@example.test` / `password-a`, unless a step says signed out. + +## UI / manual +- [x] Open `/search?type=game&q=witcher` signed in, and watch the network or the database log: the grid runs one `user_game_state` query for all its ids → AC-1 +- [x] Open `/games` signed in: every section card (posters, the Coming soon wide cards, the Top rated rows) has a bookmark or a chip; one query per section → AC-1 +- [x] Open `/games/popular` signed in: every card has a bookmark or chip; one query for the page → AC-1 +- [x] Make a game Playing with score 8 on its page, then find it on `/search?type=game`: the cyan 8 replaces the IGDB rating and a `Playing` chip shows with no button → AC-2, AC-6 +- [x] Make a game Finished with no score: the amber IGDB rating stays and a `Finished` chip shows → AC-2, AC-6 +- [x] On a card with no status, tap the bookmark: it turns green and pressed at once, its name reads `Want to play: {name}`, and no toast shows → AC-3, AC-5 +- [x] Tap the pressed bookmark: it empties, the toast reads "Removed from your games.", Undo presses it again and the game is back on `/watchlist?type=game` → AC-3, AC-5 +- [x] Tap a bookmark twice quickly: the second tap does nothing while the first write runs (the button is disabled) → AC-4 +- [x] Sign out in another tab, then tap a bookmark: it rolls back and the toast offers Sign in, which returns to the same grid URL (query string included) → AC-4 +- [x] Stale card: open `/search?type=game` in tab A; in tab B make that game Playing with a score; in tab A tap the unpressed bookmark: the toast reads "Moved to Want to play. Your score was removed.", and Undo restores Playing and the score → AC-5 +- [x] Remove a wanted game in tab B, then tap its pressed bookmark in tab A: a quiet success with no Undo toast → AC-5 +- [x] Want to play a game releasing after today (UTC): the release pill with its date shows beside the pressed bookmark; a game with no IGDB date shows Date TBA; a released wanted game shows no pill; an unpressed or started card never shows the pill → AC-7 +- [x] On `/games/coming-soon` press a bookmark: the card still shows exactly one date pill; on the `/games` Coming soon wide cards, the same → AC-7 +- [x] A game shown pressed on any grid is on `/watchlist?type=game`; a Playing chip is on `/upcoming?type=game`; a Finished chip is on `/watched?type=game` → AC-8 +- [x] Signed out, open `/games`, `/games/popular` and `/search?type=game`: IGDB ratings only, no bookmark, chip or personal pill → AC-9 +- [x] Stop the local stack's database (or break the read) and reload a grid signed in: plain catalog cards and one `game_tracking.read refused db_error` log line per grid read, not per card → AC-9 +- [x] Poster cards on `/games` sections, the list pages and `/search` show the release year under the name, and none when IGDB has no year; wide cards and Top rated rows show no meta line → AC-10 +- [x] On `/watchlist?type=game`, the card shows the green bookmark named `Remove {name} from Want to play`; tapping it removes the card with the same Undo toast; a hidden game keeps it; a missing game keeps the X → AC-11 +- [x] On `/upcoming?type=game` and `/watched?type=game`, each card shows the `Playing` or `Finished` chip left of the X named `Remove {name} from your games` → AC-11 +- [x] The footer icon key reads `Plan / Want to play` and `Planned / Want to play` → AC-12 +- [x] With a slow network, game covers on `/games`, the list pages and `/search` paint before the badges and controls arrive → AC-13 +- [x] Load `/games`, `/games/popular` and `/search?type=game` and check the database: no row is written by loading → AC-14 +- [x] `/showcase` shows the Game card section with all seven states; Tab reaches a bookmark, Enter and Space toggle it, the focus ring is visible → AC-15 +- [x] At 375px and 1280px, the card bottom rows fit (pill plus bookmark, chip plus X) without overlap → AC-15 + +## Value sourcing +- [ ] Grid read ids: a grid's ids are its own results (search page, list page, landing section), never another section's → AC-1 +- [ ] Personal score: a Want to play row never shows a score badge even if one were stored → AC-2 +- [x] Community rating: the fallback badge shows IGDB's rating (`GameSummary.communityRating` or `LandingItem.rating.value`), labelled IGDB → AC-2 +- [x] Pressed: only `status = want_to_play` presses the bookmark → AC-3 +- [ ] Release gate: a game releasing on today's UTC day counts as released (no pill); one releasing tomorrow UTC shows the pill. Check across a local timezone that is a day ahead of UTC → AC-7 +- [x] Return path: a session expired Sign in from `/games` returns to `/games`, from a list page to that page, from `/search` to the full search URL → AC-4 +- [x] Library status: the library card's chip text comes from the tab's stored status → AC-11 + +## Commands +- [x] `pnpm typecheck` → passes +- [x] `pnpm lint` → passes +- [x] `pnpm test` → all suites pass +- [x] `pnpm build` → `/games`, every `/games/{slug}` and `/search` stay ◐ (partial prerender) → AC-13 + +## Acceptance-criteria coverage +- AC-1 grid read steps, value sourcing ids · AC-2 started and badge steps · AC-3 bookmark steps · AC-4 double tap, session expired, return path · AC-5 Undo, stale card, quiet removal · AC-6 chip steps · AC-7 pill steps, UTC day · AC-8 library match · AC-9 visitor and failed read · AC-10 year meta · AC-11 library tabs · AC-12 icon key · AC-13 shell and build · AC-14 no write on load · AC-15 showcase, keyboard, widths diff --git a/lib/catalog/game-page.test.ts b/lib/catalog/game-page.test.ts index ef20706b..43d1abee 100644 --- a/lib/catalog/game-page.test.ts +++ b/lib/catalog/game-page.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { gameReleaseNote, isGameReleased } from "./game-page"; +import { gameCardStatus, gameReleaseNote, isGameReleased } from "./game-page"; import { isMovieReleased, movieReleaseNote } from "./movie-page"; /** @@ -42,3 +42,49 @@ describe("gameReleaseNote", () => { expect(gameReleaseNote("2027-01-02")).toBe(movieReleaseNote("2027-01-02")); }); }); + +// spec 0030, AC-2, AC-6, AC-7, AC-8: the card reads the stored status as it +// is, and only Want to play asks the release gate. +describe("gameCardStatus", () => { + it("is null with no row or no status", () => { + expect(gameCardStatus(undefined, today, today)).toBeNull(); + expect( + gameCardStatus({ status: null, rating: null }, today, today), + ).toBeNull(); + }); + + it("marks a Want to play game unreleased before its UTC day, or with no date (AC-7)", () => { + const wanted = { status: "want_to_play" as const, rating: null }; + expect(gameCardStatus(wanted, "2026-10-25", today)).toEqual({ + status: "want_to_play", + unreleased: true, + }); + expect(gameCardStatus(wanted, null, today)).toEqual({ + status: "want_to_play", + unreleased: true, + }); + expect(gameCardStatus(wanted, today, today)).toEqual({ + status: "want_to_play", + unreleased: false, + }); + }); + + it("never carries a score on Want to play, even if one were stored (AC-2)", () => { + expect( + gameCardStatus( + { status: "want_to_play", rating: 7 }, + "2015-05-19", + today, + ), + ).toEqual({ status: "want_to_play", unreleased: false }); + }); + + it("carries the score of a started game, whatever its release date (AC-2, AC-6)", () => { + expect( + gameCardStatus({ status: "playing", rating: 8 }, null, today), + ).toEqual({ status: "playing", rating: 8 }); + expect( + gameCardStatus({ status: "finished", rating: null }, today, today), + ).toEqual({ status: "finished", rating: null }); + }); +}); diff --git a/lib/catalog/game-page.ts b/lib/catalog/game-page.ts index 19b5ba5b..629a47df 100644 --- a/lib/catalog/game-page.ts +++ b/lib/catalog/game-page.ts @@ -1,4 +1,8 @@ import { isMovieReleased, movieReleaseNote } from "@/lib/catalog/movie-page"; +import type { + GameTrackingState, + StartedGameStatus, +} from "@/lib/tracking/types"; /** * Whether a game can be Playing, Finished or scored yet (`AGENTS.md` @@ -31,3 +35,34 @@ export function isGameReleased( export function gameReleaseNote(releaseDate: string | null): string { return movieReleaseNote(releaseDate); } + +/** + * What a game card shows of its stored row (spec 0030, AC-2, AC-6, AC-7), + * read straight from the status, so a card always names the library tab the + * game is on (AC-8): no row is no status, Want to play carries whether the + * game is still to come (for the release pill), and Playing or Finished + * carry the score for the badge. + * + * @param state The stored state, or undefined when the user has no row. + * @param releaseDate The IGDB release date already on the card. + * @param today `requestTodayUtc()`, read once per request. + */ +export function gameCardStatus( + state: GameTrackingState | undefined, + releaseDate: string | null, + today: string, +): GameCardStatus | null { + if (state === undefined || state.status === null) return null; + if (state.status === "want_to_play") { + return { + status: "want_to_play", + unreleased: !isGameReleased(releaseDate, today), + }; + } + return { status: state.status, rating: state.rating }; +} + +/** A game card's stored state, from `gameCardStatus`. */ +export type GameCardStatus = + | { status: "want_to_play"; unreleased: boolean } + | { status: StartedGameStatus; rating: number | null }; diff --git a/lib/tracking/episode-state.test.ts b/lib/tracking/episode-state.test.ts index 3193152e..a4e9293d 100644 --- a/lib/tracking/episode-state.test.ts +++ b/lib/tracking/episode-state.test.ts @@ -34,8 +34,9 @@ const createClient = vi.fn(async () => ({ })); vi.mock("@/lib/supabase/server", () => ({ createClient })); -const { episodeIdsKey, getSeasonEpisodeTracking, requestTodayUtc } = - await import("./episode-state"); +const { getSeasonEpisodeTracking, requestTodayUtc } = await import( + "./episode-state" +); const SHOW = 1396; let warn: ReturnType; @@ -83,7 +84,7 @@ describe("getSeasonEpisodeTracking", () => { }); it("reads the whole season in one query scoped to the session user and show (AC-18)", async () => { - await getSeasonEpisodeTracking(SHOW, episodeIdsKey([62087, 62085, 62086])); + await getSeasonEpisodeTracking(SHOW, "62085,62086,62087"); expect(calls.filter((c) => c.method === "from")).toEqual([ { method: "from", args: ["user_episode_state"] }, ]); @@ -140,22 +141,6 @@ describe("getSeasonEpisodeTracking", () => { }); }); -describe("episodeIdsKey", () => { - it("gives one key for the same ids in any order, with duplicates dropped", () => { - expect(episodeIdsKey([62087, 62085, 62086, 62085])).toBe( - "62085,62086,62087", - ); - }); - - it("sorts numerically, not as strings", () => { - expect(episodeIdsKey([100, 9, 20])).toBe("9,20,100"); - }); - - it("is an empty string for a season with no episodes", () => { - expect(episodeIdsKey([])).toBe(""); - }); -}); - describe("requestTodayUtc", () => { it("is today's UTC calendar date, not the local one (AC-3)", () => { vi.useFakeTimers(); diff --git a/lib/tracking/episode-state.ts b/lib/tracking/episode-state.ts index f28d9393..8682d198 100644 --- a/lib/tracking/episode-state.ts +++ b/lib/tracking/episode-state.ts @@ -7,6 +7,7 @@ import { publicEnvProblems } from "@/lib/env"; import { createClient } from "@/lib/supabase/server"; import { todayUtc } from "@/lib/tv/air-status"; +import { parseIdsKey } from "./ids-key"; import { logTrackingEvent, TRACKING_EVENT } from "./log"; import type { EpisodeTrackingState, TrackingRead } from "./types"; @@ -24,7 +25,7 @@ import type { EpisodeTrackingState, TrackingRead } from "./types"; * counted (AC-25). No row for an id means the empty state. * * @param showId The show, already confirmed by `loadSeason`. - * @param idsKey The whole season's episode ids, from `episodeIdsKey`. + * @param idsKey The whole season's episode ids, from `idsKey`. */ export const getSeasonEpisodeTracking = cache( async ( @@ -36,7 +37,7 @@ export const getSeasonEpisodeTracking = cache( const user = await getOptionalUser(); if (!user) return { kind: "signed_out" }; - const ids = idsKey === "" ? [] : idsKey.split(",").map(Number); + const ids = parseIdsKey(idsKey); if (ids.length === 0) return { kind: "ok", state: {} }; const supabase = await createClient(); @@ -63,17 +64,6 @@ export const getSeasonEpisodeTracking = cache( }, ); -/** - * The cache key `getSeasonEpisodeTracking` expects: the same ids in any order - * give the same key. Built once per page from the whole season, never from a - * single row, or each row would make its own query. - * - * @param ids The season's episode ids. - */ -export function episodeIdsKey(ids: readonly number[]): string { - return [...new Set(ids)].sort((a, b) => a - b).join(","); -} - /** * Today in UTC, read once per request so the header and every row agree on * which episodes have aired (spec 0011, AC-3). Request scoped by design: it is diff --git a/lib/tracking/game-library.test.ts b/lib/tracking/game-library.test.ts index 6e97e563..834811ed 100644 --- a/lib/tracking/game-library.test.ts +++ b/lib/tracking/game-library.test.ts @@ -58,6 +58,7 @@ afterEach(() => { const ROW = { game_id: 1942, + status: "finished", rating: 9, added_at: "2026-01-01T10:00:00+00:00", started_at: "2026-02-01T10:00:00+00:00", @@ -82,6 +83,7 @@ describe("getGameLibraryPage", () => { rows: [ { gameId: 1942, + status: "finished", rating: 9, addedAt: ROW.added_at, startedAt: ROW.started_at, @@ -95,7 +97,7 @@ describe("getGameLibraryPage", () => { { method: "select", args: [ - "game_id, rating, added_at, started_at, finished_at", + "game_id, status, rating, added_at, started_at, finished_at", { count: "exact" }, ], }, diff --git a/lib/tracking/game-library.ts b/lib/tracking/game-library.ts index bfba2e4d..a16919ff 100644 --- a/lib/tracking/game-library.ts +++ b/lib/tracking/game-library.ts @@ -41,6 +41,8 @@ export const GAME_LIBRARY_TABS: Record< /** One game row on a tab, before IGDB fills in the card. */ export type GameLibraryRow = { gameId: number; + /** The stored status, which the card shows (spec 0030, AC-8, AC-11). */ + status: GameStatus; /** The personal score; only Playing and Finished can hold one. */ rating: number | null; addedAt: string; @@ -71,7 +73,7 @@ export function getGameLibraryPage({ read: (supabase, from, to) => supabase .from("user_game_state") - .select("game_id, rating, added_at, started_at, finished_at", { + .select("game_id, status, rating, added_at, started_at, finished_at", { count: "exact", }) .eq("user_id", userId) @@ -88,6 +90,7 @@ export function getGameLibraryPage({ map: (rows) => rows.map((row) => ({ gameId: row.game_id, + status: row.status, rating: row.rating, addedAt: row.added_at, startedAt: row.started_at, diff --git a/lib/tracking/game-state.test.ts b/lib/tracking/game-state.test.ts index 0364b699..6fa145a5 100644 --- a/lib/tracking/game-state.test.ts +++ b/lib/tracking/game-state.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /** - * covers: spec 0026, AC-1, AC-12 + * covers: spec 0026, AC-1, AC-12; spec 0030, AC-1, AC-9 * * The read behind the game page's tracking slot. The session and the database * are the boundaries, so those are what is replaced; the query builder @@ -15,13 +15,15 @@ let response: { data: unknown; error: unknown } = { data: null, error: null }; function builder() { const chain: Record = {}; - for (const method of ["from", "select", "eq"]) { + for (const method of ["from", "select", "eq", "in"]) { chain[method] = (...args: unknown[]) => { calls.push({ method, args }); return chain; }; } chain.maybeSingle = () => Promise.resolve(response); + // biome-ignore lint/suspicious/noThenProperty: stands in for a thenable PostgREST builder. + chain.then = (resolve: (value: unknown) => unknown) => resolve(response); return chain; } @@ -33,7 +35,7 @@ const createClient = vi.fn(async () => ({ })); vi.mock("@/lib/supabase/server", () => ({ createClient })); -const { getGameTracking } = await import("./game-state"); +const { getGameTracking, getGameGridStates } = await import("./game-state"); let warn: ReturnType; @@ -97,3 +99,57 @@ describe("getGameTracking", () => { expect(JSON.stringify(warn.mock.calls)).not.toContain("secret detail"); }); }); + +describe("getGameGridStates", () => { + it("reads every grid game's status and score in one query (spec 0030, AC-1)", async () => { + response = { + data: [ + { game_id: 1942, status: "finished", rating: 9 }, + { game_id: 7346, status: "want_to_play", rating: null }, + ], + error: null, + }; + const result = await getGameGridStates("1942,7346"); + expect(result).toEqual({ + kind: "ok", + state: new Map([ + [1942, { status: "finished", rating: 9 }], + [7346, { status: "want_to_play", rating: null }], + ]), + }); + expect(calls).toEqual([ + { method: "from", args: ["user_game_state"] }, + { method: "select", args: ["game_id, status, rating"] }, + { method: "eq", args: ["user_id", "user-a"] }, + { method: "in", args: ["game_id", [1942, 7346]] }, + ]); + }); + + it("is signed out for a visitor (AC-9)", async () => { + getOptionalUser.mockResolvedValue(null); + expect(await getGameGridStates("1942")).toEqual({ kind: "signed_out" }); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("is signed out, with no session read, when the public env is missing (AC-9)", async () => { + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", undefined); + expect(await getGameGridStates("1942")).toEqual({ kind: "signed_out" }); + expect(getOptionalUser).not.toHaveBeenCalled(); + }); + + it("returns an empty map with no query for an empty grid", async () => { + expect(await getGameGridStates("")).toEqual({ + kind: "ok", + state: new Map(), + }); + expect(createClient).not.toHaveBeenCalled(); + expect(getOptionalUser).not.toHaveBeenCalled(); + }); + + it("reports failed and logs once with no detail on a database error (AC-9)", async () => { + response = { data: null, error: { code: "XX000", message: "1942" } }; + expect(await getGameGridStates("1942,7346")).toEqual({ kind: "failed" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("game_tracking.read refused db_error"); + }); +}); diff --git a/lib/tracking/game-state.ts b/lib/tracking/game-state.ts index f452d01a..6aa9bc65 100644 --- a/lib/tracking/game-state.ts +++ b/lib/tracking/game-state.ts @@ -1,9 +1,12 @@ import "server-only"; +import { cache } from "react"; + import { getOptionalUser } from "@/lib/auth/user"; import { publicEnvProblems } from "@/lib/env"; import { createClient } from "@/lib/supabase/server"; +import { parseIdsKey } from "./ids-key"; import { logTrackingEvent, TRACKING_EVENT } from "./log"; import { EMPTY_GAME_TRACKING, @@ -18,7 +21,8 @@ import { * Read per request and never inside `use cache`: the row belongs to one * person, and a shared entry would hand it to the next visitor (`AGENTS.md` * section 11). Only the game page's tracking slot calls it, inside its own - * Suspense boundary, so the page shell stays prerendered. It checks + * Suspense boundary, so the page shell stays prerendered. A grid of cards + * reads through `getGameGridStates` instead. It checks * `publicEnvProblems()` first, as `getMovieTracking` does, so a clone with no * Supabase configuration still serves the page with no control. * @@ -48,3 +52,55 @@ export async function getGameTracking( return { kind: "ok", state: { status: data.status, rating: data.rating } }; } + +/** + * The signed in user's status and score for every game on a grid, in one + * query for the whole grid (spec 0030, AC-1): a landing section, a list page + * or a results page. A game with no row is absent from the map. + * + * The movie grid read's shape (`getMovieGridStates`): the argument is a + * sorted id string, because React `cache()` compares arguments by identity + * and only a primitive lets every badge and control on the grid share one + * read per request. Per request and never inside `use cache`, for the reason + * `getGameTracking` gives (`AGENTS.md` section 11). A failure is logged once + * here, so the cards can quietly fall back to the catalog card (AC-9). + * An empty grid has nothing to look up, so it answers before the session + * read (spec 0030, surface table). + * + * @param idsKey The grid's game ids, from `idsKey`. + */ +export const getGameGridStates = cache( + async ( + idsKey: string, + ): Promise>> => { + const ids = parseIdsKey(idsKey); + if (ids.length === 0) return { kind: "ok", state: new Map() }; + + if (publicEnvProblems()) return { kind: "signed_out" }; + + const user = await getOptionalUser(); + if (!user) return { kind: "signed_out" }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("user_game_state") + .select("game_id, status, rating") + .eq("user_id", user.id) + .in("game_id", ids); + + if (error) { + logTrackingEvent(TRACKING_EVENT.gameRead, "db_error"); + return { kind: "failed" }; + } + + return { + kind: "ok", + state: new Map( + data.map((row) => [ + row.game_id, + { status: row.status, rating: row.rating }, + ]), + ), + }; + }, +); diff --git a/lib/tracking/ids-key.test.ts b/lib/tracking/ids-key.test.ts new file mode 100644 index 00000000..7b7b4773 --- /dev/null +++ b/lib/tracking/ids-key.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { idsKey, parseIdsKey } from "./ids-key"; + +/** + * covers: spec 0011, AC-18; spec 0020, AC-6; spec 0030, AC-1 + * + * Every grid and season read shares one request scoped read only if every + * card builds the same key, so the key's order and repeat rules are pinned. + */ +describe("idsKey", () => { + it("gives one key for the same ids in any order, with repeats dropped", () => { + expect(idsKey([62087, 62085, 62086, 62085])).toBe("62085,62086,62087"); + expect(idsKey([1396, 1399])).toBe(idsKey([1399, 1396])); + }); + + it("sorts by number, not as text, so 9 comes before 10", () => { + expect(idsKey([100, 10, 9])).toBe("9,10,100"); + }); + + it("is empty for no ids", () => { + expect(idsKey([])).toBe(""); + }); +}); + +describe("parseIdsKey", () => { + it("reads back the ids the key was built from", () => { + expect(parseIdsKey(idsKey([603, 550, 550]))).toEqual([550, 603]); + }); + + it("is no ids for the empty key", () => { + expect(parseIdsKey("")).toEqual([]); + }); +}); diff --git a/lib/tracking/ids-key.ts b/lib/tracking/ids-key.ts new file mode 100644 index 00000000..c21faee4 --- /dev/null +++ b/lib/tracking/ids-key.ts @@ -0,0 +1,25 @@ +/** + * The one way a set of ids becomes a React `cache()` key, shared by every + * per request read that serves a whole grid or season at once (the movie, + * game and show grids, a season's episodes). `cache()` compares arguments by + * identity, so only a primitive lets every card or row on a page share one + * read: the same ids in any order, with repeats, give the same key. + * + * Pure and free of `server-only`, so the slots and their tests use the real + * key instead of a copy of it. + * + * @param ids The ids on the page. + */ +export function idsKey(ids: readonly number[]): string { + return [...new Set(ids)].sort((a, b) => a - b).join(","); +} + +/** + * The ids back out of a key from `idsKey`, inside the cached read. The empty + * key is no ids, so the read can skip its query. + * + * @param key A key built by `idsKey`. + */ +export function parseIdsKey(key: string): number[] { + return key === "" ? [] : key.split(",").map(Number); +} diff --git a/lib/tracking/movie-state.test.ts b/lib/tracking/movie-state.test.ts index eed00d36..50d58d27 100644 --- a/lib/tracking/movie-state.test.ts +++ b/lib/tracking/movie-state.test.ts @@ -35,9 +35,7 @@ const createClient = vi.fn(async () => ({ })); vi.mock("@/lib/supabase/server", () => ({ createClient })); -const { getMovieTracking, getMovieGridStates, movieIdsKey } = await import( - "./movie-state" -); +const { getMovieTracking, getMovieGridStates } = await import("./movie-state"); let warn: ReturnType; @@ -118,7 +116,7 @@ describe("getMovieGridStates", () => { ], error: null, }; - const result = await getMovieGridStates(movieIdsKey([603, 550, 550])); + const result = await getMovieGridStates("550,603"); expect(result).toEqual({ kind: "ok", state: new Map([ @@ -163,10 +161,3 @@ describe("getMovieGridStates", () => { expect(warn).toHaveBeenCalledWith("movie_tracking.read refused db_error"); }); }); - -describe("movieIdsKey", () => { - it("gives one key for the same ids in any order", () => { - expect(movieIdsKey([3, 1, 2, 1])).toBe("1,2,3"); - expect(movieIdsKey([])).toBe(""); - }); -}); diff --git a/lib/tracking/movie-state.ts b/lib/tracking/movie-state.ts index 5d2004a9..b0df5a7c 100644 --- a/lib/tracking/movie-state.ts +++ b/lib/tracking/movie-state.ts @@ -6,6 +6,7 @@ import { getOptionalUser } from "@/lib/auth/user"; import { publicEnvProblems } from "@/lib/env"; import { createClient } from "@/lib/supabase/server"; +import { parseIdsKey } from "./ids-key"; import { logTrackingEvent, TRACKING_EVENT } from "./log"; import { EMPTY_MOVIE_TRACKING, @@ -79,7 +80,7 @@ export async function getMovieTracking( * on the grid share one read per request. * * @param idsKey The grid's movie ids, sorted and joined with commas. Build it - * with `movieIdsKey`. + * with `idsKey`. */ export const getMovieGridStates = cache( async ( @@ -90,7 +91,7 @@ export const getMovieGridStates = cache( const user = await getOptionalUser(); if (!user) return { kind: "signed_out" }; - const ids = idsKey === "" ? [] : idsKey.split(",").map(Number); + const ids = parseIdsKey(idsKey); if (ids.length === 0) return { kind: "ok", state: new Map() }; const supabase = await createClient(); @@ -120,13 +121,3 @@ export const getMovieGridStates = cache( }; }, ); - -/** - * The cache key `getMovieGridStates` expects: the same ids in any order - * give the same key. - * - * @param ids The movie ids on the grid. - */ -export function movieIdsKey(ids: readonly number[]): string { - return [...new Set(ids)].sort((a, b) => a - b).join(","); -} diff --git a/lib/tracking/show-grid-status.ts b/lib/tracking/show-grid-status.ts index 7a33f9ae..bacb5fce 100644 --- a/lib/tracking/show-grid-status.ts +++ b/lib/tracking/show-grid-status.ts @@ -40,7 +40,7 @@ export type ShowCardStatus = * badge, meta line and controls share one pass. Never inside `use cache` * (`AGENTS.md` section 11). * - * @param idsKey The grid's show ids, from `showIdsKey`. + * @param idsKey The grid's show ids, from `idsKey`. */ export const getShowGridStatus = cache( async ( diff --git a/lib/tracking/show-state.test.ts b/lib/tracking/show-state.test.ts index a60ddd8c..e07be8a4 100644 --- a/lib/tracking/show-state.test.ts +++ b/lib/tracking/show-state.test.ts @@ -49,7 +49,6 @@ const { getShowTracking, getTrackedShows, getWatchedEpisodeIds, - showIdsKey, WATCHED_IDS_PAGE_SIZE, } = await import("./show-state"); @@ -83,21 +82,6 @@ function expectAnonymousLog(event: string) { } } -describe("showIdsKey", () => { - it("gives the same key for the same ids in any order, with repeats dropped", () => { - expect(showIdsKey([1399, 1396, 1399])).toBe("1396,1399"); - expect(showIdsKey([1396, 1399])).toBe(showIdsKey([1399, 1396])); - }); - - it("sorts by number, not as text", () => { - expect(showIdsKey([100, 9, 20])).toBe("9,20,100"); - }); - - it("is empty for no ids", () => { - expect(showIdsKey([])).toBe(""); - }); -}); - describe("getShowTracking (spec 0020, AC-1, AC-2)", () => { it("is signed out, with no session read, when the public env is missing", async () => { vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", undefined); diff --git a/lib/tracking/show-state.ts b/lib/tracking/show-state.ts index 052bf661..fe45eb6d 100644 --- a/lib/tracking/show-state.ts +++ b/lib/tracking/show-state.ts @@ -6,6 +6,7 @@ import { getOptionalUser } from "@/lib/auth/user"; import { publicEnvProblems } from "@/lib/env"; import { createClient } from "@/lib/supabase/server"; +import { parseIdsKey } from "./ids-key"; import { logTrackingEvent, TRACKING_EVENT } from "./log"; import type { TrackingRead } from "./types"; @@ -63,7 +64,7 @@ export const getShowTracking = cache( * Which shows on a grid are tracked, in one query for the whole grid (spec * 0020, AC-6), the `getWatchlistedMovieIds` pattern. * - * @param idsKey The grid's show ids, from `showIdsKey`. + * @param idsKey The grid's show ids, from `idsKey`. */ export const getTrackedShows = cache( async (idsKey: string): Promise>> => { @@ -102,7 +103,7 @@ export const getTrackedShows = cache( * exact count is reached, as `getShowEpisodeRatings` does, so a long running * show never loses rows to the response cap. * - * @param idsKey The show ids, from `showIdsKey`: one for the show hero. + * @param idsKey The show ids, from `idsKey`: one for the show hero. */ export const getWatchedEpisodeIds = cache( async (idsKey: string): Promise>>> => { @@ -144,20 +145,6 @@ export const getWatchedEpisodeIds = cache( }, ); -/** - * The cache key the reads above expect: the same ids in any order give the - * same key, so every card on one page shares one read. - * - * @param ids The show ids. - */ -export function showIdsKey(ids: readonly number[]): string { - return [...new Set(ids)].sort((a, b) => a - b).join(","); -} - -function parseIdsKey(idsKey: string): number[] { - return idsKey === "" ? [] : idsKey.split(",").map(Number); -} - function trackingReadFailed(): { kind: "failed" } { logTrackingEvent(TRACKING_EVENT.showTrackingRead, "db_error"); return { kind: "failed" }; diff --git a/lib/tracking/types.test.ts b/lib/tracking/types.test.ts new file mode 100644 index 00000000..2323c774 --- /dev/null +++ b/lib/tracking/types.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isStartedGame } from "./types"; + +/** + * covers: spec 0030, AC-2, AC-6, AC-11 + * + * One rule picks the chip over the bookmark on every game card, and the + * score badge only on a started game. + */ +describe("isStartedGame", () => { + it("is true for Playing and Finished", () => { + expect(isStartedGame({ status: "playing" })).toBe(true); + expect(isStartedGame({ status: "finished" })).toBe(true); + }); + + it("is false for Want to play, no status, and no item", () => { + expect(isStartedGame({ status: "want_to_play" })).toBe(false); + expect(isStartedGame({ status: null })).toBe(false); + expect(isStartedGame(null)).toBe(false); + expect(isStartedGame(undefined)).toBe(false); + }); +}); diff --git a/lib/tracking/types.ts b/lib/tracking/types.ts index 7ac4f0bd..af2f715d 100644 --- a/lib/tracking/types.ts +++ b/lib/tracking/types.ts @@ -177,6 +177,31 @@ export type UntrackShowResult = */ export type GameStatus = Database["public"]["Enums"]["game_status"]; +/** + * A game that has been started: the statuses that may hold a score and that + * a card shows as the read only chip rather than the Want to play bookmark + * (`AGENTS.md` "Games", spec 0030, AC-2, AC-6, AC-11). + */ +export type StartedGameStatus = Exclude; + +/** + * Whether a game card's or library item's status is a started one. A guard + * on the whole item, so a card status union narrows to its scored variant. + * Null or undefined, a game with no row or no read, is not started. + * + * @param item Anything carrying a stored game status. + */ +export function isStartedGame( + item: T | null | undefined, +): item is T & { status: StartedGameStatus } { + return ( + item !== null && + item !== undefined && + item.status !== null && + item.status !== "want_to_play" + ); +} + /** * Every status, from the generated enum in its SQL order, which is also the * pill order (spec 0026, AC-2). The Zod schema and the control both read it,