Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e6f0fea
docs: add spec 0028 for the games home and list pages
kris1027 Oct 25, 2026
8f000fc
feat(igdb): let discover serve pages of 24
kris1027 Oct 25, 2026
777d265
feat(igdb): add wide art to every game summary
kris1027 Oct 25, 2026
68668f7
feat(igdb): read the trending and popular lists from IGDB popularity
kris1027 Oct 25, 2026
6d7f238
feat(games): add the five paged games list pages and teach the landin…
kris1027 Oct 25, 2026
71f08c4
feat(games): turn /games into the parallax games landing
kris1027 Oct 25, 2026
e9f030c
feat(navbar): show GAMES on every page and lead empty games screens t…
kris1027 Oct 25, 2026
3c7fcf2
refactor(landing): draw the wide card from its own view, apart from t…
kris1027 Oct 25, 2026
e3d7f18
feat(showcase): add the game landing cards and the games list states
kris1027 Oct 25, 2026
77e4340
docs(scope): tick the games home and list pages build
kris1027 Oct 25, 2026
be01fba
docs(specs): add the verify steps for spec 0028
kris1027 Oct 25, 2026
c24a0d3
test(games): cover the landing items and the games metadata
kris1027 Oct 25, 2026
0784c43
docs(specs): record the verify run for spec 0028
kris1027 Oct 25, 2026
40f7b6f
docs(scope): tick verify and test for the games home and list pages
kris1027 Oct 25, 2026
4ae6c9e
refactor(catalog): share one catalog error check and one source name map
kris1027 Oct 25, 2026
826348e
refactor(catalog): decide a games list's Next beside its last page
kris1027 Oct 25, 2026
e673691
refactor(igdb): write the games list page size once
kris1027 Oct 25, 2026
25287b8
refactor(igdb): name why the popularity read unbrands its kind
kris1027 Oct 25, 2026
4b5073b
refactor(landing): give games their own entry in the landing slots
kris1027 Oct 25, 2026
050b1a1
fix(games): read the games lists' day once per request
kris1027 Oct 25, 2026
f22a4b8
fix(games): name the missing games on an empty list page
kris1027 Oct 25, 2026
3610e01
feat(games): print the release day on Coming soon list cards
kris1027 Oct 25, 2026
ffad25d
docs(catalog): rewrap the JSDoc lines the games pages stretched
kris1027 Oct 25, 2026
e22844d
docs(specs): amend specs 0025 and 0027 for the games home
kris1027 Oct 25, 2026
130392c
chore(sync): reconcile docs for the games home and list pages
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
4 changes: 2 additions & 2 deletions 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. 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.
- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. Since G6 the GAMES tab shows on every page while `NEXT_PUBLIC_GAMES_LIBRARY` is `on` (spec 0028), and `/games` is the games landing with five paged list pages (`/games/{slug}`); game pages reach the sitemap only in G10.

## Build approach

Expand Down Expand Up @@ -320,7 +320,7 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke
- 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.
- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and the `typedHref` overloads accept it only on `/watchlist`, `/upcoming` and `/watched`, so `/search` cannot receive it until G7 widens the type on purpose. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). 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
14 changes: 11 additions & 3 deletions app/catalog-lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

import { MOVIE_LIST_SLUGS, TV_LIST_SLUGS } from "@/lib/catalog/lists";
import {
GAME_LIST_SLUGS,
MOVIE_LIST_SLUGS,
TV_LIST_SLUGS,
} from "@/lib/catalog/lists";

