diff --git a/.env.example b/.env.example index ca1ca6f6..5f074e3c 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,14 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # it on only together with enable_confirmations = true and an SMTP provider. NEXT_PUBLIC_AUTH_EMAIL_DELIVERY=off +# Optional. on or off; unset or empty means off. on adds a GAMES tab to +# /watchlist, /upcoming and /watched and accepts ?type=game there (spec 0027). +# Production leaves it unset until people have a way to find games; set it on +# in .env.local and in Vercel Preview. Public on purpose: it only decides +# whether the tab renders, never who may read what. Inlined at build time, so a +# change needs a redeploy. +NEXT_PUBLIC_GAMES_LIBRARY=on + # --------------------------------------------------------------------------- # Deployment (set by the host, not by you) # --------------------------------------------------------------------------- diff --git a/AGENTS.md b/AGENTS.md index 3f6cd058..fb2940d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,7 +284,7 @@ Games are a third media type, planned in [docs/scope/games.md](docs/scope/games. - **Ratings:** a personal score from 1 to 10, only on Playing or Finished. Moving back to Want to play clears it, with Undo restoring status, score and timestamps; Playing to Finished and back keeps it. IGDB's community rating shows out of 10 and is always labelled as the community rating. - **Timestamps:** added, started (the first time a game becomes Playing) and finished (every time it becomes Finished). Want to play sorts by added, Playing by started, Finished by finished. - **Not tracked:** platform played on, hours played, 100% completion. -- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. The GAMES tab stays hidden until G5 ships, and game pages reach the sitemap only in G10. +- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. Since G5 the GAMES tab shows only on those three library pages and only while `NEXT_PUBLIC_GAMES_LIBRARY` is `on` (spec 0027); it reaches every page in G6, and game pages reach the sitemap only in G10. ## Build approach @@ -319,6 +319,8 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke - Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, and TMDB's notice only in `TMDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if either is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` is TMDB's official file, hash checked; never edit it. - Production runs from [docs/deploy.md](docs/deploy.md), the runbook of spec 0018: push a migration with `supabase db push` before merging the PR that adds it, and auth settings reach the cloud only through `supabase config push` (never the dashboard). Run both pushes yourself; from an agent shell the CLI applies them without stopping at the prompt. `vercel.json` pins functions to `fra1`, next to the Supabase region `eu-central-1`. The `/api/search` rate limit is a Vercel Firewall rule, recorded only in `docs/deploy.md`. - `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` (`on` or `off`, unset meaning `off`) is read only through `getAuthEmailDelivery()` in `lib/env.ts`, separate from `getPublicEnv()` so previews with no Supabase variables still build. Production is `off`: no auth email is sent or promised. Being a `NEXT_PUBLIC_` value, a change takes a redeploy. +- `NEXT_PUBLIC_GAMES_LIBRARY` (`on` or `off`, unset meaning `off`) gates the games tabs (spec 0027). It is read only in `lib/env.ts`, through `getGamesLibrary()` (throws on an invalid value; only `LibrarySection` calls it) and `gamesLibraryEnabled()` (an invalid value reads as off; the navbar uses it). Production leaves it unset, Preview and `.env.local` set `on` (`docs/deploy.md`). +- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and the `typedHref` overloads accept it only on `/watchlist`, `/upcoming` and `/watched`, so `/search`, the catalogs, the proxy and `CATALOG_PATHS` cannot receive it until G6 or G7 widens the type on purpose. A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit. ## Code conventions diff --git a/app/games/[id]/page.tsx b/app/games/[id]/page.tsx index ac3b3955..92c9319c 100644 --- a/app/games/[id]/page.tsx +++ b/app/games/[id]/page.tsx @@ -8,6 +8,7 @@ import { RetryLink } from "@/components/retry-link"; import { StatePanel } from "@/components/state-panel"; import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot"; import { parseGameId } from "@/lib/catalog/ids"; +import { gameHref } from "@/lib/catalog/media-type"; import { truncateAtWord } from "@/lib/format"; import { loadGame } from "./load-game"; @@ -93,7 +94,7 @@ async function GameDetail({ variant="error" title="Couldn't reach IGDB" description="IGDB didn't respond. Try again in a moment." - action={} + action={} /> ); diff --git a/app/showcase/game-library-card-demo.test.tsx b/app/showcase/game-library-card-demo.test.tsx new file mode 100644 index 00000000..203c4160 --- /dev/null +++ b/app/showcase/game-library-card-demo.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { GameLibraryCardDemo } from "./game-library-card-demo"; + +/** + * covers: spec 0027, AC-17 + * + * `/showcase` is the drift detector for the UI, so its game library card + * section has to keep every state the spec lists. Each card is found by the + * name the demo gives it and checked for the mark that state is about. + */ +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); +} + +describe("GameLibraryCardDemo", () => { + it("renders eight cards, every state of the game library card", () => { + render(); + + expect(screen.getAllByRole("listitem")).toHaveLength(8); + }); + + it("shows the community rating and no pill on a released Want to play game", () => { + render(); + const released = card("Want to play, released"); + + expect(released.getByText("IGDB community rating")).toBeInTheDocument(); + expect(released.queryByText(/^Releases/)).not.toBeInTheDocument(); + }); + + it("shows a dated release pill and a Date TBA pill", () => { + render(); + + expect( + card("Want to play, coming soon").getByText("Releases Feb 3, 2027"), + ).toBeInTheDocument(); + expect( + card("Want to play, Date TBA").getByText("Release date to be announced"), + ).toBeInTheDocument(); + }); + + it("shows Playing with and without a score, and a scored Finished game", () => { + render(); + + expect(card("Playing, scored").getByText("Your score")).toBeInTheDocument(); + expect(card("Playing, scored").getByText("Started Feb 2026")).toBeVisible(); + expect( + card("Playing, not scored").getByText("IGDB community rating"), + ).toBeInTheDocument(); + expect( + card("Finished, scored").getByText("Finished Mar 2026"), + ).toBeInTheDocument(); + }); + + it("shows a hidden game with no link and a missing game as No longer on IGDB", () => { + render(); + + expect( + card("Hidden by IGDB, no link").queryByRole("link"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Remove missing game from your games", + }), + ).toBeInTheDocument(); + expect( + card("No longer on IGDB").queryByRole("link"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/app/showcase/game-library-card-demo.tsx b/app/showcase/game-library-card-demo.tsx new file mode 100644 index 00000000..d659555a --- /dev/null +++ b/app/showcase/game-library-card-demo.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { GameLibraryCard } from "@/components/library/game-library-card"; +import type { LibraryGameItem } from "@/components/library/types"; +import { PosterGrid } from "@/components/poster-grid"; + +/** + * Every state of the game library card (spec 0027, AC-17), for `/showcase`. + * A client module only because the card's remove button takes an `onClick`, + * which a Server Component cannot hand to a DOM element; the demo does + * nothing on a tap. + */ +export function GameLibraryCardDemo({ poster }: { poster: string }) { + const base: LibraryGameItem = { + gameId: 1, + name: "", + coverUrl: poster, + communityRating: 8.4, + rating: null, + hidden: false, + unreleased: null, + dateLine: null, + }; + const cards: { item: LibraryGameItem }[] = [ + { item: { ...base, name: "Want to play, released" } }, + { + item: { + ...base, + name: "Want to play, coming soon", + communityRating: null, + unreleased: { + date: { shortDate: "Feb 3, 2027", fullDate: "Feb 3, 2027" }, + }, + }, + }, + { + item: { + ...base, + name: "Want to play, Date TBA", + coverUrl: null, + communityRating: null, + unreleased: { date: null }, + }, + }, + { + item: { + ...base, + name: "Playing, scored", + rating: 9, + dateLine: "Started Feb 2026", + }, + }, + { + item: { + ...base, + name: "Playing, not scored", + dateLine: "Started Oct 2026", + }, + }, + { + item: { + ...base, + name: "Finished, scored", + rating: 7, + dateLine: "Finished Mar 2026", + }, + }, + { + item: { + ...base, + name: "Hidden by IGDB, no link", + hidden: true, + dateLine: "Finished Jan 2025", + }, + }, + { item: { ...base, name: null, coverUrl: null, communityRating: null } }, + ]; + + return ( + + {cards.map(({ item }, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a fixed demo list with no identity. +
  • + {}} /> +
  • + ))} +
    + ); +} diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index 0c5c405d..fb264d4a 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -64,6 +64,7 @@ import { catalogList, listPath } from "@/lib/catalog/lists"; import { TMDB_HOME_URL } from "@/lib/legal/operator"; import { CardRoundButtonDemo } from "./card-round-button-demo"; +import { GameLibraryCardDemo } from "./game-library-card-demo"; export const metadata: Metadata = { title: "Design system", @@ -438,6 +439,13 @@ export default function ShowcasePage() { +
    + +
    +
    ) { @@ -30,9 +32,11 @@ export default function UpcomingPage({ searchParams }: PageProps<"/upcoming">) {
    Upcoming - }> - - + + }> + + +
    ); } diff --git a/app/watched/page.tsx b/app/watched/page.tsx index faa4097e..bf268f6f 100644 --- a/app/watched/page.tsx +++ b/app/watched/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { LibraryErrorBoundary } from "@/components/library/library-error-boundary"; import { LibraryHeading } from "@/components/library/library-heading"; import { LibrarySection, @@ -23,6 +24,7 @@ export const metadata: Metadata = { * the session, the `type` and `page` parameters or the user's rows streams in * behind the Suspense boundary, inside `LibrarySection`, which calls * `requireUser()` itself: the proxy's redirect is only the convenience layer. + * A section that throws shows `LibraryErrorBoundary`'s panel under the heading. * * `LibraryHeading` takes the focus when a removal empties the page (spec * 0008, AC-16). Nothing on this page writes when it loads (spec 0020, AC-21). @@ -32,9 +34,11 @@ export default function WatchedPage({ searchParams }: PageProps<"/watched">) {
    Watched - }> - - + + }> + + +
    ); } diff --git a/app/watchlist/page.tsx b/app/watchlist/page.tsx index 144324b8..7ed45c34 100644 --- a/app/watchlist/page.tsx +++ b/app/watchlist/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { LibraryErrorBoundary } from "@/components/library/library-error-boundary"; import { LibraryHeading } from "@/components/library/library-heading"; import { LibrarySection, @@ -22,6 +23,7 @@ export const metadata: Metadata = { * the session, the `type` and `page` parameters or the user's rows streams in * behind the Suspense boundary, inside `LibrarySection`, which calls * `requireUser()` itself: the proxy's redirect is only the convenience layer. + * A section that throws shows `LibraryErrorBoundary`'s panel under the heading. * * `LibraryHeading` takes the focus when a removal empties the page (spec * 0008, AC-16). Nothing on this page writes when it loads (spec 0020, AC-21). @@ -33,9 +35,11 @@ export default function WatchlistPage({
    Watchlist - }> - - + + }> + + +
    ); } diff --git a/components/AGENTS.md b/components/AGENTS.md index d1bb4d5a..63d1da58 100644 --- a/components/AGENTS.md +++ b/components/AGENTS.md @@ -45,7 +45,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline` - `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006). - `components/catalog/` holds the title page sections both `/movies/{id}` and `/shows/{id}` share (spec 0022): `title-details.tsx` (crew and Facts, from the detail read), and `title-extras.tsx`, `where-to-watch.tsx` and `trailer-button.tsx`, fed by the separate extras read. The extras sections stream in their own Suspense boundary and read the visitor's region there through `getVisitorRegion()` (`lib/region.ts`), never in the shell. The trailer iframe (`youtube-nocookie`) mounts only while its dialog is open. - `components/catalog/landing/` holds the `/movies` and `/shows` landing sections, all Server Components except `landing-parallax.tsx` (`LandingParallax`), the landing's one client island, outside the shell. It writes only inline transforms to the `data-*` nodes the sections render, so every card and link stays server HTML. It and `layout/page-background-motion.tsx` share `startMotionLoop` (`lib/motion/loop.ts`), which owns the reduced motion stop and the mouse-only lean; a new motion loop uses it too. -- `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo. +- `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/layout/library-nav.test.tsx b/components/layout/library-nav.test.tsx index 2ba7e0af..c1fc9d11 100644 --- a/components/layout/library-nav.test.tsx +++ b/components/layout/library-nav.test.tsx @@ -112,4 +112,21 @@ describe("LibraryNav", () => { ).not.toBeInTheDocument(); }, ); + + // covers: spec 0027, AC-4 + it("carries type=game on a games tab while the games library is on", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + pathname = "/upcoming"; + search = "type=game"; + render(); + + expect( + screen.getAllByRole("link").map((link) => link.getAttribute("href")), + ).toEqual([ + "/watchlist?type=game", + "/upcoming?type=game", + "/watched?type=game", + ]); + vi.unstubAllEnvs(); + }); }); diff --git a/components/layout/library-nav.tsx b/components/layout/library-nav.tsx index cbd9f327..cc6e8c28 100644 --- a/components/layout/library-nav.tsx +++ b/components/layout/library-nav.tsx @@ -3,9 +3,9 @@ import { cn } from "cn"; import Link from "next/link"; -import { type MediaType, typedHref } from "@/lib/catalog/media-type"; +import { type LibraryType, typedHref } from "@/lib/catalog/media-type"; -import { WithTypedPage } from "./page-media-type"; +import { WithLibraryTypedPage } from "./page-media-type"; /** * The signed in library links: the pill in the desktop navbar and the list in @@ -20,8 +20,10 @@ import { WithTypedPage } from "./page-media-type"; * Upcoming sits between the two (spec 0014, AC-1). * * Each link carries the media type of the page being viewed, so the navbar - * tab chosen on `/movies` is still lit on `/watchlist` (feature 22), read - * through `WithTypedPage`; the fallback links to the default type. On a + * tab chosen on `/movies` is still lit on `/watchlist` (feature 22), and on a + * games tab they carry `type=game`, so moving between the three pages keeps + * you on games (spec 0027, AC-4). It is read through `WithLibraryTypedPage`; + * the fallback links to the default type. On a * page about neither catalog it renders nothing, like the media tabs and the * search (prompts/hide-tabs-without-type.md). * @@ -41,15 +43,19 @@ type Variant = "bar" | "sheet"; function LibraryNav({ variant }: { variant: Variant }) { return ( - ( )} > - {({ pathname, type }) => ( - + {({ pathname, libraryType }) => ( + )} - + ); } @@ -61,7 +67,7 @@ function LibraryNavView({ }: { variant: Variant; pathname: string; - type: MediaType; + type: LibraryType; }) { return (