Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aab41eb
feat(env): add the NEXT_PUBLIC_GAMES_LIBRARY flag
kris1027 Oct 25, 2026
b1d6a80
feat(catalog): add LibraryType so game reaches only the library pages
kris1027 Oct 25, 2026
8815f04
feat(tracking): add the games library copy, month format and shared r…
kris1027 Oct 25, 2026
6003540
refactor(tracking): factor showGameUndo out of useGameUndo
kris1027 Oct 25, 2026
24448c4
feat(games): read one games library page from Postgres and IGDB
kris1027 Oct 25, 2026
2dc2bd0
feat(library): show the games tabs on the three library pages
kris1027 Oct 25, 2026
d1d5dce
feat(navbar): add the GAMES tab on the library pages
kris1027 Oct 25, 2026
9c6b6dd
feat(showcase): add the game library card states
kris1027 Oct 25, 2026
ba693cf
docs: add spec 0027, its review, and sync the games docs
kris1027 Oct 25, 2026
3917d46
refactor(games): build every game page URL through gameHref
kris1027 Oct 25, 2026
fe29436
refactor(library): share the removal focus order between the movie an…
kris1027 Oct 25, 2026
7c407aa
refactor(tracking): share the counted page read between Watched movie…
kris1027 Oct 25, 2026
f908e72
refactor(library): keep the failed list line in the library copy
kris1027 Oct 25, 2026
8e84935
refactor(library): decide each games tab's card contents in one map
kris1027 Oct 25, 2026
c0cc644
fix(library): claim no release state for a game IGDB no longer has
kris1027 Oct 25, 2026
ad33ca3
refactor(library): render every catalog failure through CatalogFailed
kris1027 Oct 25, 2026
4483005
refactor(catalog): write the library paths once and key the library c…
kris1027 Oct 25, 2026
1e590be
refactor(tracking): pass one GameToastTarget to every game toast
kris1027 Oct 25, 2026
b3a97e5
docs(specs): write LibraryErrorBoundary and the review refactors into…
kris1027 Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion app/games/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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={<RetryLink href={`/games/${id}`} />}
action={<RetryLink href={gameHref(id)} />}
/>
</div>
);
Expand Down
74 changes: 74 additions & 0 deletions app/showcase/game-library-card-demo.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GameLibraryCardDemo poster="https://example.test/poster.jpg" />);

expect(screen.getAllByRole("listitem")).toHaveLength(8);
});

it("shows the community rating and no pill on a released Want to play game", () => {
render(<GameLibraryCardDemo poster="https://example.test/poster.jpg" />);
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(<GameLibraryCardDemo poster="https://example.test/poster.jpg" />);

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(<GameLibraryCardDemo poster="https://example.test/poster.jpg" />);

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(<GameLibraryCardDemo poster="https://example.test/poster.jpg" />);

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();
});
});
89 changes: 89 additions & 0 deletions app/showcase/game-library-card-demo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PosterGrid>
{cards.map(({ item }, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed demo list with no identity.
<li key={index}>
<GameLibraryCard item={item} onRemove={() => {}} />
</li>
))}
</PosterGrid>
);
}
8 changes: 8 additions & 0 deletions app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -438,6 +439,13 @@ export default function ShowcasePage() {
</PosterGrid>
</Section>

<Section
title="Game library card"
note="The games tabs of /watchlist, /upcoming and /watched (spec 0027): Want to play shows the IGDB community rating and, before release, its date or Date TBA; Playing and Finished show your score when there is one, else the community rating, with the month under the name. A game IGDB hides keeps its card but has no link, and a game IGDB no longer has keeps only its removal."
>
<GameLibraryCardDemo poster={LIGHT_POSTER} />
</Section>

<Section
title="Carousel row"
note="A title page row (Cast, More like this): the header and arrows are prerendered, the cards stream behind skeletons, and a failed or empty list keeps a card's height so nothing shifts. The arrows show from md and are disabled at each end. The landing sections reuse the error and empty lines."
Expand Down
10 changes: 7 additions & 3 deletions app/upcoming/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,16 +24,19 @@ 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.
* Nothing on this page writes when it loads (AC-21).
*/
export default function UpcomingPage({ searchParams }: PageProps<"/upcoming">) {
return (
<div className="flex flex-col gap-8">
<LibraryHeading>Upcoming</LibraryHeading>

<Suspense fallback={<LibrarySkeleton />}>
<LibrarySection list="upcoming" searchParams={searchParams} />
</Suspense>
<LibraryErrorBoundary list="upcoming">
<Suspense fallback={<LibrarySkeleton />}>
<LibrarySection list="upcoming" searchParams={searchParams} />
</Suspense>
</LibraryErrorBoundary>
</div>
);
}
10 changes: 7 additions & 3 deletions app/watched/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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).
Expand All @@ -32,9 +34,11 @@ export default function WatchedPage({ searchParams }: PageProps<"/watched">) {
<div className="flex flex-col gap-8">
<LibraryHeading>Watched</LibraryHeading>

<Suspense fallback={<LibrarySkeleton />}>
<LibrarySection list="watched" searchParams={searchParams} />
</Suspense>
<LibraryErrorBoundary list="watched">
<Suspense fallback={<LibrarySkeleton />}>
<LibrarySection list="watched" searchParams={searchParams} />
</Suspense>
</LibraryErrorBoundary>
</div>
);
}
10 changes: 7 additions & 3 deletions app/watchlist/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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).
Expand All @@ -33,9 +35,11 @@ export default function WatchlistPage({
<div className="flex flex-col gap-8">
<LibraryHeading>Watchlist</LibraryHeading>

<Suspense fallback={<LibrarySkeleton />}>
<LibrarySection list="watchlist" searchParams={searchParams} />
</Suspense>
<LibraryErrorBoundary list="watchlist">
<Suspense fallback={<LibrarySkeleton />}>
<LibrarySection list="watchlist" searchParams={searchParams} />
</Suspense>
</LibraryErrorBoundary>
</div>
);
}
2 changes: 1 addition & 1 deletion components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006).
- `components/catalog/` holds the title page sections both `/movies/{id}` and `/shows/{id}` share (spec 0022): `title-details.tsx` (crew and Facts, from the detail read), and `title-extras.tsx`, `where-to-watch.tsx` and `trailer-button.tsx`, fed by the separate extras read. The extras sections stream in their own Suspense boundary and read the visitor's region there through `getVisitorRegion()` (`lib/region.ts`), never in the shell. The trailer iframe (`youtube-nocookie`) mounts only while its dialog is open.
- `components/catalog/landing/` holds the `/movies` and `/shows` landing sections, all Server Components except `landing-parallax.tsx` (`LandingParallax`), the landing's one client island, outside the shell. It writes only inline transforms to the `data-*` nodes the sections render, so every card and link stays server HTML. It and `layout/page-background-motion.tsx` share `startMotionLoop` (`lib/motion/loop.ts`), which owns the reduced motion stop and the mouse-only lean; a new motion loop uses it too.
- `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.

Expand Down
17 changes: 17 additions & 0 deletions components/layout/library-nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LibraryNav variant="sheet" />);

expect(
screen.getAllByRole("link").map((link) => link.getAttribute("href")),
).toEqual([
"/watchlist?type=game",
"/upcoming?type=game",
"/watched?type=game",
]);
vi.unstubAllEnvs();
});
});
Loading
Loading