/**
* covers: spec 0021, the registry invariant
* covers: spec 0021, the registry invariant; spec 0028, AC-9
*
* A slug is defined once, in `lib/catalog/lists.ts`, and each list has its
* own static route folder. This keeps the two in step: a folder with no
Expand All @@ -22,6 +26,7 @@ function staticRouteFolders(dir: string): string[] {
describe.each([
["app/shows", "tv", TV_LIST_SLUGS],
["app/movies", "movie", MOVIE_LIST_SLUGS],
["app/games", "game", GAME_LIST_SLUGS],
] as const)("%s", (dir, mediaType, slugs) => {
it("has one static folder per registry slug, and no other", () => {
expect(staticRouteFolders(dir)).toEqual([...slugs].sort());
Expand All @@ -31,6 +36,9 @@ describe.each([
const source = readFileSync(join(dir, slug, "page.tsx"), "utf8");
expect(source).toContain(`catalogList("${mediaType}", "${slug}")`);
expect(source).toContain("<CatalogListPage");
expect(source).toContain("catalogListMetadata(");
// spec 0028, AC-13: a games list is `noindex` and never reads `?page`.
expect(source).toContain(
mediaType === "game" ? "gameListMetadata(LIST)" : "catalogListMetadata(",
);
});
});
12 changes: 6 additions & 6 deletions app/games/[id]/not-found.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { describe, expect, it } from "vitest";
import GameNotFound from "./not-found";

/**
* covers: spec 0025, AC-7
* covers: spec 0025, AC-7, as amended by spec 0028, AC-17
*
* The soft 404 for an unknown or hidden game. Its one action goes home,
* because `/games` does not exist until G6.
* The soft 404 for an unknown or hidden game. Its one action leads to the
* games landing.
*/
describe("GameNotFound", () => {
it("says the game is missing and offers a way home", () => {
it("says the game is missing and offers Browse games", () => {
render(<GameNotFound />);

expect(screen.getByText("We couldn't find that game")).toBeInTheDocument();
Expand All @@ -19,9 +19,9 @@ describe("GameNotFound", () => {
"IGDB has no game at this address. It may have been removed, or the link may be wrong.",
),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Go to home" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "Browse games" })).toHaveAttribute(
"href",
"/",
"/games",
);
});

Expand Down
7 changes: 4 additions & 3 deletions app/games/[id]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { StatePanel } from "@/components/state-panel";
import { ButtonLink } from "@/components/ui/button";
import { CATALOG_PATHS } from "@/lib/catalog/media-type";

/**
* A game id IGDB does not know, or one the catalog filter hides (spec 0025,
Expand All @@ -8,7 +9,7 @@ import { ButtonLink } from "@/components/ui/button";
* Rendered inside the page shell after it has streamed, so the status is 200:
* a soft 404, kept out of search results by `noindex`. A malformed id never
* gets here; `proxy.ts` answers it with a real 404 first (AC-6). The button
* goes home because `/games` does not exist until G6.
* leads to the games landing (spec 0028, AC-17).
*/
export default function GameNotFound() {
return (
Expand All @@ -18,8 +19,8 @@ export default function GameNotFound() {
title="We couldn't find that game"
description="IGDB has no game at this address. It may have been removed, or the link may be wrong."
action={
<ButtonLink size="touch" href="/">
Go to home
<ButtonLink size="touch" href={CATALOG_PATHS.game}>
Browse games
</ButtonLink>
}
/>
Expand Down
17 changes: 17 additions & 0 deletions app/games/coming-soon/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";

import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
import { gameListMetadata } from "@/lib/landing-metadata";

const LIST = catalogList("game", "coming-soon");

/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
export const metadata: Metadata = gameListMetadata(LIST);

/** One games list, paged by 24 (spec 0028, AC-9). */
export default function ComingSoonGamesPage({
searchParams,
}: PageProps<"/games/coming-soon">) {
return <CatalogListPage list={LIST} searchParams={searchParams} />;
}
23 changes: 23 additions & 0 deletions app/games/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Metadata } from "next";

import { CatalogLanding } from "@/components/catalog/catalog-landing";
import { gamesMetadata } from "@/lib/landing-metadata";

/**
* The games landing's metadata (spec 0028, AC-13): `noindex`, with no
* canonical or share card of its own, until G10 opens games to search.
*/
export const metadata: Metadata = gamesMetadata({
title: "Games",
description:
"Browse trending, popular, new, upcoming and top rated games on BeStats, and keep track of what you play.",
});

/**
* The games landing (spec 0028, AC-1): the same parallax landing as `/shows`
* and `/movies`, filled from IGDB. No session is read here; only the hero's
* status control reads it, in its own Suspense boundary (AC-19).
*/
export default function GamesPage() {
return <CatalogLanding heading="Games" mediaType="game" />;
}
17 changes: 17 additions & 0 deletions app/games/popular/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";

import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
import { gameListMetadata } from "@/lib/landing-metadata";

const LIST = catalogList("game", "popular");

/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
export const metadata: Metadata = gameListMetadata(LIST);

/** One games list, paged by 24 (spec 0028, AC-9). */
export default function PopularGamesPage({
searchParams,
}: PageProps<"/games/popular">) {
return <CatalogListPage list={LIST} searchParams={searchParams} />;
}
17 changes: 17 additions & 0 deletions app/games/recently-released/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";

import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
import { gameListMetadata } from "@/lib/landing-metadata";

const LIST = catalogList("game", "recently-released");

/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
export const metadata: Metadata = gameListMetadata(LIST);

/** One games list, paged by 24 (spec 0028, AC-9). */
export default function RecentlyReleasedGamesPage({
searchParams,
}: PageProps<"/games/recently-released">) {
return <CatalogListPage list={LIST} searchParams={searchParams} />;
}
55 changes: 55 additions & 0 deletions app/games/request-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

import { GAME_LIST_SLUGS } from "@/lib/catalog/lists";

/**
* covers: spec 0028, AC-18, AC-19
*
* `/games` and its list pages are public pages served from a prerendered
* shell. The only session read on them is the hero's `GameTrackingSlot`,
* inside its own Suspense boundary in `components/`, so no route file may
* reach for a request API or a Supabase client, and none may opt out of the
* static shell. `app/games/actions.ts` is a POST handler, not render code.
*/
const FORBIDDEN = [
"cookies(",
"headers(",
"draftMode(",
"@/lib/supabase",
"@/lib/auth/user",
];

const ACTIONS = join("app/games/actions.ts");

function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : [];
});
}

describe("the games routes read no request scoped value directly", () => {
const files = sourceFiles("app/games").filter((path) => path !== ACTIONS);
const pages = [
"app/games/page.tsx",
...GAME_LIST_SLUGS.map((slug) => join(`app/games/${slug}/page.tsx`)),
];

it("finds the landing and every list page", () => {
for (const page of pages) expect(files).toContain(page);
});

it.each(files)("%s reaches for none of them", (path) => {
const source = readFileSync(path, "utf8");
for (const api of FORBIDDEN) {
expect(source, `${path} must not use ${api}`).not.toContain(api);
}
});

it.each(pages)("%s keeps its static shell", (path) => {
expect(readFileSync(path, "utf8")).not.toMatch(/instant\s*=\s*false/);
});
});
17 changes: 17 additions & 0 deletions app/games/top-rated/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";

import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
import { gameListMetadata } from "@/lib/landing-metadata";

const LIST = catalogList("game", "top-rated");

/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
export const metadata: Metadata = gameListMetadata(LIST);

/** One games list, paged by 24 (spec 0028, AC-9). */
export default function TopRatedGamesPage({
searchParams,
}: PageProps<"/games/top-rated">) {
return <CatalogListPage list={LIST} searchParams={searchParams} />;
}
17 changes: 17 additions & 0 deletions app/games/trending/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";

import { CatalogListPage } from "@/components/catalog/catalog-list-page";
import { catalogList } from "@/lib/catalog/lists";
import { gameListMetadata } from "@/lib/landing-metadata";

const LIST = catalogList("game", "trending");

/** The list's title and note, `noindex` until G10 (spec 0028, AC-13). */
export const metadata: Metadata = gameListMetadata(LIST);

/** One games list, paged by 24 (spec 0028, AC-9). */
export default function TrendingGamesPage({
searchParams,
}: PageProps<"/games/trending">) {
return <CatalogListPage list={LIST} searchParams={searchParams} />;
}
93 changes: 92 additions & 1 deletion app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
UpcomingSkeleton,
WIDE_CARD_CLASS,
} from "@/components/catalog/landing/landing-skeletons";
import { WideCardView } from "@/components/catalog/landing/wide-card";
import {
ListEmptyPanel,
ListErrorPanel,
} from "@/components/catalog/list-panels";
import { GlassPill } from "@/components/glass-pill";
import { IconKey } from "@/components/layout/icon-key";
import { LibraryNavView } from "@/components/layout/library-nav";
Expand All @@ -31,7 +36,11 @@ import {
LegalList,
LegalSection,
} from "@/components/legal/legal-document";
import { DatedPill, DateTbaPill } from "@/components/library/dated-pill";
import {
DatedPill,
DateTbaPill,
ReleasePill,
} from "@/components/library/dated-pill";
import { PosterCard } from "@/components/poster-card";
import { PosterGrid } from "@/components/poster-grid";
import {
Expand Down Expand Up @@ -523,6 +532,88 @@ export default function ShowcasePage() {
</div>
</Section>

<Section
title="Games landing and lists"
note="/games and its list pages (spec 0028): a game card shows its cover, name and the IGDB community rating, with no personal badge or controls until G8; on Coming soon it prints the release day. A wide card shows the game's IGDB art, else its cover cropped to fill, else the missing artwork tile. A failed games section names IGDB, and a list page has its own retry and empty panels."
>
<div className="flex flex-col gap-10">
<ShowcaseLanding title="Game cards">
<div className="flex flex-wrap items-start gap-4">
<div className="w-40 md:w-60">
<PosterCard
title="A game with a cover"
posterUrl={LIGHT_POSTER}
href="/games/1942"
badge={<IgdbRatingBadge value={8.7} />}
/>
</div>
<div className="w-40 md:w-60">
<PosterCard
title="On Coming soon"
posterUrl={LIGHT_POSTER}
href="/games/1942"
badge={<IgdbRatingBadge value={8.7} />}
controls={
<ReleasePill
release={{
shortDate: "Nov 6",
fullDate: "November 6, 2026",
}}
/>
}
/>
</div>
</div>
</ShowcaseLanding>
<ShowcaseLanding title="Wide game cards">
<div className="flex flex-wrap items-start gap-4">
<WideCardView
item={{
title: "With IGDB art",
href: "/games/1942",
backdropUrl: LIGHT_POSTER,
posterUrl: null,
}}
label="Nov 6"
badge={<IgdbRatingBadge value={8.7} />}
/>
<WideCardView
item={{
title: "No art, cover cropped",
href: "/games/1942",
backdropUrl: null,
posterUrl: LIGHT_POSTER,
}}
label="Jan 8, 2027"
badge={<IgdbRatingBadge value={null} />}
/>
<WideCardView
item={{
title: "No art and no cover",
href: "/games/1942",
backdropUrl: null,
posterUrl: null,
}}
label="Feb 3"
badge={<IgdbRatingBadge value={7.2} />}
/>
</div>
</ShowcaseLanding>
<ShowcaseLanding title="A failed games section">
<CarouselRowError list={catalogList("game", "popular")} />
</ShowcaseLanding>
<ShowcaseLanding title="A games list page that failed">
<ListErrorPanel
list={catalogList("game", "top-rated")}
href="/games/top-rated"
/>
</ShowcaseLanding>
<ShowcaseLanding title="An empty games list page">
<ListEmptyPanel list={catalogList("game", "coming-soon")} />
</ShowcaseLanding>
</div>
</Section>

<Section
title="Loading"
note="Skeletons in the shape of what they replace. The pulse is cut entirely under prefers-reduced-motion."
Expand Down
Loading
Loading