diff --git a/AGENTS.md b/AGENTS.md
index fb2940d..680c73f 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. 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
@@ -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
diff --git a/app/catalog-lists.test.ts b/app/catalog-lists.test.ts
index c7e2658..455fb81 100644
--- a/app/catalog-lists.test.ts
+++ b/app/catalog-lists.test.ts
@@ -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
@@ -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());
@@ -31,6 +36,9 @@ describe.each([
const source = readFileSync(join(dir, slug, "page.tsx"), "utf8");
expect(source).toContain(`catalogList("${mediaType}", "${slug}")`);
expect(source).toContain(" {
- it("says the game is missing and offers a way home", () => {
+ it("says the game is missing and offers Browse games", () => {
render();
expect(screen.getByText("We couldn't find that game")).toBeInTheDocument();
@@ -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",
);
});
diff --git a/app/games/[id]/not-found.tsx b/app/games/[id]/not-found.tsx
index 0335086..3716534 100644
--- a/app/games/[id]/not-found.tsx
+++ b/app/games/[id]/not-found.tsx
@@ -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,
@@ -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 (
@@ -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={
-
- Go to home
+
+ Browse games
}
/>
diff --git a/app/games/coming-soon/page.tsx b/app/games/coming-soon/page.tsx
new file mode 100644
index 0000000..316eca0
--- /dev/null
+++ b/app/games/coming-soon/page.tsx
@@ -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 ;
+}
diff --git a/app/games/page.tsx b/app/games/page.tsx
new file mode 100644
index 0000000..fd6d1a3
--- /dev/null
+++ b/app/games/page.tsx
@@ -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 ;
+}
diff --git a/app/games/popular/page.tsx b/app/games/popular/page.tsx
new file mode 100644
index 0000000..b857afd
--- /dev/null
+++ b/app/games/popular/page.tsx
@@ -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 ;
+}
diff --git a/app/games/recently-released/page.tsx b/app/games/recently-released/page.tsx
new file mode 100644
index 0000000..d0d8a44
--- /dev/null
+++ b/app/games/recently-released/page.tsx
@@ -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 ;
+}
diff --git a/app/games/request-scope.test.ts b/app/games/request-scope.test.ts
new file mode 100644
index 0000000..79e3b62
--- /dev/null
+++ b/app/games/request-scope.test.ts
@@ -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/);
+ });
+});
diff --git a/app/games/top-rated/page.tsx b/app/games/top-rated/page.tsx
new file mode 100644
index 0000000..5d97701
--- /dev/null
+++ b/app/games/top-rated/page.tsx
@@ -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 ;
+}
diff --git a/app/games/trending/page.tsx b/app/games/trending/page.tsx
new file mode 100644
index 0000000..0075d64
--- /dev/null
+++ b/app/games/trending/page.tsx
@@ -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 ;
+}
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index fb264d4..ac81bc5 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -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";
@@ -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 {
@@ -523,6 +532,88 @@ export default function ShowcasePage() {
+
+
+
+
+
+ }
+ />
+
+
+ }
+ controls={
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
}
});
+ it("lists no games URL before G10 (spec 0028, AC-13)", async () => {
+ happyReads();
+
+ for (const url of urls(await sitemap())) {
+ expect(url.slice(ORIGIN.length)).not.toMatch(/^\/games(\/|$)/);
+ }
+ });
+
it("keeps a complete list for days (AC-8)", async () => {
happyReads();
diff --git a/components/AGENTS.md b/components/AGENTS.md
index 63d1da5..cdb777a 100644
--- a/components/AGENTS.md
+++ b/components/AGENTS.md
@@ -45,6 +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`).
- `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/carousel-row-states.tsx b/components/catalog/carousel-row-states.tsx
index d01d1c3..4ce0038 100644
--- a/components/catalog/carousel-row-states.tsx
+++ b/components/catalog/carousel-row-states.tsx
@@ -3,7 +3,7 @@ import type * as React from "react";
import { RetryLink } from "@/components/retry-link";
import { PosterCardSkeleton } from "@/components/skeleton";
import type { CatalogList } from "@/lib/catalog/lists";
-import { CATALOG_PATHS } from "@/lib/catalog/media-type";
+import { CATALOG_PATHS, CATALOG_SOURCES } from "@/lib/catalog/media-type";
/** How many titles a row holds: the first 12 of TMDB's page 1 (AC-3). */
const ROW_SIZE = 12;
@@ -14,14 +14,19 @@ const ROW_ITEM_CLASS = "w-[140px] shrink-0 snap-start md:w-[180px]";
/** The `sizes` of a row card's poster, matching `ROW_ITEM_CLASS` (AC-5). */
const ROW_POSTER_SIZES = "(min-width: 768px) 180px, 140px";
-/** A row whose TMDB read failed: only this row, with Try again (AC-11). */
+/**
+ * A row whose read failed: only this row, with Try again (AC-11). A games
+ * row names IGDB, its source (spec 0028, AC-11).
+ */
function CarouselRowError({ list }: { list: CatalogList }) {
return (
);
diff --git a/components/catalog/catalog-landing.tsx b/components/catalog/catalog-landing.tsx
index b12cbea..bc5e3d9 100644
--- a/components/catalog/catalog-landing.tsx
+++ b/components/catalog/catalog-landing.tsx
@@ -6,13 +6,13 @@ import { TopRatedList } from "@/components/catalog/landing/top-rated-list";
import { TrendingPin } from "@/components/catalog/landing/trending-pin";
import { UpcomingRows } from "@/components/catalog/landing/upcoming-rows";
import { landingSlots } from "@/lib/catalog/landing";
-import type { MediaType } from "@/lib/catalog/media-type";
+import type { LibraryType } from "@/lib/catalog/media-type";
/**
- * A catalog landing, `/shows` or `/movies` (prompts/home-parallax-landing.md,
- * superseding the rows of spec 0021): the hero, then Trending, Popular, Now
- * playing (Airing today), Upcoming (On the air) and Top rated, each with its
- * own layout and motion.
+ * A catalog landing, `/shows`, `/movies` or `/games` (prompts/home-parallax-
+ * landing.md, superseding the rows of spec 0021; spec 0028, AC-1): the hero,
+ * then Trending, Popular, Now playing (Airing today), Upcoming (On the air) and
+ * Top rated, each with its own layout and motion.
*
* The h1 is for the outline and assistive technology only; the hero's title
* is the visible headline. Every heading, label and See all link is in the
@@ -24,7 +24,7 @@ function CatalogLanding({
mediaType,
}: {
heading: string;
- mediaType: MediaType;
+ mediaType: LibraryType;
}) {
const slots = landingSlots(mediaType);
diff --git a/components/catalog/catalog-list-cards.tsx b/components/catalog/catalog-list-cards.tsx
index 6e3a76b..8de2a9d 100644
--- a/components/catalog/catalog-list-cards.tsx
+++ b/components/catalog/catalog-list-cards.tsx
@@ -1,16 +1,23 @@
-import { MovieListCard, ShowListCard } from "@/components/catalog/list-cards";
+import {
+ GameListCard,
+ MovieListCard,
+ ShowListCard,
+} from "@/components/catalog/list-cards";
import type { TaggedCatalogPage } from "@/lib/catalog-list-read";
/**
* One `
` and full card per title of a list page, the one place a tagged
- * page becomes show or movie cards (spec 0021, AC-4). A landing row and a
- * list page grid both render through it and differ only in the props.
+ * page becomes show, movie or game cards (spec 0021, AC-4; spec 0028, AC-8). A
+ * landing row and a list page grid both render through it and differ only in
+ * the props.
*
* `ids` is every id the caller renders, so all the cards' slots share one
* read (prompts/catalog-card-status.md).
*
* @param limit How many titles to render; all of them when left out.
* @param eagerCount How many leading posters load eagerly (AC-7).
+ * @param releaseDay The request's UTC day, set on a games list whose cards
+ * print their release date (spec 0028, AC-8).
*/
function CatalogListCards({
result,
@@ -19,6 +26,7 @@ function CatalogListCards({
limit,
sizes,
itemClassName,
+ releaseDay,
}: {
result: TaggedCatalogPage;
returnPath: string;
@@ -26,6 +34,7 @@ function CatalogListCards({
limit?: number;
sizes?: string;
itemClassName?: string;
+ releaseDay?: string;
}) {
if (result.mediaType === "tv") {
const shows = result.paged.results.slice(0, limit);
@@ -42,6 +51,18 @@ function CatalogListCards({
+ ));
+ }
const movies = result.paged.results.slice(0, limit);
const ids = movies.map((movie) => movie.id);
return movies.map((movie, index) => (
diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx
index ac44248..7cf016c 100644
--- a/components/catalog/catalog-list-page.test.tsx
+++ b/components/catalog/catalog-list-page.test.tsx
@@ -17,10 +17,18 @@ import type { CatalogList } from "@/lib/catalog/lists";
*/
const read = vi.fn();
+vi.mock("@/lib/igdb", async () => ({
+ IgdbError: (await import("@/lib/igdb/errors")).IgdbError,
+ getGameGenres: async () => [],
+}));
vi.mock("@/lib/tmdb", async () => ({
TmdbError: (await import("@/lib/tmdb/errors")).TmdbError,
}));
-vi.mock("@/lib/catalog-list-read", () => ({
+vi.mock("@/lib/tracking/episode-state", () => ({
+ requestTodayUtc: () => "2026-10-18",
+}));
+vi.mock("@/lib/catalog-list-read", async (importActual) => ({
+ ...(await importActual()),
readTaggedCatalogGridPage: (...args: unknown[]) => read(...args),
}));
vi.mock("@/components/catalog/list-cards", () => ({
@@ -38,6 +46,18 @@ vi.mock("@/components/catalog/list-cards", () => ({
{props.show.name}
),
+ GameListCard: (props: {
+ game: { name: string };
+ priority: boolean;
+ releaseDay?: string;
+ }) => (
+
+ {props.game.name}
+
+ ),
MovieListCard: (props: {
movie: { title: string };
priority: boolean;
@@ -50,6 +70,7 @@ vi.mock("@/components/catalog/list-cards", () => ({
}));
const { TmdbError } = await import("@/lib/tmdb/errors");
+const { IgdbError, IgdbValidationError } = await import("@/lib/igdb/errors");
const { catalogList } = await import("@/lib/catalog/lists");
const { CatalogListPage } = await import("./catalog-list-page");
@@ -348,3 +369,121 @@ describe("the list page states (AC-10)", () => {
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});
+
+/** covers: spec 0028, AC-9 to AC-11 */
+describe("a games list page", () => {
+ const COMING_SOON = catalogList("game", "coming-soon");
+
+ function games(count: number) {
+ return Array.from({ length: count }, (_, index) => ({
+ id: index + 1,
+ name: `Game ${index + 1}`,
+ }));
+ }
+
+ function gamePage(
+ count: number,
+ totalResults: number | null,
+ hasNextPage = false,
+ ) {
+ return {
+ mediaType: "game",
+ paged: { page: 1, results: games(count), hasNextPage, totalResults },
+ };
+ }
+
+ it("links back to Games above its h1 and note", () => {
+ const header = pageTree(COMING_SOON).find(
+ (child) => child?.type === "header",
+ );
+ if (!header) throw new Error("the list page has no header");
+ render(header);
+
+ expect(screen.getByRole("link", { name: "Games" })).toHaveAttribute(
+ "href",
+ "/games",
+ );
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Games coming soon" }),
+ ).toBeInTheDocument();
+ });
+
+ it("counts pages from IGDB's count, 24 a page", async () => {
+ read.mockResolvedValue(gamePage(24, 50));
+ await renderGrid(COMING_SOON);
+
+ expect(screen.getAllByRole("listitem")).toHaveLength(24);
+ expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /Next/ })).toHaveAttribute(
+ "href",
+ "/games/coming-soon?page=2",
+ );
+ });
+
+ it("dates Coming soon's cards with the request's day, and no other list's", async () => {
+ read.mockResolvedValue(gamePage(2, 2));
+ await renderGrid(COMING_SOON);
+ expect(screen.getByText("Game 1")).toHaveAttribute(
+ "data-release-day",
+ "2026-10-18",
+ );
+ document.body.innerHTML = "";
+
+ await renderGrid(catalogList("game", "top-rated"));
+ expect(screen.getByText("Game 1")).toHaveAttribute(
+ "data-release-day",
+ "none",
+ );
+ });
+
+ it("caps the count at IGDB's 50 pages", async () => {
+ read.mockResolvedValue(gamePage(24, 5000));
+ await renderGrid(COMING_SOON);
+
+ expect(screen.getByText("Page 1 of 50")).toBeInTheDocument();
+ });
+
+ it("says only Page N with no count, and offers Next while IGDB has more", async () => {
+ read.mockResolvedValue(gamePage(24, null, true));
+ await renderGrid(COMING_SOON);
+
+ expect(screen.getByText("Page 1")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /Next/ })).toBeInTheDocument();
+ });
+
+ it("shows no such page past the last page", async () => {
+ read.mockResolvedValue(gamePage(0, 30));
+ await renderGrid(COMING_SOON, { page: "3" });
+
+ expect(
+ screen.getByText("There are no upcoming games at this page number."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the empty panel for an empty page 1", async () => {
+ read.mockResolvedValue(gamePage(0, 0));
+ await renderGrid(COMING_SOON);
+
+ expect(
+ screen.getByText("IGDB lists no upcoming games right now."),
+ ).toBeInTheDocument();
+ });
+
+ it("answers an IGDB failure with its retry panel", async () => {
+ read.mockRejectedValue(new IgdbError("timeout", "games", "slow"));
+ await renderGrid(COMING_SOON, { page: "2" });
+
+ expect(screen.getByText("Couldn't reach IGDB")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /Try again/ })).toHaveAttribute(
+ "href",
+ "/games/coming-soon?page=2",
+ );
+ });
+
+ it("rethrows a validation error, which is a bug", async () => {
+ read.mockRejectedValue(new IgdbValidationError("page", "bad"));
+ await expect(renderGrid(COMING_SOON)).rejects.toBeInstanceOf(
+ IgdbValidationError,
+ );
+ });
+});
diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx
index 9ee7700..74b39d1 100644
--- a/components/catalog/catalog-list-page.tsx
+++ b/components/catalog/catalog-list-page.tsx
@@ -3,9 +3,12 @@ import Link from "next/link";
import { Suspense } from "react";
import { CatalogListCards } from "@/components/catalog/catalog-list-cards";
+import {
+ ListEmptyPanel,
+ ListErrorPanel,
+} from "@/components/catalog/list-panels";
import { PaginationLinks } from "@/components/pagination-links";
import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid";
-import { RetryLink } from "@/components/retry-link";
import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
import { ButtonLink } from "@/components/ui/button";
@@ -13,20 +16,27 @@ import { GRID_PAGE_SIZE } from "@/lib/catalog/grid-page";
import { type CatalogList, listPageHref, listPath } from "@/lib/catalog/lists";
import { CATALOG_PATHS } from "@/lib/catalog/media-type";
import { parsePageParam } from "@/lib/catalog/pages";
+import { isCatalogError } from "@/lib/catalog-errors";
import {
+ lastListPage,
+ listHasNextPage,
readTaggedCatalogGridPage,
type TaggedCatalogPage,
} from "@/lib/catalog-list-read";
-import { TmdbError } from "@/lib/tmdb";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
-const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const;
+const LANDING_LABELS = {
+ tv: "Shows",
+ movie: "Movies",
+ game: "Games",
+} as const satisfies Record;
type SearchParams = Promise>;
/**
- * One list's See all page, `/shows/{slug}` or `/movies/{slug}` (spec 0021,
- * AC-9, AC-10): a back link to the landing, the list's h1 and note, then the
- * paged grid the landing used to be.
+ * One list's See all page, `/shows/{slug}`, `/movies/{slug}` or `/games/{slug}`
+ * (spec 0021, AC-9, AC-10; spec 0028, AC-9 to AC-11): a back link to the
+ * landing, the list's h1 and note, then the paged grid the landing used to be.
*
* The header is static and prerendered; the grid needs the `page` search
* parameter, so it streams behind a skeleton of the same footprint. No
@@ -81,15 +91,10 @@ async function ListGrid({
try {
result = await readTaggedCatalogGridPage(list, page);
} catch (error) {
- if (!(error instanceof TmdbError)) throw error;
+ if (!isCatalogError(error)) throw error;
return (
- }
- />
+
);
}
@@ -97,17 +102,16 @@ async function ListGrid({
if (page === 1 && result.paged.results.length === 0) {
return (
-
+
);
}
- const lastPage = result.paged.totalPages;
- if (page > lastPage) return ;
+ // A games list with no count knows its end only by an empty page.
+ const lastPage = lastListPage(result);
+ if (result.paged.results.length === 0) return ;
+ if (lastPage !== null && page > lastPage) return ;
+ const hasNextPage = listHasNextPage(result, page);
return (
);
}
diff --git a/components/catalog/landing/landing-badge.test.tsx b/components/catalog/landing/landing-badge.test.tsx
index ad7b9a6..cb4f16a 100644
--- a/components/catalog/landing/landing-badge.test.tsx
+++ b/components/catalog/landing/landing-badge.test.tsx
@@ -3,7 +3,7 @@ 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
+ * prompts/landing-review-fixes.md, decisions 3 and 7; spec 0028, AC-7, AC-8
*
* `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
@@ -26,8 +26,17 @@ vi.mock("@/components/tracking/movie-tracking-slot", () => ({
vi.mock("@/components/tracking/show-tracking-slot", () => ({
ShowTrackingSlot: () => null,
}));
+vi.mock("@/components/tracking/game-tracking-slot", () => ({
+ GameTrackingSlot: () => null,
+}));
-const { LandingControls, LandingTracking } = await import("./landing-badge");
+const { LandingBadge, LandingControls, LandingTracking } = await import(
+ "./landing-badge"
+);
+const { GameTrackingSlot } = await import(
+ "@/components/tracking/game-tracking-slot"
+);
+const { IgdbRatingBadge } = await import("@/components/rating-badges");
const { MovieTrackingSlot } = await import(
"@/components/tracking/movie-tracking-slot"
);
@@ -46,12 +55,13 @@ const BASE = {
href: "/movies/1",
posterUrl: null,
backdropUrl: null,
- tmdbRating: 8,
+ rating: { source: "tmdb" as const, value: 8 },
year: 2026,
genreIds: [],
};
-function slot(element: React.ReactElement) {
+function slot(element: React.ReactElement | null) {
+ if (element === null) throw new Error("expected an element");
const { children } = element.props as { children: React.ReactElement };
return children;
}
@@ -99,6 +109,43 @@ 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.
+describe("a game", () => {
+ const GAME = {
+ ...BASE,
+ rating: { source: "igdb" as const, value: 8.4 },
+ mediaType: "game" as const,
+ id: 1942,
+ href: "/games/1942",
+ releaseDate: "2015-05-19",
+ };
+
+ 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,
+ );
+ expect(inner.type).toBe(IgdbRatingBadge);
+ expect(inner.props).toEqual({ value: 8.4 });
+ });
+
+ it("has no controls", () => {
+ expect(LandingControls({ item: GAME, ids: [1942] })).toBeNull();
+ });
+
+ it("gets the game status control in the hero", () => {
+ const child = slot(LandingTracking({ item: GAME }));
+ expect(child.type).toBe(GameTrackingSlot);
+ expect(child.props).toEqual({
+ gameId: 1942,
+ name: "Dune",
+ releaseDate: "2015-05-19",
+ });
+ });
+});
+
describe("LandingTracking", () => {
it("renders a movie's tracking slot with its release date", () => {
const child = slot(
diff --git a/components/catalog/landing/landing-badge.tsx b/components/catalog/landing/landing-badge.tsx
index 17cf3fa..1c0fd7e 100644
--- a/components/catalog/landing/landing-badge.tsx
+++ b/components/catalog/landing/landing-badge.tsx
@@ -1,7 +1,8 @@
import type * as React from "react";
import { Suspense } from "react";
-import { TmdbRatingBadge } from "@/components/rating-badges";
+import { IgdbRatingBadge, TmdbRatingBadge } from "@/components/rating-badges";
+import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";
import {
MovieCardBadge,
MovieCardControls,
@@ -12,7 +13,7 @@ import {
ShowCardControls,
} from "@/components/tracking/show-card-slots";
import { ShowTrackingSlot } from "@/components/tracking/show-tracking-slot";
-import { CATALOG_PATHS, type MediaType } from "@/lib/catalog/media-type";
+import { CATALOG_PATHS, type LibraryType } from "@/lib/catalog/media-type";
import type { LandingItem } from "./landing-items";
@@ -22,17 +23,16 @@ 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 or movie slot themselves.
+ * 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).
*/
const LANDING_SLOTS: Record<
- MediaType,
+ LibraryType,
{
- badge: (item: LandingItem, ids: Ids) => React.ReactNode;
- controls: (
- item: LandingItem,
- ids: Ids,
- releasePill: boolean,
- ) => React.ReactNode;
+ badge: ((item: LandingItem, ids: Ids) => React.ReactNode) | null;
+ controls:
+ | ((item: LandingItem, ids: Ids, releasePill: boolean) => React.ReactNode)
+ | null;
tracking: (item: LandingItem) => React.ReactNode;
}
> = {
@@ -40,7 +40,7 @@ const LANDING_SLOTS: Record<
badge: (item, ids) => (
),
@@ -61,7 +61,7 @@ const LANDING_SLOTS: Record<
),
@@ -83,21 +83,44 @@ const LANDING_SLOTS: Record<
/>
),
},
+ game: {
+ badge: null,
+ controls: null,
+ tracking: (item) => (
+
+ ),
+ },
};
+/**
+ * A title's community rating, labelled with the catalog it came from: TMDB's
+ * for a show or movie, IGDB's for a game (spec 0028; AGENTS.md section 9).
+ */
+function CommunityRatingBadge({ rating }: { rating: LandingItem["rating"] }) {
+ return rating.source === "igdb" ? (
+
+ ) : (
+
+ );
+}
+
/**
* A landing card's badge (prompts/home-parallax-landing.md, decision 5): the
* 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.
+ * 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).
*/
function LandingBadge({ item, ids }: { item: LandingItem; ids: Ids }) {
- return (
- }>
- {LANDING_SLOTS[item.mediaType].badge(item, ids)}
-
- );
+ const badge = LANDING_SLOTS[item.mediaType].badge;
+ const rating = ;
+ if (badge === null) return rating;
+ return {badge(item, ids)};
}
/**
@@ -107,7 +130,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).
+ * 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).
*/
function LandingControls({
item,
@@ -118,17 +142,18 @@ function LandingControls({
ids: Ids;
releasePill?: boolean;
}) {
+ const controls = LANDING_SLOTS[item.mediaType].controls;
+ if (controls === null) return null;
return (
-
- {LANDING_SLOTS[item.mediaType].controls(item, ids, releasePill)}
-
+ {controls(item, ids, releasePill)}
);
}
/**
* The hero's tracking control (decision 4): the title page's own slot, which
* handles a signed out visitor and the release gate, in a Suspense with no
- * fallback so the hero never waits on the session.
+ * fallback so the hero never waits on the session. A game gets the game
+ * page's status control, with its release gate (spec 0028, AC-7).
*/
function LandingTracking({ item }: { item: LandingItem }) {
return (
@@ -138,4 +163,4 @@ function LandingTracking({ item }: { item: LandingItem }) {
);
}
-export { LandingBadge, LandingControls, LandingTracking };
+export { CommunityRatingBadge, LandingBadge, LandingControls, LandingTracking };
diff --git a/components/catalog/landing/landing-hero.test.tsx b/components/catalog/landing/landing-hero.test.tsx
index 24135a4..9316fa2 100644
--- a/components/catalog/landing/landing-hero.test.tsx
+++ b/components/catalog/landing/landing-hero.test.tsx
@@ -13,8 +13,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const read = vi.fn();
const movieGenres = vi.fn();
const tvGenres = vi.fn();
+const gameGenres = vi.fn();
vi.mock("next/server", () => ({ connection: async () => {} }));
+vi.mock("@/lib/igdb", async () => ({
+ IgdbError: (await import("@/lib/igdb/errors")).IgdbError,
+ getGameGenres: () => gameGenres(),
+}));
vi.mock("@/lib/tmdb", async () => ({
TmdbError: (await import("@/lib/tmdb/errors")).TmdbError,
getMovieGenres: () => movieGenres(),
@@ -42,6 +47,11 @@ vi.mock("@/components/tracking/movie-tracking-slot", () => ({
{`movie/${movieId}`}
),
}));
+vi.mock("@/components/tracking/game-tracking-slot", () => ({
+ GameTrackingSlot: ({ gameId }: { gameId: number }) => (
+ {`game/${gameId}`}
+ ),
+}));
vi.mock("@/components/tracking/show-tracking-slot", () => ({
ShowTrackingSlot: ({ showId }: { showId: number }) => (
{`tv/${showId}`}
@@ -49,6 +59,7 @@ vi.mock("@/components/tracking/show-tracking-slot", () => ({
}));
const { TmdbError } = await import("@/lib/tmdb/errors");
+const { IgdbError } = await import("@/lib/igdb/errors");
const { landingSlots } = await import("@/lib/catalog/landing");
const { LandingHeroContent } = await import("./landing-hero");
@@ -81,6 +92,7 @@ afterEach(() => {
read.mockReset();
movieGenres.mockReset();
tvGenres.mockReset();
+ gameGenres.mockReset();
});
describe("LandingHeroContent (AC-3)", () => {
@@ -179,3 +191,71 @@ describe("LandingHeroContent's failure states (AC-6)", () => {
).toBeNull();
});
});
+
+/** covers: spec 0028, AC-7 */
+describe("the games hero", () => {
+ const GAME = {
+ id: 1942,
+ name: "The Witcher 3: Wild Hunt",
+ coverUrl:
+ "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co1.jpg",
+ artUrl: "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg",
+ releaseDate: "2015-05-19",
+ releaseYear: 2015,
+ communityRating: 9.3,
+ communityRatingCount: 4000,
+ genreIds: [12, 31],
+ platformIds: [],
+ hidden: false,
+ };
+
+ function gamePage(results: unknown[]) {
+ return {
+ mediaType: "game",
+ paged: { results, page: 1, hasNextPage: false, totalResults: 1 },
+ };
+ }
+
+ it("features the game over its art, with IGDB's rating, genres and the status control", async () => {
+ read.mockResolvedValue(gamePage([GAME]));
+ gameGenres.mockResolvedValue([
+ { id: 12, name: "Role-playing (RPG)" },
+ { id: 31, name: "Adventure" },
+ ]);
+
+ const { container } = render(
+ await LandingHeroContent({ list: landingSlots("game").trending }),
+ );
+
+ expect(screen.getByRole("link", { name: GAME.name })).toHaveAttribute(
+ "href",
+ "/games/1942",
+ );
+ expect(screen.getByText("IGDB community rating")).toBeInTheDocument();
+ expect(screen.getByText("9.3")).toBeInTheDocument();
+ expect(
+ screen.getByText("Role-playing (RPG), Adventure"),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("trailer")).not.toBeInTheDocument();
+ expect(screen.getByTestId("tracking")).toHaveTextContent("game/1942");
+ expect(
+ decodeURIComponent(
+ container.querySelector("img")?.getAttribute("src") ?? "",
+ ),
+ ).toContain("t_1080p/ar1.jpg");
+ });
+
+ it("falls back to the cover, and leaves genres out when IGDB fails them", async () => {
+ read.mockResolvedValue(gamePage([{ ...GAME, artUrl: null }]));
+ gameGenres.mockRejectedValue(new IgdbError("upstream", "genres", "503"));
+
+ const { container } = render(
+ await LandingHeroContent({ list: landingSlots("game").trending }),
+ );
+
+ expect(container.querySelector("img")?.getAttribute("src")).toContain(
+ "co1.jpg",
+ );
+ expect(screen.queryByText(/Adventure/)).not.toBeInTheDocument();
+ });
+});
diff --git a/components/catalog/landing/landing-hero.tsx b/components/catalog/landing/landing-hero.tsx
index 3cd123d..7b43dd2 100644
--- a/components/catalog/landing/landing-hero.tsx
+++ b/components/catalog/landing/landing-hero.tsx
@@ -5,18 +5,18 @@ import { Suspense } from "react";
import { TrailerSlot } from "@/components/catalog/title-extras";
import { GlassPill } from "@/components/glass-pill";
-import { TmdbRatingBadge } from "@/components/rating-badges";
import type { CatalogList } from "@/lib/catalog/lists";
-import type { MediaType } from "@/lib/catalog/media-type";
+import type { LibraryType } from "@/lib/catalog/media-type";
+import { isCatalogError } from "@/lib/catalog-errors";
+import { getGameGenres } from "@/lib/igdb";
import {
type Genre,
getMovieGenres,
getTvGenres,
resizeImageUrl,
- TmdbError,
} from "@/lib/tmdb";
import { ArtFallback } from "./art-fallback";
-import { LandingTracking } from "./landing-badge";
+import { CommunityRatingBadge, LandingTracking } from "./landing-badge";
import {
type LandingItem,
landingItems,
@@ -85,7 +85,7 @@ async function LandingHeroContent({ list }: { list: CatalogList }) {
#1 Trending
-
+
{genres.length > 0 ? {genres.join(", ")} : null}
-
+ {/* A game has no trailer until G9 (spec 0028, AC-7). */}
+ {item.mediaType === "game" ? null : (
+
+ )}
@@ -143,23 +146,28 @@ function HeroArtwork({ item }: { item: LandingItem }) {
);
}
-/** Each media type's cached genre list, read by the hero's genre line. */
-const GENRE_LISTS: Record Promise> = {
+/**
+ * Each media type's cached genre list, read by the hero's genre line. A game
+ * reads IGDB's genres (spec 0028, AC-7), whose `GameRef` has TMDB's
+ * `Genre` shape, `{ id, name }`.
+ */
+const GENRE_LISTS: Record Promise> = {
tv: getTvGenres,
movie: getMovieGenres,
+ game: getGameGenres,
};
/**
* The featured title's genre names, from the cached genre list its media
- * type has. A failed genre read leaves the genres out rather than failing
- * the hero.
+ * type has. A failed genre read, TMDB's or IGDB's, leaves the genres out
+ * rather than failing the hero.
*/
async function genreNames(item: LandingItem): Promise {
let genres: Genre[];
try {
genres = await GENRE_LISTS[item.mediaType]();
} catch (error) {
- if (!(error instanceof TmdbError)) throw error;
+ if (!isCatalogError(error)) throw error;
return [];
}
const names = new Map(genres.map((genre) => [genre.id, genre.name]));
diff --git a/components/catalog/landing/landing-items.test.ts b/components/catalog/landing/landing-items.test.ts
new file mode 100644
index 0000000..beabfac
--- /dev/null
+++ b/components/catalog/landing/landing-items.test.ts
@@ -0,0 +1,244 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: spec 0028, AC-3, AC-6, AC-8, AC-11
+ *
+ * The one place a tagged list page becomes the landing's `LandingItem`s, and
+ * the read every landing section shares. A game's cover is its poster, its
+ * IGDB art its backdrop and its rating IGDB's; a failed TMDB or IGDB read is
+ * one section's null, and anything else is a bug that surfaces. The list read
+ * is replaced; both error classes are the real ones.
+ */
+const read = vi.fn();
+
+vi.mock("next/server", () => ({ connection: async () => {} }));
+vi.mock("@/lib/igdb", async () => ({
+ IgdbError: (await import("@/lib/igdb/errors")).IgdbError,
+}));
+vi.mock("@/lib/tmdb", async () => ({
+ TmdbError: (await import("@/lib/tmdb/errors")).TmdbError,
+}));
+vi.mock("@/lib/catalog-list-read", () => ({
+ readTaggedCatalogList: (...args: unknown[]) => read(...args),
+}));
+
+const { IgdbError, IgdbValidationError } = await import("@/lib/igdb/errors");
+const { TmdbError } = await import("@/lib/tmdb/errors");
+const { catalogList } = await import("@/lib/catalog/lists");
+const { itemIds, landingItems, newLandingItems, readLandingList } =
+ await import("./landing-items");
+
+type Page = Parameters[0];
+
+function game(id: number, extra: Record = {}) {
+ return {
+ id,
+ name: `Game ${id}`,
+ coverUrl: `https://images.igdb.com/igdb/image/upload/t_cover_big/c${id}.jpg`,
+ artUrl: `https://images.igdb.com/igdb/image/upload/t_1080p/a${id}.jpg`,
+ releaseDate: "2026-11-19",
+ releaseYear: 2026,
+ communityRating: 8.4,
+ communityRatingCount: 250,
+ genreIds: [12, 31],
+ platformIds: [6],
+ hidden: false,
+ ...extra,
+ };
+}
+
+function gamePage(results: unknown[]): Page {
+ return {
+ mediaType: "game",
+ paged: {
+ page: 1,
+ results,
+ hasNextPage: false,
+ totalResults: results.length,
+ },
+ } as Page;
+}
+
+function moviePage(results: unknown[]): Page {
+ return {
+ mediaType: "movie",
+ paged: { page: 1, results, totalPages: 1, totalResults: results.length },
+ } as Page;
+}
+
+function tvPage(results: unknown[]): Page {
+ return {
+ mediaType: "tv",
+ paged: { page: 1, results, totalPages: 1, totalResults: results.length },
+ } as Page;
+}
+
+afterEach(() => read.mockReset());
+
+describe("landingItems", () => {
+ it("draws a game from its cover, its IGDB art and IGDB's rating (AC-6, AC-8)", () => {
+ const [item] = landingItems(gamePage([game(1942)]), 0, 1);
+
+ expect(item).toEqual({
+ mediaType: "game",
+ id: 1942,
+ title: "Game 1942",
+ href: "/games/1942",
+ posterUrl: game(1942).coverUrl,
+ backdropUrl: game(1942).artUrl,
+ rating: { source: "igdb", value: 8.4 },
+ releaseDate: "2026-11-19",
+ year: 2026,
+ genreIds: [12, 31],
+ });
+ });
+
+ it("keeps a game's missing art, date and rating as nulls, never invented", () => {
+ const [item] = landingItems(
+ gamePage([
+ game(7, {
+ coverUrl: null,
+ artUrl: null,
+ releaseDate: null,
+ releaseYear: null,
+ communityRating: null,
+ }),
+ ]),
+ 0,
+ 1,
+ );
+
+ expect(item).toMatchObject({
+ posterUrl: null,
+ backdropUrl: null,
+ releaseDate: null,
+ year: null,
+ rating: { source: "igdb", value: null },
+ });
+ });
+
+ it("labels a movie's rating as TMDB's and links it under /movies", () => {
+ const [item] = landingItems(
+ moviePage([
+ {
+ id: 550,
+ title: "Fight Club",
+ posterUrl: null,
+ backdropUrl: null,
+ releaseDate: "1999-10-15",
+ releaseYear: 1999,
+ tmdbRating: 8.4,
+ genreIds: [18],
+ },
+ ]),
+ 0,
+ 1,
+ );
+
+ expect(item).toMatchObject({
+ mediaType: "movie",
+ href: "/movies/550",
+ rating: { source: "tmdb", value: 8.4 },
+ releaseDate: "1999-10-15",
+ });
+ });
+
+ it("gives a show no release date and its first air year", () => {
+ const [item] = landingItems(
+ tvPage([
+ {
+ id: 1399,
+ name: "Game of Thrones",
+ posterUrl: null,
+ backdropUrl: null,
+ firstAirYear: 2011,
+ tmdbRating: 8.5,
+ genreIds: [],
+ },
+ ]),
+ 0,
+ 1,
+ );
+
+ expect(item).toMatchObject({
+ mediaType: "tv",
+ href: "/shows/1399",
+ releaseDate: null,
+ year: 2011,
+ rating: { source: "tmdb", value: 8.5 },
+ });
+ });
+
+ it("takes only the titles from start up to end, in IGDB's order", () => {
+ const page = gamePage([1, 2, 3, 4, 5].map((id) => game(id)));
+
+ expect(itemIds(landingItems(page, 1, 3))).toEqual([2, 3]);
+ });
+
+ it("returns nothing for an empty page", () => {
+ expect(landingItems(gamePage([]), 0, 9)).toEqual([]);
+ });
+});
+
+describe("newLandingItems", () => {
+ it("skips games already shown and still fills its count", () => {
+ const page = gamePage([1, 2, 3, 4, 5].map((id) => game(id)));
+
+ expect(itemIds(newLandingItems(page, [2, 3], 3))).toEqual([1, 4, 5]);
+ });
+
+ it("returns fewer than the count when the page runs out, never padding (AC-3)", () => {
+ const page = gamePage([1, 2].map((id) => game(id)));
+
+ expect(itemIds(newLandingItems(page, [], 10))).toEqual([1, 2]);
+ });
+});
+
+describe("readLandingList (AC-3, AC-11)", () => {
+ const TRENDING = catalogList("game", "trending");
+
+ it("reads the list's page, page 1 by default", async () => {
+ const page = gamePage([game(1)]);
+ read.mockResolvedValue(page);
+
+ await expect(readLandingList(TRENDING)).resolves.toBe(page);
+ expect(read).toHaveBeenCalledWith(TRENDING, 1);
+ });
+
+ it("asks for the page it is given", async () => {
+ read.mockResolvedValue(gamePage([]));
+
+ await readLandingList(TRENDING, 2);
+
+ expect(read).toHaveBeenCalledWith(TRENDING, 2);
+ });
+
+ it.each([
+ ["an IGDB failure", () => new IgdbError("timeout", "games", "timed out")],
+ [
+ "a TMDB failure",
+ () => new TmdbError("upstream", "/trending/movie/day", "down", 502),
+ ],
+ ])(
+ "answers %s with null, so only that section fails",
+ async (_name, make) => {
+ read.mockRejectedValue(make());
+
+ await expect(readLandingList(TRENDING)).resolves.toBeNull();
+ },
+ );
+
+ it("rethrows an IGDB validation error, a bug rather than an outage", async () => {
+ read.mockRejectedValue(new IgdbValidationError("page", "bad page"));
+
+ await expect(readLandingList(TRENDING)).rejects.toBeInstanceOf(
+ IgdbValidationError,
+ );
+ });
+
+ it("rethrows any other error", async () => {
+ read.mockRejectedValue(new TypeError("boom"));
+
+ await expect(readLandingList(TRENDING)).rejects.toThrow("boom");
+ });
+});
diff --git a/components/catalog/landing/landing-items.ts b/components/catalog/landing/landing-items.ts
index 83379c5..c525419 100644
--- a/components/catalog/landing/landing-items.ts
+++ b/components/catalog/landing/landing-items.ts
@@ -1,28 +1,37 @@
import { connection } from "next/server";
import type { CatalogList } from "@/lib/catalog/lists";
-import { type MediaType, titleHref } from "@/lib/catalog/media-type";
+import {
+ gameHref,
+ type LibraryType,
+ titleHref,
+} from "@/lib/catalog/media-type";
+import { isCatalogError } from "@/lib/catalog-errors";
import {
readTaggedCatalogList,
type TaggedCatalogPage,
} from "@/lib/catalog-list-read";
-import { TmdbError } from "@/lib/tmdb";
/**
* One title as every landing section draws it, whichever media type it is
* (prompts/home-parallax-landing.md). The sections render posters, wide
* cards and list rows from this alone, so none of them branches on the type
- * except to pick the status badge.
+ * except to pick the status badge. A game joins in spec 0028: its cover is
+ * the poster, its IGDB art the backdrop, and its rating is IGDB's.
*/
export type LandingItem = {
- mediaType: MediaType;
+ mediaType: LibraryType;
id: number;
title: string;
href: string;
posterUrl: string | null;
backdropUrl: string | null;
- tmdbRating: number | null;
- /** A movie's release date, for the card's release gate; null for TV. */
+ /** The community rating, always shown as its own catalog's (AGENTS.md §9). */
+ rating: { source: "tmdb" | "igdb"; value: number | null };
+ /**
+ * A movie's or game's release date, for the card's release gate and the
+ * Coming soon date; null for TV.
+ */
releaseDate: string | null;
/** A show's first air year, the TV Upcoming card's label (decision 12). */
year: number | null;
@@ -37,9 +46,12 @@ export type LandingItem = {
* for the Upcoming rows, whose rows 3 and 4 come from page 2
* (prompts/home-parallax-landing.md, decision 13, amended 2026-10-18).
*
- * @returns The page, or null when TMDB failed: the section shows its own
- * Try again and the rest of the landing renders (decision 16). Anything other
- * than a `TmdbError` is a bug and is rethrown.
+ * A games list reads the same 24 game page its list page shows, so See all
+ * costs nothing more either (spec 0028, AC-3).
+ *
+ * @returns The page, or null when TMDB or IGDB failed: the section shows its
+ * own Try again and the rest of the landing renders (decision 16). Anything
+ * else, an `IgdbValidationError` included, is a bug and is rethrown.
*/
export async function readLandingList(
list: CatalogList,
@@ -49,15 +61,15 @@ export async function readLandingList(
try {
return await readTaggedCatalogList(list, page);
} catch (error) {
- if (!(error instanceof TmdbError)) throw error;
+ if (!isCatalogError(error)) throw error;
return null;
}
}
/**
* The titles from `start` up to `end`, as `LandingItem`s. The one place a
- * TV result and a movie result are told apart, so every section draws both
- * catalogs from the same shape; the `href` comes from `titleHref`, the one
+ * TV, movie and game result are told apart, so every section draws all
+ * three from the same shape; the `href` comes from `titleHref`, the one
* title URL builder (prompts/landing-review-fixes.md, decision 1).
*/
export function landingItems(
@@ -73,12 +85,26 @@ export function landingItems(
href: titleHref("tv", show.id),
posterUrl: show.posterUrl,
backdropUrl: show.backdropUrl,
- tmdbRating: show.tmdbRating,
+ rating: { source: "tmdb", value: show.tmdbRating },
releaseDate: null,
year: show.firstAirYear,
genreIds: show.genreIds,
}));
}
+ if (page.mediaType === "game") {
+ return page.paged.results.slice(start, end).map((game) => ({
+ mediaType: "game",
+ id: game.id,
+ title: game.name,
+ href: gameHref(game.id),
+ posterUrl: game.coverUrl,
+ backdropUrl: game.artUrl,
+ rating: { source: "igdb", value: game.communityRating },
+ releaseDate: game.releaseDate,
+ year: game.releaseYear,
+ genreIds: game.genreIds,
+ }));
+ }
return page.paged.results.slice(start, end).map((movie) => ({
mediaType: "movie",
id: movie.id,
@@ -86,7 +112,7 @@ export function landingItems(
href: titleHref("movie", movie.id),
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
- tmdbRating: movie.tmdbRating,
+ rating: { source: "tmdb", value: movie.tmdbRating },
releaseDate: movie.releaseDate,
year: movie.releaseYear,
genreIds: movie.genreIds,
diff --git a/components/catalog/landing/landing-sections.test.tsx b/components/catalog/landing/landing-sections.test.tsx
index 6209635..4af2e4a 100644
--- a/components/catalog/landing/landing-sections.test.tsx
+++ b/components/catalog/landing/landing-sections.test.tsx
@@ -16,6 +16,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const read = vi.fn();
vi.mock("next/server", () => ({ connection: async () => {} }));
+vi.mock("@/lib/igdb", async () => ({
+ IgdbError: (await import("@/lib/igdb/errors")).IgdbError,
+ getGameGenres: async () => [],
+}));
vi.mock("@/lib/tmdb", async () => ({
TmdbError: (await import("@/lib/tmdb/errors")).TmdbError,
}));
@@ -49,6 +53,7 @@ vi.mock("./landing-badge", () => ({
}));
const { TmdbError } = await import("@/lib/tmdb/errors");
+const { IgdbError } = await import("@/lib/igdb/errors");
const { landingSlots } = await import("@/lib/catalog/landing");
const { TrendingItems } = await import("./trending-pin");
const { PopularItems } = await import("./popular-grid");
@@ -485,3 +490,101 @@ describe("the sidebar count (decision 12)", () => {
expect(await NowBlurb({ list: TV.now })).toBeNull();
});
});
+
+/** covers: spec 0028, AC-3, AC-8, AC-9, AC-11 */
+describe("the games landing sections", () => {
+ const GAMES = landingSlots("game");
+
+ function games(count: number, extra: Record = {}) {
+ return Array.from({ length: count }, (_, index) => ({
+ id: index + 1,
+ name: `Game ${index + 1}`,
+ coverUrl: `https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co${index + 1}.jpg`,
+ artUrl: `https://images.igdb.com/igdb/image/upload/t_1080p/ar${index + 1}.jpg`,
+ releaseDate: "2026-11-06",
+ releaseYear: 2026,
+ communityRating: 8.2,
+ communityRatingCount: 300,
+ genreIds: [],
+ platformIds: [],
+ hidden: false,
+ ...extra,
+ }));
+ }
+
+ function gamePage(results: unknown[]) {
+ return {
+ mediaType: "game",
+ paged: {
+ results,
+ page: 1,
+ hasNextPage: false,
+ totalResults: results.length,
+ },
+ };
+ }
+
+ it("draws each section's count from the 24 game page", async () => {
+ read.mockResolvedValue(gamePage(games(24)));
+
+ const { container } = render(await TopRatedItems({ list: GAMES.top }));
+
+ expect(titles(container)).toHaveLength(8);
+ expect(screen.getByRole("link", { name: "Game 1" })).toHaveAttribute(
+ "href",
+ "/games/1",
+ );
+ });
+
+ it("puts a game's art on its wide card and its UTC release day under it", async () => {
+ read.mockResolvedValue(
+ gamePage([
+ ...games(1),
+ ...games(1, { id: 2, name: "Later", releaseDate: "2027-01-08" }),
+ ]),
+ );
+
+ const { container } = render(await UpcomingItems({ list: GAMES.upcoming }));
+
+ expect(
+ decodeURIComponent(
+ container.querySelector("img")?.getAttribute("src") ?? "",
+ ),
+ ).toContain("t_1080p/ar1.jpg");
+ expect(screen.getByText("Nov 6")).toBeInTheDocument();
+ expect(screen.getByText("Jan 8, 2027")).toBeInTheDocument();
+ });
+
+ it("falls back to the cover on a wide card with no art", async () => {
+ read.mockResolvedValue(gamePage(games(1, { artUrl: null })));
+
+ const { container } = render(await UpcomingItems({ list: GAMES.upcoming }));
+
+ expect(container.querySelector("img")?.getAttribute("src")).toContain(
+ "co1.jpg",
+ );
+ });
+
+ it("counts the anticipated games in the sidebar line", async () => {
+ read.mockResolvedValue(gamePage(games(24)));
+
+ render((await NowBlurb({ list: GAMES.now })) as React.ReactElement);
+
+ expect(
+ screen.getByText("The 12 most anticipated games of the last 60 days."),
+ ).toBeInTheDocument();
+ });
+
+ it("names IGDB when a section's read fails, and offers Try again", async () => {
+ read.mockRejectedValue(new IgdbError("timeout", "games", "slow"));
+
+ render(await PopularItems({ list: GAMES.popular }));
+
+ expect(screen.getByText("Couldn't load Popular")).toBeInTheDocument();
+ expect(screen.getByText("IGDB didn't respond.")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /Try again/ })).toHaveAttribute(
+ "href",
+ "/games",
+ );
+ });
+});
diff --git a/components/catalog/landing/upcoming-rows.tsx b/components/catalog/landing/upcoming-rows.tsx
index 33c0e21..ef06caa 100644
--- a/components/catalog/landing/upcoming-rows.tsx
+++ b/components/catalog/landing/upcoming-rows.tsx
@@ -1,12 +1,9 @@
-import Image from "next/image";
-import Link from "next/link";
import { Suspense } from "react";
import { LANDING_COUNTS } from "@/lib/catalog/landing";
import type { CatalogList } from "@/lib/catalog/lists";
import { formatShortDate } from "@/lib/format";
import { requestTodayUtc } from "@/lib/tracking/episode-state";
-import { ArtFallback } from "./art-fallback";
import { LandingBadge, LandingControls } from "./landing-badge";
import {
itemIds,
@@ -15,9 +12,8 @@ import {
readLandingList,
} from "./landing-items";
import { headingId, readLandingSection, SectionHeader } from "./landing-parts";
-import { UpcomingSkeleton, WIDE_CARD_CLASS } from "./landing-skeletons";
-
-const CARD_SIZES = "(min-width: 768px) 340px, 260px";
+import { UpcomingSkeleton } from "./landing-skeletons";
+import { WideCardView } from "./wide-card";
/**
* A row's frame: out to the column's edges, scrolling natively on a phone
@@ -108,9 +104,9 @@ function toRows(items: readonly LandingItem[], size: number): LandingItem[][] {
}
/**
- * The card's date: a movie's release day in UTC, with the year only outside
- * this one, or a show's first air year, since a TV list result carries no
- * next air date (decision 12).
+ * The card's date: a movie's or game's release day in UTC, with the year only
+ * outside this one, or a show's first air year, since a TV list result
+ * carries no next air date (decision 12; spec 0028, AC-8).
*/
function dateLabel(item: LandingItem, today: string): string | null {
if (item.mediaType === "tv") {
@@ -120,12 +116,9 @@ function dateLabel(item: LandingItem, today: string): string | null {
}
/**
- * A wide card: the backdrop, or the poster cropped to fill, or the flat
- * missing image plate (decision 10), with the title and date over a shade at
- * the bottom. The whole card is the title's link; the badge and, top left,
- * the controls sit above it (prompts/landing-card-controls.md). The control
- * row lets clicks through between its children, as `PosterCard`'s does, and
- * leaves out the release pill: the date at the bottom already says it
+ * A wide card with the section's badge and controls, the personal slots of
+ * `landing-badge.tsx` in `WideCardView`'s two corners. The release pill is
+ * left out: the date at the bottom already says it
* (prompts/landing-review-fixes.md, decision 3).
*/
function WideCard({
@@ -137,51 +130,13 @@ function WideCard({
ids: readonly number[];
label: string | null;
}) {
- const src = item.backdropUrl ?? item.posterUrl;
return (
-
- {src ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {/* `empty:hidden`: a visitor's controls render nothing, so no plate. */}
-
-
-
-
-
-
-
- {item.title}
-
-
- {label ? (
-
- {label}
-
- ) : null}
-
-
+ }
+ controls={}
+ />
);
}
diff --git a/components/catalog/landing/wide-card.tsx b/components/catalog/landing/wide-card.tsx
new file mode 100644
index 0000000..e5569a5
--- /dev/null
+++ b/components/catalog/landing/wide-card.tsx
@@ -0,0 +1,81 @@
+import Image from "next/image";
+import Link from "next/link";
+import type * as React from "react";
+
+import { ArtFallback } from "./art-fallback";
+import type { LandingItem } from "./landing-items";
+import { WIDE_CARD_CLASS } from "./landing-skeletons";
+
+const CARD_SIZES = "(min-width: 768px) 340px, 260px";
+
+/**
+ * A wide card: the backdrop (a game's IGDB art), or the poster cropped to
+ * fill, or the flat missing image plate (decision 10; spec 0028, AC-8), with
+ * the title and date over a shade at the bottom. The whole card is the
+ * title's link; the badge and, top left, the controls sit above it
+ * (prompts/landing-card-controls.md). The control row lets clicks through
+ * between its children, as `PosterCard`'s does.
+ *
+ * Kept apart from the slots that read the session, so `/showcase` can draw
+ * it with a plain badge.
+ */
+function WideCardView({
+ item,
+ label,
+ badge,
+ controls,
+}: {
+ item: Pick;
+ label: string | null;
+ badge: React.ReactNode;
+ controls?: React.ReactNode;
+}) {
+ const src = item.backdropUrl ?? item.posterUrl;
+ return (
+
+ {src ? (
+
+ ) : (
+
+ )}
+
+
+
+ {badge}
+
+ {/* `empty:hidden`: a visitor's controls render nothing, so no plate. */}
+
+ {controls}
+
+
+
+
+
+ {item.title}
+
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+ );
+}
+
+export { WideCardView };
diff --git a/components/catalog/list-cards.test.tsx b/components/catalog/list-cards.test.tsx
index 8417d96..fbe4c7f 100644
--- a/components/catalog/list-cards.test.tsx
+++ b/components/catalog/list-cards.test.tsx
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
- * covers: spec 0021, AC-4, AC-5
+ * covers: spec 0021, AC-4, AC-5; spec 0028, AC-8
*
* 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
@@ -68,7 +68,9 @@ vi.mock("@/components/tracking/movie-card-slots", () => ({
),
}));
-const { MovieListCard, ShowListCard } = await import("./list-cards");
+const { GameListCard, MovieListCard, ShowListCard } = await import(
+ "./list-cards"
+);
const { ROW_POSTER_SIZES } = await import("./carousel-row-states");
const { POSTER_SIZES } = await import("@/components/poster-card");
@@ -87,6 +89,14 @@ const MOVIE = {
tmdbRating: 8.4,
} as Parameters[0]["movie"];
+const GAME = {
+ id: 1942,
+ name: "The Witcher 3",
+ coverUrl: null,
+ communityRating: 9.3,
+ releaseDate: "2026-11-06",
+} as Parameters[0]["game"];
+
afterEach(() => {
document.body.innerHTML = "";
});
@@ -243,3 +253,44 @@ describe("MovieListCard (AC-4)", () => {
expect(screen.queryByTestId("meta")).not.toBeInTheDocument();
});
});
+
+describe("GameListCard (spec 0028, AC-8)", () => {
+ it("links the card to the game page with IGDB's rating", () => {
+ render();
+
+ expect(screen.getByRole("link", { name: "The Witcher 3" })).toHaveAttribute(
+ "href",
+ "/games/1942",
+ );
+ expect(screen.getByText("9.3")).toBeInTheDocument();
+ });
+
+ it("prints no date unless the list dates its cards", () => {
+ render();
+
+ expect(
+ document.querySelector('[data-slot="release-date"]'),
+ ).not.toBeInTheDocument();
+ });
+
+ it("prints the release day, with the year only outside this one", () => {
+ const { unmount } = render(
+ ,
+ );
+ expect(
+ document.querySelector('[data-slot="release-date"]'),
+ ).toHaveTextContent("Nov 6");
+ unmount();
+
+ render(
+ ,
+ );
+ expect(
+ document.querySelector('[data-slot="release-date"]'),
+ ).toHaveTextContent("Jan 8, 2027");
+ });
+});
diff --git a/components/catalog/list-cards.tsx b/components/catalog/list-cards.tsx
index 925b038..2318571 100644
--- a/components/catalog/list-cards.tsx
+++ b/components/catalog/list-cards.tsx
@@ -1,7 +1,8 @@
import { Suspense } from "react";
+import { ReleasePill } from "@/components/library/dated-pill";
import { PosterCard } from "@/components/poster-card";
-import { TmdbRatingBadge } from "@/components/rating-badges";
+import { IgdbRatingBadge, TmdbRatingBadge } from "@/components/rating-badges";
import {
MovieCardBadge,
MovieCardControls,
@@ -11,6 +12,9 @@ import {
ShowCardControls,
ShowCardMeta,
} from "@/components/tracking/show-card-slots";
+import { gameHref } from "@/lib/catalog/media-type";
+import { formatReleaseDate } from "@/lib/format";
+import type { GameSummary } from "@/lib/igdb";
import type { MovieSummary, TvShowSummary } from "@/lib/tmdb";
type ListCardProps = {
@@ -126,4 +130,42 @@ function MovieListCard({
);
}
-export { MovieListCard, ShowListCard };
+/**
+ * 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.
+ *
+ * @param releaseDay The request's UTC day, for the date's year rule; left
+ * out on a list whose cards print no date.
+ */
+function GameListCard({
+ game,
+ priority,
+ sizes,
+ releaseDay,
+}: Omit & {
+ game: GameSummary;
+ releaseDay?: string;
+}) {
+ return (
+ }
+ controls={
+ releaseDay === undefined ? undefined : (
+
+ )
+ }
+ priority={priority}
+ sizes={sizes}
+ />
+ );
+}
+
+export { GameListCard, MovieListCard, ShowListCard };
diff --git a/components/catalog/list-panels.tsx b/components/catalog/list-panels.tsx
new file mode 100644
index 0000000..77e0cd5
--- /dev/null
+++ b/components/catalog/list-panels.tsx
@@ -0,0 +1,41 @@
+import { RetryLink } from "@/components/retry-link";
+import { StatePanel } from "@/components/state-panel";
+import type { CatalogList } from "@/lib/catalog/lists";
+import { CATALOG_SOURCES } from "@/lib/catalog/media-type";
+
+/**
+ * A list page whose catalog read failed, naming its source, with Try again
+ * to the page the visitor is on (spec 0021, AC-10; spec 0028, AC-11).
+ */
+function ListErrorPanel({ list, href }: { list: CatalogList; href: string }) {
+ const source = CATALOG_SOURCES[list.mediaType];
+ return (
+ }
+ />
+ );
+}
+
+/**
+ * Page 1 of a list with no titles. A TMDB list keeps spec 0021's copy
+ * (AC-10); a games list names what is missing with its `pageNoun` (spec
+ * 0028, AC-11).
+ */
+function ListEmptyPanel({ list }: { list: CatalogList }) {
+ return (
+
+ );
+}
+
+export { ListEmptyPanel, ListErrorPanel };
diff --git a/components/layout/brand-link.test.tsx b/components/layout/brand-link.test.tsx
index b21b9ea..865c56c 100644
--- a/components/layout/brand-link.test.tsx
+++ b/components/layout/brand-link.test.tsx
@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BrandLink, LAST_MEDIA_TYPE_KEY } from "@/components/layout/brand-link";
@@ -16,7 +16,7 @@ function brandHref() {
}
/**
- * covers: prompts/logo-follows-media-type.md, AC-1 to AC-4
+ * covers: prompts/logo-follows-media-type.md, AC-1 to AC-4; spec 0028, AC-16
*
* The logo follows the page's catalog, and on a page about neither catalog
* the one last viewed, falling back to `/shows` whenever the memory is
@@ -77,6 +77,48 @@ describe("BrandLink", () => {
expect(brandHref()).toBe("/shows");
});
+ // spec 0028, AC-16
+ describe("on games", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it.each([
+ ["/games", ""],
+ ["/games/top-rated", ""],
+ ["/games/1942", ""],
+ ["/watched", "type=game"],
+ ])("links %s?%s to /games and remembers game", (path, query) => {
+ vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
+ pathname.value = path;
+ search.value = query;
+ render();
+
+ expect(brandHref()).toBe("/games");
+ expect(window.localStorage.getItem(LAST_MEDIA_TYPE_KEY)).toBe("game");
+ });
+
+ it("sends a remembered game to /games only while the flag is on", () => {
+ window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, "game");
+ pathname.value = "/account";
+
+ vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
+ render().unmount();
+ expect(window.localStorage.getItem(LAST_MEDIA_TYPE_KEY)).toBe("game");
+ render();
+ expect(brandHref()).toBe("/games");
+ });
+
+ it("reads a stale remembered game as nothing with the flag off", () => {
+ window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, "game");
+ vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
+ pathname.value = "/account";
+ render();
+
+ expect(brandHref()).toBe("/shows");
+ });
+ });
+
it("survives storage that throws", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
diff --git a/components/layout/brand-link.tsx b/components/layout/brand-link.tsx
index 5481fc3..a2e8f5c 100644
--- a/components/layout/brand-link.tsx
+++ b/components/layout/brand-link.tsx
@@ -6,8 +6,9 @@ import { useEffect, useSyncExternalStore } from "react";
import {
CATALOG_PATHS,
isMediaType,
- type MediaType,
+ type LibraryType,
} from "@/lib/catalog/media-type";
+import { gamesLibraryEnabled } from "@/lib/env";
import { WithPageMediaType } from "./page-media-type";
@@ -21,22 +22,23 @@ const BRAND_CLASS =
"rounded-sm text-xl leading-none font-extrabold tracking-[-0.03em] text-foreground";
/**
- * The BeStats wordmark, linking to the popular page of the catalog being
- * viewed (prompts/logo-follows-media-type.md): `/movies` on a movie page,
- * `/shows` on a TV page. On a page about neither catalog it links to the one
- * last viewed, kept in `localStorage` because a cookie the server read would
- * pull every route out of its prerendered shell. It is a per browser
- * convenience only: nothing else reads it, and it never lights a tab, since
- * selection comes from the URL (`components/AGENTS.md`).
+ * The BeStats wordmark, linking to the landing of the catalog being viewed
+ * (prompts/logo-follows-media-type.md): `/movies` on a movie page, `/shows` on
+ * a TV page, and `/games` on a games page or games tab (spec 0028, AC-16). On a
+ * page about neither catalog it links to the one last viewed, kept in
+ * `localStorage` because a cookie the server read would pull every route out of
+ * its prerendered shell. It is a per browser convenience only: nothing else
+ * reads it, and it never lights a tab, since selection comes from the URL
+ * (`components/AGENTS.md`).
*/
function BrandLink() {
return (
}>
{(page) =>
- page.type === null ? (
+ page.libraryType === null ? (
) : (
-
+
)
}
@@ -44,7 +46,7 @@ function BrandLink() {
}
/** On a typed page the logo follows the page, and remembers it. */
-function TypedBrandLink({ type }: { type: MediaType }) {
+function TypedBrandLink({ type }: { type: LibraryType }) {
useEffect(() => {
try {
window.localStorage.setItem(LAST_MEDIA_TYPE_KEY, type);
@@ -70,9 +72,15 @@ function subscribe(onChange: () => void) {
return () => window.removeEventListener("storage", onChange);
}
-function readRemembered(): MediaType | null {
+/**
+ * The stored type, parsed as a `LibraryType`. A remembered `game` counts only
+ * while the games library is on, so a stale value never leads to a hidden
+ * tab (spec 0028, AC-16).
+ */
+function readRemembered(): LibraryType | null {
try {
const value = window.localStorage.getItem(LAST_MEDIA_TYPE_KEY);
+ if (value === "game") return gamesLibraryEnabled() ? "game" : null;
return isMediaType(value) ? value : null;
} catch {
return null;
@@ -83,7 +91,7 @@ function readRemembered(): MediaType | null {
* The wordmark for one catalog. Also the Suspense fallback in `Navbar`, where
* a dynamic route's pathname is unknown at prerender time.
*/
-function BrandLinkView({ type }: { type: MediaType }) {
+function BrandLinkView({ type }: { type: LibraryType }) {
return (
BeStats
diff --git a/components/layout/library-nav.test.tsx b/components/layout/library-nav.test.tsx
index c1fc9d1..1efce9b 100644
--- a/components/layout/library-nav.test.tsx
+++ b/components/layout/library-nav.test.tsx
@@ -129,4 +129,21 @@ describe("LibraryNav", () => {
]);
vi.unstubAllEnvs();
});
+
+ // covers: spec 0028, AC-14
+ it("carries type=game on /games while the games library is on", () => {
+ vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
+ pathname = "/games/top-rated";
+ search = "";
+ 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/media-type-tabs.test.tsx b/components/layout/media-type-tabs.test.tsx
index c9e868e..b178ac1 100644
--- a/components/layout/media-type-tabs.test.tsx
+++ b/components/layout/media-type-tabs.test.tsx
@@ -177,20 +177,49 @@ describe("MediaTypeTabs", () => {
},
);
+ // spec 0028, AC-14: GAMES on every page, leading to /games.
it.each(["/search", "/shows", "/movies/550"])(
- "keeps %s to SHOWS and MOVIES",
+ "adds GAMES to /games on %s, unlit",
(path) => {
pathname.value = path;
- search.value = "type=game";
+ search.value = "type=movie";
render();
- expect(
- screen.queryByRole("link", { name: "GAMES" }),
- ).not.toBeInTheDocument();
+ const games = screen.getByRole("link", { name: "GAMES" });
+ expect(games).toHaveAttribute("href", "/games");
+ expect(games).not.toHaveAttribute("aria-current");
+ },
+ );
+
+ it.each(["/games", "/games/top-rated", "/games/1942"])(
+ "lights GAMES on %s, with the catalogs one click away",
+ (path) => {
+ pathname.value = path;
+ search.value = "";
+ render();
+
+ expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByRole("link", { name: "SHOWS" })).toHaveAttribute(
+ "href",
+ "/shows",
+ );
},
);
});
+ it("shows no tabs on /games with the games library off", () => {
+ vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
+ pathname.value = "/games";
+ search.value = "";
+ render();
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ vi.unstubAllEnvs();
+ });
+
it("never renders GAMES with the games library off", () => {
vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off");
pathname.value = "/watchlist";
@@ -250,7 +279,7 @@ describe("MediaTypeTabs", () => {
);
});
- it("keeps the fallback to two tabs on /search", async () => {
+ it("gives the /search fallback a GAMES link to /games (spec 0028, AC-14)", async () => {
vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
suspendSearch.value = true;
pathname.value = "/search";
@@ -258,7 +287,11 @@ describe("MediaTypeTabs", () => {
render();
});
- expect(screen.getAllByRole("link")).toHaveLength(2);
+ expect(screen.getAllByRole("link")).toHaveLength(3);
+ expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute(
+ "href",
+ "/games",
+ );
});
});
});
diff --git a/components/layout/media-type-tabs.tsx b/components/layout/media-type-tabs.tsx
index 3fcd64f..030d4b5 100644
--- a/components/layout/media-type-tabs.tsx
+++ b/components/layout/media-type-tabs.tsx
@@ -17,8 +17,8 @@ import { WithLibraryTypedPage } from "./page-media-type";
/**
* The SHOWS and MOVIES control in the navbar (spec 0004, AC-14; feature 22),
- * with GAMES beside them on the library pages while the games library is on
- * (spec 0027, AC-3).
+ * with GAMES beside them on every page while the games library is on (spec
+ * 0027, AC-3; spec 0028, AC-14).
*
* These are links, not toggles. Selection comes from the URL, so a deep link,
* a browser back button and a server render all agree on which tab is lit;
@@ -27,9 +27,10 @@ import { WithLibraryTypedPage } from "./page-media-type";
* cannot.
*
* On `/watchlist`, `/upcoming`, `/watched` and `/search` the tabs switch the
- * page's own `type` parameter in place; everywhere else they lead to `/shows`
- * and `/movies`. `WithLibraryTypedPage` reads the page's type, and on those
- * four pages the current query, which `/search` keeps across a switch.
+ * page's own `type` parameter in place (GAMES leads from `/search` to
+ * `/games`); everywhere else they lead to `/shows`, `/movies` and `/games`.
+ * `WithLibraryTypedPage` reads the page's type, and on those four pages the
+ * current query, which `/search` keeps across a switch.
*
* On a page about neither catalog (`/account`, `/privacy`, the auth pages)
* there is nothing to switch, so it renders nothing
@@ -70,16 +71,31 @@ function MediaTypeTabs({ className }: { className?: string }) {
}
/**
- * Each tab's link on a typed page: the same page with its own `type`, and a
- * GAMES link only on a library page while the games library is on.
+ * Each tab's link on a typed page: the same page with its own `type`, and,
+ * while the games library is on, GAMES switching a library page's `type` in
+ * place or leading from `/search` to `/games` (spec 0028, AC-14).
*/
function typedHrefs(pathname: TypedPath, current?: URLSearchParams): TabHrefs {
const hrefs = {
tv: typedHref(pathname, "tv", { current }),
movie: typedHref(pathname, "movie", { current }),
};
- if (!isLibraryPath(pathname) || !gamesLibraryEnabled()) return hrefs;
- return { ...hrefs, game: typedHref(pathname, "game") };
+ if (!gamesLibraryEnabled()) return hrefs;
+ return {
+ ...hrefs,
+ game: isLibraryPath(pathname)
+ ? typedHref(pathname, "game")
+ : CATALOG_PATHS.game,
+ };
+}
+
+/**
+ * Each tab's link off the typed pages: the three catalog landings, GAMES
+ * only while the games library is on (spec 0028, AC-14).
+ */
+function catalogHrefs(): TabHrefs {
+ const hrefs = { tv: CATALOG_PATHS.tv, movie: CATALOG_PATHS.movie };
+ return gamesLibraryEnabled() ? { ...hrefs, game: CATALOG_PATHS.game } : hrefs;
}
/** Where each tab leads; a GAMES tab renders only when it has a link. */
@@ -96,12 +112,13 @@ const TABS = [
* which only the prerendered fallback uses: a page about neither catalog
* renders no tabs at all.
*
- * @param hrefs Where each tab leads, `/shows` and `/movies` by default. The
- * GAMES tab renders only when `hrefs` gives it a link.
+ * @param hrefs Where each tab leads, `/shows`, `/movies` and, with the games
+ * library on, `/games` by default. The GAMES tab renders only when `hrefs`
+ * gives it a link.
*/
function MediaTypeTabsView({
selected,
- hrefs = CATALOG_PATHS,
+ hrefs = catalogHrefs(),
className,
}: {
selected: LibraryType | null;
diff --git a/components/layout/navbar.test.tsx b/components/layout/navbar.test.tsx
index bba55b5..a0b0582 100644
--- a/components/layout/navbar.test.tsx
+++ b/components/layout/navbar.test.tsx
@@ -184,17 +184,53 @@ describe("Navbar", () => {
expect(
screen.queryByRole("button", { name: "Search" }),
).not.toBeInTheDocument();
+ // spec 0028, AC-16: the logo follows a games tab and remembers it.
expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute(
"href",
- "/shows",
+ "/games",
);
- expect(window.localStorage.getItem("bestats:last-media-type")).toBeNull();
+ expect(window.localStorage.getItem("bestats:last-media-type")).toBe("game");
vi.unstubAllEnvs();
+ window.localStorage.removeItem("bestats:last-media-type");
pathname.current = "/shows";
search.current = "type=movie";
});
+ // spec 0028, AC-14 to AC-16
+ it.each(["/games", "/games/coming-soon"])(
+ "lights GAMES on %s, with no search and the logo on /games",
+ (path) => {
+ vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on");
+ pathname.current = path;
+ search.current = "";
+ render(
+ }
+ desktopAccountSlot={}
+ />,
+ );
+
+ expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Search" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute(
+ "href",
+ "/games",
+ );
+
+ vi.unstubAllEnvs();
+ window.localStorage.removeItem("bestats:last-media-type");
+ pathname.current = "/shows";
+ search.current = "type=movie";
+ },
+ );
+
it("takes the type from the type parameter on /search", () => {
pathname.current = "/search";
render(
diff --git a/components/layout/page-media-type.tsx b/components/layout/page-media-type.tsx
index fa2aa41..d8de076 100644
--- a/components/layout/page-media-type.tsx
+++ b/components/layout/page-media-type.tsx
@@ -16,10 +16,11 @@ import { gamesLibraryEnabled } from "@/lib/env";
* page's media type, null on a page about neither catalog, and on a typed
* page its current query.
*
- * `type` is the catalog, for the search and the logo; `libraryType` is what
- * the tabs and the Library links light, which on a games tab of a library
- * page is `game` while `type` is null (spec 0027, AC-4): there is no game
- * search until G7, and the logo never remembers `game`.
+ * `type` is the catalog, for the search; `libraryType` is what the tabs, the
+ * Library links and the logo follow, which on a games tab of a library page
+ * and on `/games` and below it is `game` while `type` is null (spec 0027,
+ * AC-4; spec 0028, AC-14 to AC-16): there is no game search until G7, so the
+ * search renders nothing there.
*/
type PageMediaType =
| {
@@ -68,8 +69,13 @@ function WithPageMediaType({
);
}
- const type = mediaTypeForLocation(pathname, null);
- return children({ pathname, type, libraryType: type, params: null });
+ // Only the pathname is read here, so `/games` keeps its prerendered shell.
+ return children({
+ pathname,
+ type: mediaTypeForLocation(pathname, null),
+ libraryType: libraryTypeForLocation(pathname, null, gamesLibraryEnabled()),
+ params: null,
+ });
}
/** A page that has a media type, as `WithTypedPage` hands it on. */
diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx
index 6629243..6c53f8c 100644
--- a/components/library/game-library-tab.test.tsx
+++ b/components/library/game-library-tab.test.tsx
@@ -94,6 +94,7 @@ function game(id: number, extra: Record = {}) {
id,
name: `Game ${id}`,
coverUrl: null,
+ artUrl: null,
releaseDate: "2020-05-01",
releaseYear: 2020,
communityRating: 8.1,
@@ -221,13 +222,18 @@ describe("the reads and the panels (AC-6, AC-7, AC-14)", () => {
["watchlist", "No games to play yet"],
["upcoming", "Nothing in progress"],
["watched", "No finished games yet"],
- ] as const)("shows the %s empty state as text only", async (list, title) => {
- await renderSection(list, { type: "game" });
+ ] as const)(
+ "shows the %s empty state with Browse games (spec 0028, AC-17)",
+ async (list, title) => {
+ await renderSection(list, { type: "game" });
- expect(screen.getByText(title)).toBeInTheDocument();
- expect(screen.queryByRole("link")).not.toBeInTheDocument();
- expect(getLibraryGames).not.toHaveBeenCalled();
- });
+ expect(screen.getByText(title)).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "Browse games" }),
+ ).toHaveAttribute("href", "/games");
+ expect(getLibraryGames).not.toHaveBeenCalled();
+ },
+ );
it("shows the list's failed panel when Postgres fails", async () => {
getGameLibraryPage.mockResolvedValue({ kind: "failed" });
diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx
index de6357a..0d65d0e 100644
--- a/components/library/library-panels.tsx
+++ b/components/library/library-panels.tsx
@@ -127,9 +127,9 @@ export function TabNotes({
}
/**
- * A tab with nothing on it. A show or movie tab offers its catalog; a games
- * tab is text only, because there is no games browse page until G6 (spec
- * 0027, AC-14).
+ * A tab with nothing on it, offering its catalog's landing: `/shows`,
+ * `/movies`, or `/games` for a games tab (spec 0027, AC-14, as amended by
+ * spec 0028, AC-17).
*/
export function EmptyPanel({
list,
@@ -146,11 +146,9 @@ export function EmptyPanel({
title={copy.empty.title}
description={copy.empty.description}
action={
- type !== "game" && copy.browse ? (
-
- {copy.browse}
-
- ) : undefined
+
+ {copy.browse}
+
}
/>
diff --git a/components/pagination-links.tsx b/components/pagination-links.tsx
index 7362355..d0505a4 100644
--- a/components/pagination-links.tsx
+++ b/components/pagination-links.tsx
@@ -15,16 +15,22 @@ import { ButtonLink } from "@/components/ui/button";
*
* @param href Builds the URL for a page number. Page 1 should map to the bare
* path, so there is one URL per page.
+ * @param lastPage The last page, or null when the list has no count (an IGDB
+ * list without one, spec 0028, AC-10): the line then reads `Page N` and Next
+ * shows only when `hasNextPage` says so.
*/
function PaginationLinks({
page,
lastPage,
+ hasNextPage = false,
href,
}: {
page: number;
- lastPage: number;
+ lastPage: number | null;
+ hasNextPage?: boolean;
href: (page: number) => string;
}) {
+ const hasNext = lastPage === null ? hasNextPage : page < lastPage;
return (
- {page < lastPage ? (
+ {hasNext ? (
}` |
+| `LandingItem` | `components/catalog/landing/landing-items.ts` | `mediaType: LibraryType`; the TMDB rating becomes `rating: { source: "tmdb" \| "igdb"; value: number \| null }` |
+| `LandingSlots` for games | `lib/catalog/landing.ts` | trending, popular, `now` = recently-released, `nowLabel` = `New releases`, `upcoming` = coming-soon, top = top-rated |
+
+**Code touch points** (named so the build does not hunt for them):
+
+- `lib/catalog/lists.ts`: `catalogListsFor` gains a `"game"` overload; its `MediaType` overload never returns game lists, which is what keeps `app/sitemap.ts` (it calls `catalogListsFor("tv")` and `catalogListsFor("movie")`) free of `/games` URLs. `catalogList`, `listPath`, `listPageHref` and `isCatalogListSlug` take `LibraryType`. `app/catalog-lists.test.ts` checks the `app/games/` folders against the registry.
+- `components/catalog/catalog-list-page.tsx`: `LANDING_LABELS` gains `game: "Games"`; the grid renders game cards for the game branch; metadata never reads `?page`. Each `app/games//page.tsx` copies its sibling `app/movies//page.tsx` pattern exactly.
+- `proxy.ts`: `listMediaType` becomes `LibraryType`; the existing `/^\/games\/([^/]+)$/` entry gains `listMediaType: "game"`, and its "no list pages yet" comment goes. The proxy's own local `CATALOG_PATHS` is a different constant from the one in `media-type.ts`. `proxy.test.ts` adds `/games/Trending`, `/games/coming_soon`, `/games/trending.jpg` and `/games/1942`.
+- `components/catalog/landing/`: `LandingItem.rating` replaces `tmdbRating` at every use (`landing-badge.tsx`, `landing-hero.tsx`); the badge picks `TmdbRatingBadge` or `IgdbRatingBadge` (both in `components/rating-badges.tsx`) by `rating.source`. A game card's status and controls slot renders nothing, with no Suspense wrapper. The hero's genre lookup gains a game branch over `getGameGenres` and treats an `IgdbError` like a `TmdbError` (genres left out); the hero renders no `TrailerSlot` for a game; it passes `GameTrackingSlot` the props the game page passes, inside a Suspense boundary with the same fallback as the movie hero's tracking slot.
+- `components/layout/`: `media-type-tabs.tsx` (a `/games` branch, lit on `/games*`, `/games` as the GAMES href off the library pages) and the mobile menu if it repeats the tabs; `page-media-type.tsx` returns `libraryType: "game"` and `type: null` on `/games*`; `brand-link.tsx` for AC-16; the navbar search slot for AC-15.
+- `components/library/library-panels.tsx` (the three empty games tabs) and `app/games/[id]/not-found.tsx` with its test, for AC-17.
+
+Invariant in code: `MediaType` is not widened. `/search`, the TMDB reads, the title pages and `typedHref`'s search overload still cannot receive `game` until G7.
+
+**State transitions**: none.
+
+**API surface** (pages and reads; no Server Action and no Route Handler is added):
+
+| Surface | Kind | Key inputs | Key outputs | Auth | Key errors |
+|---|---|---|---|---|---|
+| `/games` | page | none | the landing | public; hero slot reads the session if any | per section `IgdbError` → section Try again |
+| `/games/{slug}` (5 static folders) | page | `?page` | list page | public | `IgdbError` → retry panel; bad page → no such page panel |
+| `getPopularGames(kind)` | cached read, `lib/igdb/reads.ts` | `kind` | `GameSummary[]` in rank order, visible only | server only | `IgdbError` kinds; bad kind → `IgdbValidationError` |
+| `discoverGames(options)` | cached read, existing | adds `pageSize` | `Paged` | server only | `IgdbValidationError` for a bad `pageSize` |
+| `readGameListPage(list, page, today)` | helper, `lib/catalog-list-read.ts` | registry entry, page, UTC day | `TaggedCatalogPage` (game branch) | server only | rethrows `IgdbError` for the caller |
+| `gameListOptions(slug, today)` | pure, `lib/catalog/game-lists.ts` | slug, UTC day | the `DiscoverGamesOptions` of AC-2 | none | none (typed slugs only) |
+
+**Value sourcing**:
+
+| Action | Value produced / displayed | Source |
+|---|---|---|
+| discover lists | `today` | `new Date()` UTC day, read after `connection()` in the page's read helper; passed down, never read in `lib/igdb/` (spec 0023 rule) |
+| discover lists | window ends (`today − 59`, `today + 1`, `today + 120`) | derived from `today` in `gameListOptions` |
+| top rated | rating floor 100 | constant in `gameListOptions`, decided here |
+| popularity lists | popularity type ids for Visits and Playing | `lib/igdb/constants.ts`, pinned, verified by `pnpm igdb:live` against `popularity_types` |
+| popularity lists | rank order | `popularity_primitives.value` desc |
+| popularity lists | hidden or missing games | `GameSummary.hidden` and `BatchResult.missingIds` from `getGamesByIds` |
+| list page | `Page N of M` | AC-10: kept count, or IGDB `X-Count` (`totalResults`), or `hasNextPage` |
+| hero, wide cards | art image | `GameSummary.artUrl`, AC-6 |
+| hero | genre names | `GameSummary.genreIds` mapped through `getGameGenres()` |
+| hero | release gate, status | `GameTrackingSlot` (spec 0026), from `releaseDate` and the user's row |
+| cards | rating | `GameSummary.communityRating`, labelled as community rating |
+| cards | Coming soon date | `GameSummary.releaseDate`, printed as a UTC day |
+| sidebar line | `{n}` | the rendered Recently released count |
+| nav | flag | `gamesLibraryEnabled()` in `lib/env.ts` |
+| logo | remembered type | the existing brand link memory, widened to `LibraryType` |
+
+**Key invariants**:
+
+- Every game a list shows passes the catalog filter: discover lists by `CATALOG_RULE` in the query, popularity lists by dropping `hidden` after the batch.
+- A landing read and its list page's read are one cache entry (same function, same canonical options).
+- `lib/igdb/` never reads the clock for a filter.
+- No game URL enters the sitemap before G10, and every games page is `noindex`.
+- `MediaType` stays two valued.
+
+**Security model**: every page is public and reads only public IGDB data through the server only module; the Twitch credentials never leave `lib/igdb/env.ts`. The one private surface is the hero's `GameTrackingSlot`, which reads the user's own row under RLS inside its own Suspense boundary, uncached, exactly as on the game page. No new input reaches IGDB except `page` (validated by `parsePageParam`, then by the module) and `kind`, a typed literal. The limiter and the minutes and hours cache lifetimes bound IGDB traffic; the Vercel Firewall rule stays on `/api/search` only, since these pages make only cached reads.
+
+**Configuration required**: none new. `NEXT_PUBLIC_GAMES_LIBRARY` keeps gating the tab, stays unset in Production and `on` in Preview and `.env.local`. It stays until G10, which removes it when games launch (this settles spec 0027's follow-up).
+
+**Critical test scenarios**:
+
+- Happy path: `/games` renders all six parts from fixtures in order, each See all opens its list page with 24 cards and correct pagination, verifies **AC-1**, **AC-3**, **AC-9**, **AC-10**
+- Popularity filter: a fixture with hidden and missing ids among the top primitives gives a list with neither, in rank order, with an exact count, verifies **AC-4**
+- Date windows: `gameListOptions` for a fixed `today` across a month and year boundary gives the exact UTC days, verifies **AC-2**
+- Failure case: one section's IGDB read times out; that section shows Try again and the others render; a list page shows the retry panel, verifies **AC-11**
+- Proxy: the five slugs pass, case and spelling variants and image paths 404, a numeric id reaches the game page, verifies **AC-12**
+- Auth/permission: a signed out visitor sees the hero with no status control and no session read anywhere else; a signed in user's status shows only in the hero slot and never in a cached entry, verifies **AC-7**, **AC-19**
+- SEO: every games page is `noindex` with no canonical, and the sitemap has no `/games` URL, verifies **AC-13**
+
+## Build plan
+
+Tracer Bullet: the first slice is one list, end to end, from IGDB to a live page through the registry, proxy and metadata. Then the module strands, the other lists, the landing, and the navigation thicken it.
+
+1. **Thin thread, one list**: `GameListSlug` and the `top-rated` entry in the registry, `CATALOG_PATHS.game`, `gameListOptions`, `pageSize` on `discoverGames` (validation, `readSummaryPage`, tests), the game branch of `readGameListPage` and the list page, `/games/top-rated` with plain game cards, `noindex` metadata, the proxy slug rule, and the sitemap exclusion test, satisfies **AC-2**, **AC-5**, **AC-8**, **AC-9**, **AC-10**, **AC-11**, **AC-12**, **AC-13**, **AC-18**
+2. **Module strands**: `artUrl` (fields, schema, normalize, types), `IGDB_ART_LADDER` and the loader step, `getPopularGames` with its pinned popularity type ids, the batch, the drop and the `hours` cache, plus `igdb.live.ts` checks for the art rungs and the two popularity types, and `surface.test.ts` updated for the new export, satisfies **AC-4**, **AC-6**
+3. **The other four lists**: `trending`, `popular`, `recently-released`, `coming-soon` registry entries and route folders, popularity paging, the folder and registry agreement test, satisfies **AC-2**, **AC-4**, **AC-9**, **AC-10**
+4. **The landing**: `LandingItem` widened, `landingItems` and `readLandingList` game branches, `landingSlots("game")`, the game `nowBlurb`, the IGDB badge and no status on game cards, Coming soon dates, `app/games/page.tsx` on `CatalogLanding`, the hero's art, genres and `GameTrackingSlot`, and the request scope test for `/games`, satisfies **AC-1**, **AC-3**, **AC-7**, **AC-8**, **AC-11**, **AC-18**, **AC-19**
+5. **Navigation**: GAMES on every page and its link targets, search hidden on `/games/*`, the logo's memory widened, Browse games on the empty games tabs and the game not found panel, with the navbar, library nav and brand link tests, satisfies **AC-14**, **AC-15**, **AC-16**, **AC-17**
+6. **Proof**: `/showcase` states, 375px and 1280px passes, keyboard and reduced motion, `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`, `pnpm igdb:live`, satisfies **AC-20**, **AC-21**, and confirms **AC-18**
+
+## Consequences
+
+**Positive**:
+
+- `/games` looks and behaves like its two siblings, and any later change to the landing reaches all three.
+- G7 and G8 inherit the registry, the `CATALOG_PATHS.game` entry, the page size and the art field instead of reopening them.
+- Popularity counts are exact, since the whole visible list is known.
+
+**Negative / tradeoffs**:
+
+- The landing components now branch on three media types; the TMDB only assumptions in `LandingItem` and the list page have to be widened carefully.
+- Every summary read carries two more expanded fields, so batch and discover payloads grow, and a popularity list is one 500 id batch, the heaviest single IGDB read in the app (cached for hours).
+- Popularity lists stop at about 20 pages (500 primitives less the hidden ones).
+- With 24 per page, Coming soon's landing skips items 21 to 24 of page 1 (they show on See all), because each page fills only its own rows.
+- Game cards on the landing have no status or controls until G8, unlike show and movie cards.
+- Popularity type ids are IGDB facts that can change; the live check is the only guard.
+
+**Neutral**:
+
+- Amends spec 0025, AC-7 (the not found button) and spec 0027, AC-14 (Browse games) and AC-4 (the logo remembers `game`); both specs get an amendment note when this ships.
+- `docs/scope/games.md` G6 said "rows of carousels"; the parallax landing replaced the rows on 2026-10-18, and this spec follows it.
+
+## Follow-up
+
+- [ ] G8: give game cards on `/games`, the list pages and the hero row the status badge and controls, with one status read per grid.
+- [ ] G9: add the Trailer button to the games hero once the game page has trailers.
+- [ ] G10: drop `noindex`, add canonicals, share cards and the sitemap entries for `/games` and its list pages, and remove `NEXT_PUBLIC_GAMES_LIBRARY`.
+- [x] Amend spec 0025 (AC-7 button) and spec 0027 (AC-4 logo, AC-14 Browse games) with a pointer to this spec.
+- [ ] `lib/igdb/AGENTS.md`: record `getPopularGames`, the art ladder and the `hours` popularity lifetime when this ships (`/sync`).
+
+## Rationale
+
+Reasoning and options: see [rationale.md](rationale.md).
diff --git a/docs/specs/0028-games-home-list-pages/rationale.md b/docs/specs/0028-games-home-list-pages/rationale.md
new file mode 100644
index 0000000..f2cc5ba
--- /dev/null
+++ b/docs/specs/0028-games-home-list-pages/rationale.md
@@ -0,0 +1,64 @@
+# 0028. Games home and list pages: rationale
+
+## Context
+
+Games have a page (spec 0025), a status and rating (spec 0026) and three library tabs (spec 0027), but nothing to browse. A visitor can reach a game only by its URL, and the games library tabs say "Games you mark Want to play show up here" with no way to find one. `/games` itself 404s.
+
+The scope row for G6 was written on 2026-10-17 and asks for carousel rows "like `/shows` and `/movies`". On 2026-10-18 those two pages stopped being rows: they became the parallax landing (prompts/home-parallax-landing.md), a full bleed hero and five sections that each have their own layout and motion. The rows survive only on the title pages. So "like the siblings" and "carousel rows" now point at different things.
+
+IGDB is not TMDB. It has no ready made trending, now playing or upcoming lists; it has a query language over its `games` endpoint and a separate `popularity_primitives` endpoint that ranks game ids by signals such as visits and players. The module from spec 0023 already offers `discoverGames` with date windows, hype and rating sorts, but returns 20 per page while every list page in the app shows 24, and a game summary carries only a portrait cover, while the landing's hero and wide cards need landscape art. Popularity primitives return ids only, and some of those ids are DLC or editions the catalog filter hides.
+
+The navigation was left half open on purpose. Spec 0027 kept `MediaType` two valued and the GAMES tab on the library pages only, and listed what G6 owes: `/games` in `CATALOG_PATHS`, GAMES on every page, a Browse games button on the empty tabs, and a logo that remembers `game`. Game search is G7, so a games page must not offer a search that returns shows. Production still has the games flag off, and spec 0025 keeps every game page `noindex` until G10.
+
+## Options considered
+
+### Option 1: One landing over a widened list registry
+
+Widen the list registry, the read seam (`readLandingList`, `TaggedCatalogPage`) and `LandingItem` to a third media type. `/games` renders the same `CatalogLanding` with game slots, and the list pages reuse `CatalogListPage` with a game card. IGDB gains a popularity read, an art field and a page size.
+
+**Pros**:
+- One landing for three catalogs; a fix or a design change lands once.
+- `/games` matches its siblings exactly, which is what AGENTS.md section 3 asks of new screens.
+- The registry keeps every slug in one place for the routes, the proxy and the sitemap guard.
+
+**Cons**:
+- The landing code, typed for TMDB today, must branch on a third type, and each branch is a place a TMDB assumption can leak (the rating badge, the status slots).
+- The registry now holds entries the sitemap must skip until G10.
+
+### Option 2: A separate games landing
+
+Copy the six landing parts into `components/game/landing/` and read IGDB directly, leaving the TMDB landing untouched.
+
+**Pros**:
+- Zero risk to `/shows` and `/movies`.
+- Game specific choices (no status, different badge) need no branching.
+
+**Cons**:
+- Two copies of the parallax engine and its six layouts drift; the next landing change has to be made twice.
+- The registry, proxy and sitemap still need games, so the saving is smaller than it looks.
+
+### Option 3: Plain carousel rows, as the scope text says
+
+Four `CarouselScroller` rows (Popular right now, Recently released, Coming soon, Top rated) and four list pages.
+
+**Pros**:
+- Smallest build; the row component already exists.
+- No hero, so no landscape art is needed.
+
+**Cons**:
+- `/games` would look like the old landing the other two retired, an inconsistency every visitor sees when switching tabs.
+- The engineer chose the parallax landing in the design conversation.
+
+## Rationale
+
+Option 1 follows from two forces in the Context: the siblings changed shape after the scope was written, and the UI rule says new screens follow the shipped UI. Copying the landing (Option 2) would honour the look but double the most motion heavy code in the app; widening it costs a few typed branches that the compiler lists for us, the same way spec 0027 widened the library types.
+
+Filling five sections from four scope lists was settled by adding a second popularity list rather than dropping a section: IGDB's `Visits` reads as "trending" (what people look at now) and `Playing` as "popular" (what people play now), which mirrors the trending and popular pair on the TMDB landings. Recently released and Coming soon both order by hype, the engineer's pick: newest first surfaces obscure releases, and hype keeps the known games on top. Top rated needs a floor of 100 ratings so a handful of perfect scores cannot lead.
+
+The popularity read takes the top 500 primitives and the whole visible list in one cached batch, so a list page can slice it and give an exact count with every page full, the engineer's "overfetch to fill". Reading primitive pages one at a time would leave short or overlapping pages once hidden games drop out. The cost is one heavy batch per list, cached for hours, which matches how often IGDB recalculates popularity.
+
+Art comes from expanding `artworks` and `screenshots` in the summary fields, not a separate read: it gives every card and the hero landscape art with no extra request. The page size becomes an option so the grid fills its 2, 3, 4 and 6 columns as every other list page does, while spec 0023's callers keep 20.
+
+The release posture (live but `noindex`, unlinked in production) repeats spec 0025's, so G10 stays the one switch for search engines and production. The flag stays until then and G10 removes it, closing spec 0027's open question. The GAMES tab on `/search` links to `/games` because a tab that switched `/search` to games would land on a search that does not exist until G7, and the navbar search hides on games pages for the same reason.
+
+Runner ups for the calls made in writing: `getPopularGames` as a list returning summaries (over returning ids and batching in the page, which would put a second cache boundary in the page); `pageSize` limited to 20 or 24 (over any value, which would grow the validation surface for no caller); `CATALOG_PATHS` keyed by `LibraryType` (over widening `MediaType`, which would reach `/search` and the TMDB reads before G7).
diff --git a/docs/specs/0028-games-home-list-pages/verify.md b/docs/specs/0028-games-home-list-pages/verify.md
new file mode 100644
index 0000000..d0d5e34
--- /dev/null
+++ b/docs/specs/0028-games-home-list-pages/verify.md
@@ -0,0 +1,38 @@
+# Verify: Games home and list pages · spec 0028 · updated 2026-10-25
+_These steps come from the acceptance criteria in spec 0028. `/check verify` runs them, and `/test` locks in the ones that should last. Run them with `NEXT_PUBLIC_GAMES_LIBRARY=on` unless a step says otherwise._
+
+## UI / manual
+- [x] Open `/games` at 1280px. You should see, in order: the hero, Trending now, Popular, Recently released (sidebar label `New releases`), Coming soon and Top rated. Each section has a See all link to `/games/{slug}`. → AC-1, AC-9
+- [x] Count the cards on `/games`: Trending 9, Popular 14, Recently released 12, Coming soon up to 40 across 4 rows, Top rated 8. A short list shows fewer cards, never padding. → AC-3
+- [x] The hero shows Trending's first game over its art (or its cover when there's no art), the `#1 Trending` chip, the IGDB community rating when the game has one, up to 3 genres, the name linked to `/games/{id}`, and no Trailer button. → AC-7
+- [x] Signed out: the hero shows no status control. Signed in (`pnpm dev:docker`): the hero shows Want to play / Playing / Finished. On an unreleased game only Want to play is allowed. → AC-7, AC-19
+- [x] Coming soon cards show the UTC release day (`Nov 19`, or `Feb 12, 2027` for another year) over the game's art. A game with no art shows its cover cropped to fill. → AC-8
+- [x] Every game card links to `/games/{id}` and has no personal badge and no controls. → AC-8
+- [x] The sidebar line reads `The 12 most anticipated games of the last 60 days.` → AC-9
+- [x] Open each See all page. You should see `← Games`, the h1, the note, 24 cards and `Page 1 of M`. Next goes to `?page=2`. → AC-9, AC-10
+- [x] Open `/games/trending?page=99` and `/games/top-rated?page=60`. Each shows "There are no … at this page number." → AC-10
+- [x] Block IGDB (for example, set a bad `IGDB_CLIENT_SECRET` and restart). Each landing section shows `Couldn't load …` / `IGDB didn't respond.` with Try again, and a list page shows `Couldn't reach IGDB` with a retry to the same URL. → AC-11
+- [x] GAMES is lit on `/games`, `/games/top-rated` and `/games/1942`. On `/shows`, `/movies/550` and `/search` it links to `/games`. On `/watched` it switches `?type=game`. → AC-14
+- [x] Set `NEXT_PUBLIC_GAMES_LIBRARY=off` and restart. No GAMES tab shows anywhere, and `/games` and `/games/top-rated` still load by URL. → AC-14
+- [x] There's no navbar search on `/games` or `/games/*`. Every other typed page keeps it. → AC-15
+- [x] The logo links to `/games` on `/games*` and on `/watched?type=game`. Then on `/account` it still links to `/games`. With the flag off, the same remembered value sends it to `/shows`. → AC-16
+- [x] An empty games library tab shows a `Browse games` button to `/games`. `/games/999999999` (an unknown game) shows `Browse games` too. → AC-17
+- [x] At 375px there's no horizontal scroll on `/games` or a list page. Tab moves through the cards in order. With reduced motion turned on, nothing moves. → AC-20
+- [x] `/showcase` (in dev) has the "Games landing and lists" section: a game card, wide cards with and without art, a failed section, and the list page error and empty panels. → AC-21
+
+## Commands
+- [x] `curl -s -o /dev/null -w '%{http_code}' localhost:3000/games/Trending` → 404, and the same for `/games/coming_soon` and `/games/trending.jpg`. `/games/coming-soon` and `/games/1942` → 200. → AC-12
+- [x] `curl -s localhost:3000/games | grep -c noindex` → at least 1, and there's no `rel="canonical"` on `/games` or any list page. → AC-13
+- [x] `curl -s localhost:3000/sitemap.xml | grep -c /games` → 0 (on an indexable deployment). → AC-13
+- [x] `IGDB_CLIENT_ID= IGDB_CLIENT_SECRET= pnpm build` passes and lists `/games` and the five list pages as ◐. → AC-18
+- [x] `pnpm igdb:live` passes: the art rungs, Visits = 1 and Playing = 3 from IGDB, both popularity lists, and at least 12 hyped recent releases. → AC-2, AC-4, AC-6
+- [x] `pnpm typecheck && pnpm lint:ci && pnpm test` all pass. → AC-5, AC-20
+
+## Value sourcing
+- [x] The windows follow the UTC day. Near midnight UTC, Recently released ends on the UTC date, not the local one (see the test in `lib/catalog-list-read.test.ts`). → AC-2
+- [x] Top rated asks for `rating_count >= 100` (check the IGDB body in `lib/catalog-list-read.test.ts` or the logs). → AC-2
+- [x] A hidden edition among the top popularity primitives never shows on Trending or Popular, and the page count is exact (`Page 1 of M` with `M = ceil(kept / 24)`). → AC-4, AC-10
+- [x] The hero genres come from IGDB's genre list. When that read fails, the genres are left out. → AC-7
+
+## Acceptance criteria coverage
+- AC-1 landing order · AC-2 sources and windows · AC-3 counts and shared cache · AC-4 popularity filter · AC-5 page size (unit tests) · AC-6 art and ladder (unit and live) · AC-7 hero · AC-8 cards · AC-9 list pages and copy · AC-10 pagination · AC-11 failures · AC-12 proxy · AC-13 SEO · AC-14 GAMES tab · AC-15 search hidden · AC-16 logo · AC-17 Browse games · AC-18 build · AC-19 session reads (request scope test, signed in hero) · AC-20 mobile, keyboard, reduced motion · AC-21 showcase
diff --git a/lib/catalog-errors.ts b/lib/catalog-errors.ts
new file mode 100644
index 0000000..dedb74e
--- /dev/null
+++ b/lib/catalog-errors.ts
@@ -0,0 +1,12 @@
+import { IgdbError } from "@/lib/igdb";
+import { TmdbError } from "@/lib/tmdb";
+
+/**
+ * True for a failure of a list's own catalog, TMDB's or IGDB's, which a
+ * landing section or list page answers with its own Try again (spec 0021,
+ * AC-11; spec 0028, AC-11). Anything else, an `IgdbValidationError`
+ * included, is a bug the caller rethrows.
+ */
+export function isCatalogError(error: unknown): boolean {
+ return error instanceof TmdbError || error instanceof IgdbError;
+}
diff --git a/lib/catalog-list-read.test.ts b/lib/catalog-list-read.test.ts
index ae775c2..9b48d5f 100644
--- a/lib/catalog-list-read.test.ts
+++ b/lib/catalog-list-read.test.ts
@@ -16,13 +16,27 @@ const tmdb = vi.hoisted(() => ({
vi.mock("@/lib/tmdb", () => tmdb);
+const igdb = vi.hoisted(() => ({
+ discoverGames: vi.fn(),
+ getPopularGames: vi.fn(),
+}));
+vi.mock("@/lib/igdb", () => igdb);
+vi.mock("next/server", () => ({ connection: async () => {} }));
+
const { catalogList } = await import("@/lib/catalog/lists");
-const { readCatalogList, readTaggedCatalogGridPage } = await import(
- "./catalog-list-read"
-);
+const {
+ lastListPage,
+ listHasNextPage,
+ readCatalogList,
+ readGameListPage,
+ readTaggedCatalogGridPage,
+ readTaggedCatalogList,
+} = await import("./catalog-list-read");
afterEach(() => {
for (const fn of Object.values(tmdb)) fn.mockReset();
+ for (const fn of Object.values(igdb)) fn.mockReset();
+ vi.useRealTimers();
});
describe("readCatalogList", () => {
@@ -88,7 +102,128 @@ describe("readTaggedCatalogGridPage", () => {
expect(result.results.map((show) => show.id)).toEqual(
Array.from({ length: 24 }, (_, index) => 25 + index),
);
- expect(result.totalPages).toBe(25);
+ expect("totalPages" in result && result.totalPages).toBe(25);
expect(mediaType).toBe("tv");
});
});
+
+/** covers: spec 0028, AC-2 to AC-4, AC-10 */
+describe("readGameListPage", () => {
+ const ranked = (count: number) =>
+ Array.from({ length: count }, (_, index) => ({ id: index + 1 }));
+
+ it("slices a popularity list by 24, with the exact count", async () => {
+ igdb.getPopularGames.mockResolvedValue(ranked(50));
+
+ const paged = await readGameListPage(
+ catalogList("game", "trending"),
+ 2,
+ "2026-10-25",
+ );
+
+ expect(igdb.getPopularGames).toHaveBeenCalledWith("trending");
+ expect(paged.results.map((game) => game.id)).toEqual(
+ Array.from({ length: 24 }, (_, index) => 25 + index),
+ );
+ expect(paged).toMatchObject({ hasNextPage: true, totalResults: 50 });
+ });
+
+ it("reads Popular from the Playing list, and ends on the last slice", async () => {
+ igdb.getPopularGames.mockResolvedValue(ranked(50));
+
+ const paged = await readGameListPage(
+ catalogList("game", "popular"),
+ 3,
+ "2026-10-25",
+ );
+
+ expect(igdb.getPopularGames).toHaveBeenCalledWith("popular");
+ expect(paged.results.map((game) => game.id)).toEqual([49, 50]);
+ expect(paged.hasNextPage).toBe(false);
+ });
+
+ it("reads a discover list with its day's options and the page", async () => {
+ await readGameListPage(catalogList("game", "coming-soon"), 2, "2026-10-25");
+
+ expect(igdb.discoverGames).toHaveBeenCalledWith({
+ sort: "hypes_desc",
+ minHypes: 1,
+ releasedFrom: "2026-10-26",
+ releasedTo: "2027-02-22",
+ pageSize: 24,
+ page: 2,
+ });
+ });
+
+ it("answers past page 50 empty, without asking IGDB", async () => {
+ const paged = await readGameListPage(
+ catalogList("game", "top-rated"),
+ 51,
+ "2026-10-25",
+ );
+
+ expect(paged.results).toEqual([]);
+ expect(igdb.discoverGames).not.toHaveBeenCalled();
+ });
+
+ it("reads today in UTC for the landing and the list page alike", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-10-25T23:30:00-05:00"));
+ igdb.discoverGames.mockResolvedValue({ results: [] });
+
+ await readTaggedCatalogList(catalogList("game", "recently-released"), 1);
+ await readTaggedCatalogGridPage(
+ catalogList("game", "recently-released"),
+ 1,
+ );
+
+ expect(igdb.discoverGames.mock.calls).toEqual([
+ [expect.objectContaining({ releasedTo: "2026-10-26", page: 1 })],
+ [expect.objectContaining({ releasedTo: "2026-10-26", page: 1 })],
+ ]);
+ });
+});
+
+describe("lastListPage (spec 0028, AC-10)", () => {
+ const game = (totalResults: number | null) => ({
+ mediaType: "game" as const,
+ paged: { page: 1, results: [], hasNextPage: false, totalResults },
+ });
+
+ it("counts a games list's pages by 24, capped at 50, or null", () => {
+ expect(lastListPage(game(49))).toBe(3);
+ expect(lastListPage(game(48))).toBe(2);
+ expect(lastListPage(game(100_000))).toBe(50);
+ expect(lastListPage(game(null))).toBeNull();
+ });
+});
+
+describe("listHasNextPage (spec 0028, AC-10)", () => {
+ const game = (totalResults: number | null, hasNextPage: boolean) => ({
+ mediaType: "game" as const,
+ paged: { page: 1, results: [], hasNextPage, totalResults },
+ });
+
+ it("offers Next on a games list with no count while IGDB has more", () => {
+ expect(listHasNextPage(game(null, true), 1)).toBe(true);
+ expect(listHasNextPage(game(null, false), 1)).toBe(false);
+ });
+
+ it("never offers Next from page 50", () => {
+ expect(listHasNextPage(game(null, true), 49)).toBe(true);
+ expect(listHasNextPage(game(null, true), 50)).toBe(false);
+ });
+
+ it("leaves Next to the pager when the last page is known", () => {
+ expect(listHasNextPage(game(100, true), 1)).toBe(false);
+ expect(
+ listHasNextPage(
+ {
+ mediaType: "tv",
+ paged: { page: 1, results: [], totalPages: 3, totalResults: 60 },
+ },
+ 1,
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/lib/catalog-list-read.ts b/lib/catalog-list-read.ts
index d2355fc..a1fd10f 100644
--- a/lib/catalog-list-read.ts
+++ b/lib/catalog-list-read.ts
@@ -1,11 +1,27 @@
+import { connection } from "next/server";
+
+import {
+ gameListOptions,
+ isPopularGameList,
+ POPULAR_GAME_LISTS,
+} from "@/lib/catalog/game-lists";
import { readGridPage } from "@/lib/catalog/grid-page";
import type {
CatalogList,
+ GameCatalogList,
MovieCatalogList,
MovieListSlug,
+ TmdbCatalogList,
TvCatalogList,
TvListSlug,
} from "@/lib/catalog/lists";
+import {
+ discoverGames,
+ type GameSummary,
+ getPopularGames,
+ type Paged as IgdbPaged,
+} from "@/lib/igdb";
+import { IGDB_LIST_PAGE_SIZE, IGDB_MAX_PAGE } from "@/lib/igdb/constants";
import {
discoverMovies,
discoverTvShows,
@@ -17,6 +33,7 @@ import {
type TvListKind,
type TvShowSummary,
} from "@/lib/tmdb";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
const TV_KINDS: Record, TvListKind> = {
trending: "trending",
@@ -56,11 +73,11 @@ export function readCatalogList(
page: number,
): Promise>;
export function readCatalogList(
- list: CatalogList,
+ list: TmdbCatalogList,
page: number,
): Promise | Paged>;
export function readCatalogList(
- list: CatalogList,
+ list: TmdbCatalogList,
page: number,
): Promise | Paged> {
if (list.mediaType === "tv") {
@@ -73,23 +90,115 @@ export function readCatalogList(
: getMovieList(MOVIE_KINDS[list.slug], { page });
}
-/** One page of a list, tagged with its media type so a renderer can narrow it. */
+/**
+ * One page of a list, tagged with its media type so a renderer can narrow it.
+ * A games page carries IGDB's paging (a count or null, and `hasNextPage`)
+ * rather than TMDB's `totalPages` (spec 0028).
+ */
export type TaggedCatalogPage =
| { mediaType: "tv"; paged: Paged }
- | { mediaType: "movie"; paged: Paged };
+ | { mediaType: "movie"; paged: Paged }
+ | { mediaType: "game"; paged: IgdbPaged };
+
+/**
+ * One 24 game page of a games list (spec 0028, AC-2, AC-4, AC-10), the one
+ * place a games slug becomes an IGDB call.
+ *
+ * A popularity list is read whole and sliced, so `totalResults` is the exact
+ * number of visible games. A discover list asks IGDB for the page at 24, so
+ * its count is IGDB's own or null. A page past IGDB's 50 is answered empty,
+ * never asked for, so it reads as "no such page" rather than a validation
+ * error. A landing section and its list page call this with the same
+ * arguments, so they share one cache entry.
+ *
+ * @param today The current UTC day, read by the caller after `connection()`;
+ * only the discover windows use it.
+ * @throws {IgdbError} When IGDB fails; callers catch it into their own state.
+ */
+export async function readGameListPage(
+ list: GameCatalogList,
+ page: number,
+ today: string,
+): Promise> {
+ if (isPopularGameList(list.slug)) {
+ const games = await getPopularGames(POPULAR_GAME_LISTS[list.slug]);
+ const start = (page - 1) * IGDB_LIST_PAGE_SIZE;
+ return {
+ page,
+ results: games.slice(start, start + IGDB_LIST_PAGE_SIZE),
+ hasNextPage: start + IGDB_LIST_PAGE_SIZE < games.length,
+ totalResults: games.length,
+ };
+ }
+ if (page > IGDB_MAX_PAGE) {
+ return { page, results: [], hasNextPage: false, totalResults: null };
+ }
+ return discoverGames({ ...gameListOptions(list.slug, today), page });
+}
+
+/**
+ * `readGameListPage` at request time: `connection()` first, so `pnpm build`
+ * asks IGDB nothing, then today's UTC day (spec 0028, AC-18), read once per
+ * request through `requestTodayUtc`, so the windows and the Coming soon
+ * dates agree on the day.
+ */
+async function readTaggedGamePage(
+ list: GameCatalogList,
+ page: number,
+): Promise {
+ await connection();
+ return {
+ mediaType: "game",
+ paged: await readGameListPage(list, page, requestTodayUtc()),
+ };
+}
+
+/**
+ * The last page a list reaches, or null when it is not known (spec 0021,
+ * AC-9; spec 0028, AC-10). A TMDB list knows its pages. A games list knows
+ * them from a count, capped at IGDB's 50; with no count it does not, and the
+ * pager offers Next only while IGDB says there is more.
+ */
+export function lastListPage(result: TaggedCatalogPage): number | null {
+ if (result.mediaType !== "game") return result.paged.totalPages;
+ const { totalResults } = result.paged;
+ if (totalResults === null) return null;
+ return Math.min(IGDB_MAX_PAGE, Math.ceil(totalResults / IGDB_LIST_PAGE_SIZE));
+}
+
+/**
+ * Whether the pager offers Next on a list whose last page is unknown (spec
+ * 0028, AC-10): only a games list with no count, only while IGDB says there
+ * is more, and never past IGDB's page 50. A list with a known last page
+ * leaves Next to `PaginationLinks`, which counts to it.
+ */
+export function listHasNextPage(
+ result: TaggedCatalogPage,
+ page: number,
+): boolean {
+ return (
+ lastListPage(result) === null &&
+ result.mediaType === "game" &&
+ result.paged.hasNextPage &&
+ page < IGDB_MAX_PAGE
+ );
+}
/**
* `readCatalogList` with its result tagged, so a component rendering any list
* picks the show or movie card by narrowing on `mediaType` rather than by a
* cast. The very same call underneath, so it shares the cache entry. The
- * landing rows read TMDB page 1 through this.
+ * landing rows read TMDB page 1 through this. A games list reads its 24 game
+ * page, the same one its list page shows (spec 0028, AC-3).
*
* @throws {TmdbError} When TMDB fails.
+ * @throws {IgdbError} When IGDB fails, for a games list.
*/
export async function readTaggedCatalogList(
list: CatalogList,
page: number,
): Promise {
+ if (list.mediaType === "game") return readTaggedGamePage(list, page);
return list.mediaType === "tv"
? { mediaType: "tv", paged: await readCatalogList(list, page) }
: { mediaType: "movie", paged: await readCatalogList(list, page) };
@@ -111,6 +220,8 @@ export async function readTaggedCatalogGridPage(
list: CatalogList,
page: number,
): Promise {
+ // A games list page is already 24 games, so the grid page is the list page.
+ if (list.mediaType === "game") return readTaggedGamePage(list, page);
return list.mediaType === "tv"
? {
mediaType: "tv",
diff --git a/lib/catalog/game-lists.test.ts b/lib/catalog/game-lists.test.ts
new file mode 100644
index 0000000..810b10c
--- /dev/null
+++ b/lib/catalog/game-lists.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+
+import { gameListOptions, isPopularGameList, shiftUtcDay } from "./game-lists";
+
+/** covers: spec 0028, AC-2 */
+describe("gameListOptions", () => {
+ it("windows Recently released over 60 inclusive days, across a year edge", () => {
+ expect(gameListOptions("recently-released", "2027-01-15")).toEqual({
+ sort: "hypes_desc",
+ minHypes: 1,
+ releasedFrom: "2026-11-17",
+ releasedTo: "2027-01-15",
+ pageSize: 24,
+ });
+ });
+
+ it("windows Coming soon from tomorrow to 120 days out, across a month edge", () => {
+ expect(gameListOptions("coming-soon", "2026-10-31")).toEqual({
+ sort: "hypes_desc",
+ minHypes: 1,
+ releasedFrom: "2026-11-01",
+ releasedTo: "2027-02-28",
+ pageSize: 24,
+ });
+ });
+
+ it("asks Top rated for at least 100 ratings and no window", () => {
+ expect(gameListOptions("top-rated", "2026-10-25")).toEqual({
+ sort: "rating_desc",
+ minRatingCount: 100,
+ pageSize: 24,
+ });
+ });
+});
+
+describe("shiftUtcDay", () => {
+ it("steps through a leap day", () => {
+ expect(shiftUtcDay("2028-02-28", 1)).toBe("2028-02-29");
+ expect(shiftUtcDay("2028-03-01", -1)).toBe("2028-02-29");
+ });
+});
+
+describe("isPopularGameList", () => {
+ it("is true only for the two popularity lists", () => {
+ expect(isPopularGameList("trending")).toBe(true);
+ expect(isPopularGameList("popular")).toBe(true);
+ expect(isPopularGameList("top-rated")).toBe(false);
+ });
+});
diff --git a/lib/catalog/game-lists.ts b/lib/catalog/game-lists.ts
new file mode 100644
index 0000000..28e2a81
--- /dev/null
+++ b/lib/catalog/game-lists.ts
@@ -0,0 +1,83 @@
+import { IGDB_LIST_PAGE_SIZE } from "@/lib/igdb/constants";
+import type { DiscoverGamesOptions, PopularGamesKind } from "@/lib/igdb/types";
+
+import type { GameListSlug } from "./lists";
+
+/**
+ * Where each games list comes from (spec 0028, AC-2): the two popularity
+ * lists by their IGDB popularity kind, the other three as `discoverGames`
+ * options. Pure and free of `server-only`, so the tests and the proxy side
+ * of `lib/catalog/` can import it; it reads no clock, the caller passes
+ * `today`.
+ */
+
+/** The lists read from IGDB's popularity primitives. */
+export const POPULAR_GAME_LISTS = {
+ trending: "trending",
+ popular: "popular",
+} as const satisfies Partial>;
+
+export type PopularGameListSlug = keyof typeof POPULAR_GAME_LISTS;
+export type DiscoverGameListSlug = Exclude;
+
+/** Recently released reaches back this many days before today, inclusive. */
+const RECENT_DAYS_BACK = 59;
+
+/** Coming soon reaches this many days past today. */
+const COMING_SOON_DAYS = 120;
+
+/** Top rated counts only games with at least this many ratings. */
+const TOP_RATED_MIN_RATINGS = 100;
+
+/** True for a slug read from popularity rather than discover. */
+export function isPopularGameList(
+ slug: GameListSlug,
+): slug is PopularGameListSlug {
+ return slug in POPULAR_GAME_LISTS;
+}
+
+/** The UTC day `days` after `day` (before it when negative), `YYYY-MM-DD`. */
+export function shiftUtcDay(day: string, days: number): string {
+ const date = new Date(`${day}T00:00:00Z`);
+ date.setUTCDate(date.getUTCDate() + days);
+ return date.toISOString().slice(0, 10);
+}
+
+/**
+ * The `discoverGames` options of a discover list for one UTC day (spec 0028,
+ * AC-2). Both windows are inclusive: Recently released is `today − 59` to
+ * `today`, 60 days, and Coming soon `today + 1` to `today + 120`. Because the
+ * day is in the options, a landing section and its list page share one cache
+ * entry within a UTC day.
+ *
+ * @param today The current UTC day, read after `connection()` by the caller.
+ */
+export function gameListOptions(
+ slug: DiscoverGameListSlug,
+ today: string,
+): DiscoverGamesOptions {
+ switch (slug) {
+ case "recently-released":
+ return {
+ sort: "hypes_desc",
+ minHypes: 1,
+ releasedFrom: shiftUtcDay(today, -RECENT_DAYS_BACK),
+ releasedTo: today,
+ pageSize: IGDB_LIST_PAGE_SIZE,
+ };
+ case "coming-soon":
+ return {
+ sort: "hypes_desc",
+ minHypes: 1,
+ releasedFrom: shiftUtcDay(today, 1),
+ releasedTo: shiftUtcDay(today, COMING_SOON_DAYS),
+ pageSize: IGDB_LIST_PAGE_SIZE,
+ };
+ case "top-rated":
+ return {
+ sort: "rating_desc",
+ minRatingCount: TOP_RATED_MIN_RATINGS,
+ pageSize: IGDB_LIST_PAGE_SIZE,
+ };
+ }
+}
diff --git a/lib/catalog/landing.test.ts b/lib/catalog/landing.test.ts
index 36ccc69..be007ef 100644
--- a/lib/catalog/landing.test.ts
+++ b/lib/catalog/landing.test.ts
@@ -39,6 +39,23 @@ describe("landingSlots (AC-1)", () => {
]);
expect(slots.nowLabel).toBe("On TV today");
});
+
+ // spec 0028, AC-1, AC-9
+ it("maps the games landing's slots, New releases in the sidebar", () => {
+ const slots = landingSlots("game");
+ expect(
+ [slots.trending, slots.popular, slots.now, slots.upcoming, slots.top].map(
+ (list) => `${list.mediaType}/${list.slug}`,
+ ),
+ ).toEqual([
+ "game/trending",
+ "game/popular",
+ "game/recently-released",
+ "game/coming-soon",
+ "game/top-rated",
+ ]);
+ expect(slots.nowLabel).toBe("New releases");
+ });
});
describe("LANDING_COUNTS (AC-2)", () => {
@@ -68,6 +85,18 @@ describe("nowBlurb (decision 12)", () => {
expect(nowBlurb(list, 1)).toBe("1 show airing today.");
});
+ // spec 0028, AC-9
+ it("counts games for the games landing, never with the movie line", () => {
+ const list = landingSlots("game").now;
+ expect(nowBlurb(list, 12)).toBe(
+ "The 12 most anticipated games of the last 60 days.",
+ );
+ expect(nowBlurb(list, 1)).toBe(
+ "The 1 most anticipated game of the last 60 days.",
+ );
+ expect(nowBlurb(list, 0)).toBeNull();
+ });
+
it("says nothing when nothing is rendered", () => {
expect(nowBlurb(landingSlots("movie").now, 0)).toBeNull();
});
diff --git a/lib/catalog/landing.ts b/lib/catalog/landing.ts
index f62f27d..41eff0a 100644
--- a/lib/catalog/landing.ts
+++ b/lib/catalog/landing.ts
@@ -1,5 +1,5 @@
import { type CatalogList, catalogList } from "./lists";
-import type { MediaType } from "./media-type";
+import type { LibraryType } from "./media-type";
/**
* Which list fills each part of a catalog landing, and the words only the
@@ -39,9 +39,29 @@ const MOVIE_SLOTS: LandingSlots = {
top: catalogList("movie", "top-rated"),
};
+/**
+ * The games landing (spec 0028, AC-1, AC-9): Recently released takes the
+ * sticky sidebar under its `New releases` label, and Coming soon the counter
+ * moving rows.
+ */
+const GAME_SLOTS: LandingSlots = {
+ trending: catalogList("game", "trending"),
+ popular: catalogList("game", "popular"),
+ now: catalogList("game", "recently-released"),
+ nowLabel: "New releases",
+ upcoming: catalogList("game", "coming-soon"),
+ top: catalogList("game", "top-rated"),
+};
+
+const SLOTS: Record = {
+ tv: TV_SLOTS,
+ movie: MOVIE_SLOTS,
+ game: GAME_SLOTS,
+};
+
/** One media type's landing slots. */
-export function landingSlots(mediaType: MediaType): LandingSlots {
- return mediaType === "tv" ? TV_SLOTS : MOVIE_SLOTS;
+export function landingSlots(mediaType: LibraryType): LandingSlots {
+ return SLOTS[mediaType];
}
/**
@@ -68,8 +88,13 @@ export const LANDING_COUNTS = {
*/
export function nowBlurb(list: CatalogList, count: number): string | null {
if (count === 0) return null;
- if (list.mediaType === "tv") {
- return `${count} ${count === 1 ? "show" : "shows"} airing today.`;
+ switch (list.mediaType) {
+ case "tv":
+ return `${count} ${count === 1 ? "show" : "shows"} airing today.`;
+ case "movie":
+ return `${count} ${count === 1 ? "film" : "films"} in cinemas this week.`;
+ case "game":
+ // spec 0028, AC-9: its own branch, never the movie line.
+ return `The ${count} most anticipated ${count === 1 ? "game" : "games"} of the last 60 days.`;
}
- return `${count} ${count === 1 ? "film" : "films"} in cinemas this week.`;
}
diff --git a/lib/catalog/lists.test.ts b/lib/catalog/lists.test.ts
index 6b03182..ac2335d 100644
--- a/lib/catalog/lists.test.ts
+++ b/lib/catalog/lists.test.ts
@@ -41,7 +41,7 @@ describe("the list registry", () => {
it("names every list once", () => {
const keys = CATALOG_LISTS.map((list) => `${list.mediaType}/${list.slug}`);
- expect(new Set(keys).size).toBe(10);
+ expect(new Set(keys).size).toBe(15);
});
it("finds an entry by media type and slug", () => {
diff --git a/lib/catalog/lists.ts b/lib/catalog/lists.ts
index 6bbace9..f9aaa60 100644
--- a/lib/catalog/lists.ts
+++ b/lib/catalog/lists.ts
@@ -1,4 +1,4 @@
-import { CATALOG_PATHS, type MediaType } from "./media-type";
+import { CATALOG_PATHS, type LibraryType, type MediaType } from "./media-type";
/**
* The TV lists a landing row and its list page show, in row order (spec 0021,
@@ -22,8 +22,21 @@ export const MOVIE_LIST_SLUGS = [
"top-rated",
] as const;
+/**
+ * The games twin, under `app/games/` (spec 0028, AC-2, AC-9). Its lists come
+ * from IGDB, not TMDB, through `readGameListPage`.
+ */
+export const GAME_LIST_SLUGS = [
+ "trending",
+ "popular",
+ "recently-released",
+ "coming-soon",
+ "top-rated",
+] as const;
+
export type TvListSlug = (typeof TV_LIST_SLUGS)[number];
export type MovieListSlug = (typeof MOVIE_LIST_SLUGS)[number];
+export type GameListSlug = (typeof GAME_LIST_SLUGS)[number];
/** The words every list shows, wherever it is rendered (spec 0021, AC-1, AC-9). */
type ListText = {
@@ -42,7 +55,15 @@ export type MovieCatalogList = ListText & {
mediaType: "movie";
slug: MovieListSlug;
};
-export type CatalogList = TvCatalogList | MovieCatalogList;
+export type GameCatalogList = ListText & {
+ mediaType: "game";
+ slug: GameListSlug;
+ /** The cards print each game's release day: Coming soon (spec 0028, AC-8). */
+ datedCards?: true;
+};
+/** A TMDB list: the shows and movies the sitemap and TMDB reads take. */
+export type TmdbCatalogList = TvCatalogList | MovieCatalogList;
+export type CatalogList = TmdbCatalogList | GameCatalogList;
/**
* Every list, once (spec 0021, the registry). The routes, the proxy, the
@@ -131,13 +152,60 @@ export const CATALOG_LISTS: readonly CatalogList[] = [
note: "Ordered by TMDB community rating.",
pageNoun: "top rated movies",
},
+ {
+ mediaType: "game",
+ slug: "trending",
+ rowTitle: "Trending now",
+ heading: "Trending games",
+ note: "The games people are looking at most on IGDB right now.",
+ pageNoun: "trending games",
+ },
+ {
+ mediaType: "game",
+ slug: "popular",
+ rowTitle: "Popular",
+ heading: "Popular games",
+ note: "The games the most IGDB members are playing right now.",
+ pageNoun: "popular games",
+ },
+ {
+ mediaType: "game",
+ slug: "recently-released",
+ rowTitle: "Recently released",
+ heading: "Recently released games",
+ note: "Released in the last 60 days, most anticipated first, per IGDB.",
+ pageNoun: "recently released games",
+ },
+ {
+ mediaType: "game",
+ slug: "coming-soon",
+ rowTitle: "Coming soon",
+ heading: "Games coming soon",
+ note: "Releasing in the next 120 days, most anticipated first, per IGDB.",
+ pageNoun: "upcoming games",
+ datedCards: true,
+ },
+ {
+ mediaType: "game",
+ slug: "top-rated",
+ rowTitle: "Top rated",
+ heading: "Top rated games",
+ note: "Ordered by IGDB community rating, among games with at least 100 ratings.",
+ pageNoun: "top rated games",
+ },
];
-/** One media type's lists, in row order (spec 0021, AC-1). */
+/**
+ * One media type's lists, in row order (spec 0021, AC-1). The `MediaType`
+ * overload never returns a game list, which is what keeps the sitemap, which
+ * asks for `tv` and `movie`, free of `/games` URLs until G10 (spec 0028,
+ * AC-13).
+ */
export function catalogListsFor(mediaType: "tv"): TvCatalogList[];
export function catalogListsFor(mediaType: "movie"): MovieCatalogList[];
-export function catalogListsFor(mediaType: MediaType): CatalogList[];
-export function catalogListsFor(mediaType: MediaType): CatalogList[] {
+export function catalogListsFor(mediaType: "game"): GameCatalogList[];
+export function catalogListsFor(mediaType: MediaType): TmdbCatalogList[];
+export function catalogListsFor(mediaType: LibraryType): CatalogList[] {
return CATALOG_LISTS.filter((list) => list.mediaType === mediaType);
}
@@ -150,7 +218,11 @@ export function catalogList(
mediaType: "movie",
slug: MovieListSlug,
): MovieCatalogList;
-export function catalogList(mediaType: MediaType, slug: string): CatalogList {
+export function catalogList(
+ mediaType: "game",
+ slug: GameListSlug,
+): GameCatalogList;
+export function catalogList(mediaType: LibraryType, slug: string): CatalogList {
const list = CATALOG_LISTS.find(
(entry) => entry.mediaType === mediaType && entry.slug === slug,
);
@@ -159,9 +231,9 @@ export function catalogList(mediaType: MediaType, slug: string): CatalogList {
}
/**
- * A list's page: `/shows/{slug}` or `/movies/{slug}` (spec 0021, AC-9). Takes
- * the registry entry, not a media type and slug, so a caller cannot pair a
- * slug with the wrong catalog.
+ * A list's page: `/shows/{slug}`, `/movies/{slug}` or `/games/{slug}` (spec
+ * 0021, AC-9; spec 0028, AC-9). Takes the registry entry, not a media type and
+ * slug, so a caller cannot pair a slug with the wrong catalog.
*/
export function listPath(list: CatalogList): string {
return `${CATALOG_PATHS[list.mediaType]}/${list.slug}`;
@@ -180,11 +252,11 @@ export function listPageHref(list: CatalogList, page: number): string {
/**
* True for a path segment that names one of this media type's lists, matched
* exactly and case sensitively, so `/shows/Popular` and `/shows/upcoming`
- * stay 404s (spec 0021, AC-13). The proxy checks it before the numeric id
- * parser.
+ * stay 404s (spec 0021, AC-13; spec 0028, AC-12). The proxy checks it before
+ * the numeric id parser.
*/
export function isCatalogListSlug(
- mediaType: MediaType,
+ mediaType: LibraryType,
segment: string,
): boolean {
return CATALOG_LISTS.some(
diff --git a/lib/catalog/media-type.test.ts b/lib/catalog/media-type.test.ts
index 0466396..415a6f6 100644
--- a/lib/catalog/media-type.test.ts
+++ b/lib/catalog/media-type.test.ts
@@ -132,6 +132,15 @@ describe("libraryTypeForLocation", () => {
expect(libraryTypeForLocation("/watchlist", "game", false)).toBe("tv");
});
+ // spec 0028, AC-14, AC-16
+ it("is game on /games and below it while the flag is on, and no type when off", () => {
+ for (const path of ["/games", "/games/trending", "/games/1942"]) {
+ expect(libraryTypeForLocation(path, null, true)).toBe("game");
+ expect(libraryTypeForLocation(path, null, false)).toBeNull();
+ }
+ expect(libraryTypeForLocation("/gamesx", null, true)).toBeNull();
+ });
+
it("matches mediaTypeForLocation for the catalogs", () => {
expect(libraryTypeForLocation("/upcoming", "movie", true)).toBe("movie");
expect(libraryTypeForLocation("/account", null, true)).toBeNull();
diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts
index 9e7a3db..7eec751 100644
--- a/lib/catalog/media-type.ts
+++ b/lib/catalog/media-type.ts
@@ -16,12 +16,26 @@ export type LibraryType = MediaType | "game";
/**
* Each catalog's browse page, which also types every title page below it
- * (feature 22). The one place these two paths are written as a pair.
+ * (feature 22). The one place these paths are written together. `/games`
+ * joined in spec 0028; the record is keyed by `LibraryType`, while
+ * `MediaType` stays `tv | movie`.
*/
export const CATALOG_PATHS = {
tv: "/shows",
movie: "/movies",
-} as const satisfies Record;
+ game: "/games",
+} as const satisfies Record;
+
+/**
+ * The catalog each media type's titles come from, named in every error and
+ * empty copy that blames it (spec 0028, AC-11). The one place a type is
+ * paired with its source, so a games state never says TMDB.
+ */
+export const CATALOG_SOURCES = {
+ tv: "TMDB",
+ movie: "TMDB",
+ game: "IGDB",
+} as const satisfies Record;
/**
* The three library pages, one per `LibraryList`, and the only typed pages a
@@ -147,17 +161,29 @@ export function mediaTypeForLocation(
return null;
}
+/** True on `/games` and every path under it (spec 0028, AC-14). */
+export function isGamesPath(pathname: string | null): boolean {
+ if (pathname === null) return false;
+ return (
+ pathname === CATALOG_PATHS.game ||
+ pathname.startsWith(`${CATALOG_PATHS.game}/`)
+ );
+}
+
/**
* `mediaTypeForLocation` for the controls that may list games: the navbar
- * tabs and the Library links (spec 0027, AC-3, AC-4). It is `game` only on a
- * library page asking for it while the games library is on, and otherwise
- * exactly `mediaTypeForLocation`.
+ * tabs, the Library links and the logo (spec 0027, AC-3, AC-4; spec 0028,
+ * AC-14, AC-16). It is `game` on `/games` and its list and game pages, and on
+ * a library page asking for it, while the games library is on, and otherwise
+ * exactly `mediaTypeForLocation`. With the library off a games page has no
+ * type, like a page about neither catalog.
*/
export function libraryTypeForLocation(
pathname: string | null,
typeParam: string | null,
gamesEnabled: boolean,
): LibraryType | null {
+ if (isGamesPath(pathname)) return gamesEnabled ? "game" : null;
if (
isLibraryPath(pathname) &&
parseLibraryTypeParam(typeParam, gamesEnabled) === "game"
diff --git a/lib/igdb/AGENTS.md b/lib/igdb/AGENTS.md
index e52a868..740f9ae 100644
--- a/lib/igdb/AGENTS.md
+++ b/lib/igdb/AGENTS.md
@@ -7,10 +7,11 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it.
## What lives where
-- `index.ts` is the only entry point a page may import. It exports the six
+- `index.ts` is the only entry point a page may import. It exports the seven
cached reads (`getGame`, `getGamesByIds`, `searchGames`, `discoverGames`,
- `getGameGenres`, `getGamePlatforms`), `isIgdbNotFound`, `IgdbError`,
- `IgdbValidationError` and the types in the spec's design section.
+ `getPopularGames`, `getGameGenres`, `getGamePlatforms`), `isIgdbNotFound`,
+ `IgdbError`, `IgdbValidationError` and the types in the spec's design
+ section, plus `PopularGamesKind` (spec 0028).
`surface.test.ts` fails on any other export, runtime or type.
- `reads.ts` holds every `use cache` wrapper and is the only file that imports
`next/cache`.
@@ -25,7 +26,12 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it.
holds the catalog rule; `limiter.ts`, `log.ts`, `constants.ts`,
`schemas.ts`, `normalize.ts`, `types.ts`, `validation.ts`, `errors.ts`,
`result.ts`, then the reads in `games.ts`, `batch.ts`, `search.ts`,
- `discover.ts` (both paging through `summary-page.ts`) and `reference.ts`.
+ `discover.ts` (both paging through `summary-page.ts`), `popularity.ts`
+ (Trending and Popular: the `popularity_primitives` ranks, then the batch
+ read, spec 0028) and `reference.ts`.
+- Pure code outside this folder (`lib/catalog/game-lists.ts`,
+ `lib/catalog-list-read.ts`) may import `constants.ts` and `types.ts`
+ directly, since `index.ts` is server only; a page or component never does.
- Pure files (no `server-only`, so tests and the live check read them):
`catalog-filter.ts`, `constants.ts`, `errors.ts`, `query.ts`, `types.ts`,
`validation.ts`. Every other file starts with `import "server-only";`.
@@ -45,8 +51,9 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it.
- **A failure crosses the cache boundary as a plain `IgdbResult`**, and the
wrapper rebuilds the `IgdbError`, so `kind` and `isIgdbNotFound` survive.
Never throw an `IgdbError` out of a cached scope.
-- **Lifetimes**: `hours` for `getGame` and `getGamesByIds`, `minutes` for
- search and discover, `max` for the reference lists. A failure takes
+- **Lifetimes**: `hours` for `getGame`, `getGamesByIds` and
+ `getPopularGames`, `minutes` for search and discover, `max` for the
+ reference lists. A failure takes
`failureProfile(kind)`: `seconds` for `timeout`, `rate_limited` and
`upstream`, `minutes` for the rest.
- **The token and the limiter live in process memory**, never in a cache entry,
@@ -70,14 +77,18 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it.
full precision, and always labelled as the community rating on a page.
- **Every filter is in the query**, so every result meets it and IGDB's
`X-Count` header is a truthful `totalResults` (null when absent). A page asks
- for 21 and returns 20 to know `hasNextPage`.
+ for one more than its size and returns the size to know `hasNextPage`.
+ Discover accepts `pageSize` 20 (the default) or `IGDB_LIST_PAGE_SIZE` 24,
+ the games list pages' grid (spec 0028, AC-5); the size is in the cache key.
- **The module never reads the clock for a filter.** Discover callers pass UTC
days; only the year bound of a filter reads the current year.
- **Covers are built at `t_cover_big_2x` (528px), the ceiling, not the size
every image loads.** `lib/images/igdb-loader.ts` steps a `next/image` width
down `IGDB_COVER_LADDER` in `constants.ts` and never past the URL's own size
- (spec 0025, AC-12). It lives outside this folder because it runs in the
- browser; `lib/images/loader.ts`, the `loaderFile`, sends it every IGDB URL.
+ (spec 0025, AC-12). A game's wide art (`artUrl`, built at `t_1080p`) steps
+ down `IGDB_ART_LADDER` the same way (spec 0028, AC-6). The loader lives
+ outside this folder because it runs in the browser; `lib/images/loader.ts`,
+ the `loaderFile`, sends it every IGDB URL.
- Adding a field is a three place change: `schemas.ts`, `types.ts`,
`normalize.ts`, plus the field list in `constants.ts`.
diff --git a/lib/igdb/constants.ts b/lib/igdb/constants.ts
index 584edbf..63ba570 100644
--- a/lib/igdb/constants.ts
+++ b/lib/igdb/constants.ts
@@ -37,6 +37,29 @@ export const IGDB_COVER_LADDER: readonly { size: string; width: number }[] = [
{ size: "t_cover_big_2x", width: 528 },
];
+/**
+ * The art size the module builds, artwork or screenshot alike: the ceiling
+ * the loader steps down from for the games hero and the wide cards (spec
+ * 0028, AC-6). 1920px, so the full width hero is sharp.
+ */
+export const IGDB_ART_SIZE = "t_1080p";
+
+/**
+ * IGDB's art sizes the loader may pick, narrowest first, with their pixel
+ * widths (spec 0028, AC-6). Kept apart from the cover ladder, so an art URL
+ * is never matched against cover rungs. The two screenshot sizes are fixed
+ * 16:9 crops (555 and 940 wide, measured live on 2026-10-25); `t_720p` and
+ * `t_1080p` keep the image's own shape at that height, so their width is the
+ * 16:9 width and a narrower image comes back narrower. `pnpm igdb:live`
+ * checks each.
+ */
+export const IGDB_ART_LADDER: readonly { size: string; width: number }[] = [
+ { size: "t_screenshot_med", width: 555 },
+ { size: "t_screenshot_big", width: 940 },
+ { size: "t_720p", width: 1280 },
+ { size: IGDB_ART_SIZE, width: 1920 },
+];
+
/**
* The game types the catalog shows, by IGDB id (games.md, settled filter).
* Every other id (DLC 1, expansion 2, bundle 3, mod 5, episode 6, season 7,
@@ -50,6 +73,17 @@ export const IGDB_VISIBLE_GAME_TYPES = {
10: "expanded_game",
} as const;
+/**
+ * IGDB's own popularity types behind the two popularity lists, by id, with
+ * the name `pnpm igdb:live` checks against `popularity_types` (spec 0028,
+ * AC-2, AC-6). Both have IGDB as their source (121), confirmed live on
+ * 2026-10-25.
+ */
+export const IGDB_POPULARITY_TYPES = {
+ trending: { id: 1, name: "Visits" },
+ popular: { id: 3, name: "Playing" },
+} as const;
+
/** The Erotic theme, hidden the way adult movies are (games.md). */
export const IGDB_EROTIC_THEME_ID = 42;
@@ -92,6 +126,18 @@ export const IGDB_BATCH_LIMIT = 500;
/** Results per page of a search or discover read (AC-12). */
export const IGDB_PAGE_SIZE = 20;
+/**
+ * Games on one games list page, and the page size every games list discover
+ * asks for: the grid fills every row at 24 (spec 0028, AC-5).
+ */
+export const IGDB_LIST_PAGE_SIZE = 24;
+
+/** The page sizes discover accepts: the default and the list page's. */
+export const IGDB_DISCOVER_PAGE_SIZES = [
+ IGDB_PAGE_SIZE,
+ IGDB_LIST_PAGE_SIZE,
+] as const;
+
/** The last page a caller may ask for; a later one throws (AC-12b). */
export const IGDB_MAX_PAGE = 50;
@@ -125,11 +171,14 @@ export const IGDB_GAME_FIELDS = [
/**
* The `fields` clause of a batch, search or discover read. Genres, platforms
- * and themes stay ids; their names come from the reference lists.
+ * and themes stay ids; their names come from the reference lists. Artworks
+ * and screenshots give a card its wide art (spec 0028, AC-6).
*/
export const IGDB_SUMMARY_FIELDS = [
"name",
"cover.image_id",
+ "artworks.image_id",
+ "screenshots.image_id",
"first_release_date",
"rating",
"rating_count",
diff --git a/lib/igdb/discover.ts b/lib/igdb/discover.ts
index 9631fa7..dc9efba 100644
--- a/lib/igdb/discover.ts
+++ b/lib/igdb/discover.ts
@@ -58,5 +58,6 @@ export async function fetchDiscoverGames(
where,
sort,
page: valid.page,
+ pageSize: valid.pageSize,
});
}
diff --git a/lib/igdb/igdb.live.ts b/lib/igdb/igdb.live.ts
index a500af6..14d7aa4 100644
--- a/lib/igdb/igdb.live.ts
+++ b/lib/igdb/igdb.live.ts
@@ -2,23 +2,31 @@ import { describe, expect, it } from "vitest";
import { catalogWhereClause, isCatalogVisible } from "./catalog-filter";
import { igdbRequest } from "./client";
import {
+ IGDB_ART_LADDER,
IGDB_COVER_LADDER,
IGDB_EROTIC_THEME_ID,
IGDB_IMAGE_BASE,
+ IGDB_POPULARITY_TYPES,
IGDB_SUMMARY_FIELDS,
IGDB_VISIBLE_GAME_TYPES,
} from "./constants";
import { fetchDiscoverGames as fetchValidDiscoverGames } from "./discover";
import { getIgdbCredentials } from "./env";
import { fetchGame as fetchValidGame } from "./games";
+import { fetchPopularGames as fetchValidPopularGames } from "./popularity";
import { buildQuery, type Condition, cond } from "./query";
import { fetchGameGenres, fetchGamePlatforms } from "./reference";
import { recordsSchema } from "./schemas";
import { fetchSearchGames as fetchValidSearchGames } from "./search";
import { getIgdbToken } from "./token";
-import type { DiscoverGamesOptions, SearchGamesOptions } from "./types";
+import type {
+ DiscoverGamesOptions,
+ PopularGamesKind,
+ SearchGamesOptions,
+} from "./types";
import {
assertGameId,
+ assertPopularKind,
validateDiscoverOptions,
validateSearchOptions,
} from "./validation";
@@ -32,6 +40,10 @@ const fetchSearchGames = async (options: SearchGamesOptions) =>
const fetchDiscoverGames = async (options: DiscoverGamesOptions) =>
fetchValidDiscoverGames(validateDiscoverOptions(options));
+/** The inner read, given the input `reads.ts` would check first. */
+const fetchPopularGames = async (kind: PopularGamesKind) =>
+ fetchValidPopularGames(assertPopularKind(kind));
+
/**
* LIVE CHECK. This file talks to the real Twitch and IGDB APIs.
*
@@ -318,6 +330,74 @@ describe.skipIf(!hasCredentials)("IGDB live check (real network)", () => {
}
});
+ // spec 0028, AC-6: the art ladder's widths are IGDB's real ones.
+ it("serves every art ladder rung at its stated width", async () => {
+ const [game] = (
+ await fetchDiscoverGames({ sort: "rating_count_desc", pageSize: 24 })
+ ).results.filter((result) => result.artUrl !== null);
+ expect(game, "a well known game with art").toBeDefined();
+ const file = (game.artUrl as string).split("/").at(-1);
+ for (const { size, width } of IGDB_ART_LADDER) {
+ const response = await fetch(`${IGDB_IMAGE_BASE}/${size}/${file}`);
+ expect(response.status, size).toBe(200);
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ const real = jpegSize(bytes);
+ if (size.startsWith("t_screenshot")) {
+ // A fixed 16:9 crop: always exactly this wide.
+ expect(real?.width, size).toBe(width);
+ } else {
+ // Fit to a height, 9/16 of the 16:9 width; never wider than that.
+ expect(real?.height, size).toBe((width * 9) / 16);
+ expect(real?.width ?? Infinity, size).toBeLessThanOrEqual(width);
+ }
+ }
+ });
+
+ // spec 0028, AC-2, AC-6: the pinned popularity types are IGDB's own.
+ it("matches the pinned popularity type ids to their names and source", async () => {
+ const { data } = await igdbRequest(
+ "popularity_types",
+ buildQuery({ fields: ["name", "popularity_source"], limit: 500 }),
+ recordsSchema,
+ );
+ const types = data as {
+ id: number;
+ name: string;
+ popularity_source: number;
+ }[];
+ for (const { id, name } of Object.values(IGDB_POPULARITY_TYPES)) {
+ const type = types.find((entry) => entry.id === id);
+ expect(type?.name).toBe(name);
+ expect(type?.popularity_source, `${name} comes from IGDB`).toBe(121);
+ }
+ });
+
+ it("reads both popularity lists as visible games in rank order", async () => {
+ for (const kind of ["trending", "popular"] as const) {
+ const games = await fetchPopularGames(kind);
+ expect(games.length, kind).toBeGreaterThan(24);
+ expect(games.every((game) => !game.hidden)).toBe(true);
+ expect(new Set(games.map((game) => game.id)).size).toBe(games.length);
+ console.info(`${kind}: ${games.length} visible games`);
+ }
+ });
+
+ // spec 0028, AC-2: Recently released keeps its hype floor only while the
+ // window holds at least 12 games; fewer means the spec switches the sort.
+ it("finds at least 12 hyped games released in the last 60 days", async () => {
+ const day = (offset: number) =>
+ new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
+ const page = await fetchDiscoverGames({
+ sort: "hypes_desc",
+ minHypes: 1,
+ releasedFrom: day(-59),
+ releasedTo: day(0),
+ pageSize: 24,
+ });
+ console.info(`recently released with hypes: ${page.totalResults}`);
+ expect(page.results.length).toBeGreaterThanOrEqual(12);
+ });
+
it("reads the genre and platform lists", async () => {
const genres = await fetchGameGenres();
const platforms = await fetchGamePlatforms();
@@ -333,11 +413,16 @@ describe.skipIf(hasCredentials)("IGDB live check", () => {
});
});
+/** A JPEG's pixel width, or null. Enough to confirm a cover size. */
+function jpegWidth(bytes: Uint8Array): number | null {
+ return jpegSize(bytes)?.width ?? null;
+}
+
/**
- * A JPEG's pixel width, read from its first start of frame marker, or null.
- * Enough to confirm a cover size without an image library.
+ * A JPEG's pixel size, read from its first start of frame marker, or null,
+ * without an image library.
*/
-function jpegWidth(bytes: Uint8Array): number | null {
+function jpegSize(bytes: Uint8Array): { width: number; height: number } | null {
let offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) return null;
@@ -349,7 +434,12 @@ function jpegWidth(bytes: Uint8Array): number | null {
marker !== 0xc4 &&
marker !== 0xc8 &&
marker !== 0xcc;
- if (isFrame) return (bytes[offset + 7] << 8) | bytes[offset + 8];
+ if (isFrame) {
+ return {
+ height: (bytes[offset + 5] << 8) | bytes[offset + 6],
+ width: (bytes[offset + 7] << 8) | bytes[offset + 8],
+ };
+ }
offset += 2 + length;
}
return null;
diff --git a/lib/igdb/index.ts b/lib/igdb/index.ts
index ba06c44..db6eeee 100644
--- a/lib/igdb/index.ts
+++ b/lib/igdb/index.ts
@@ -26,6 +26,7 @@ export {
getGameGenres,
getGamePlatforms,
getGamesByIds,
+ getPopularGames,
searchGames,
} from "./reads";
export type {
@@ -37,5 +38,6 @@ export type {
GameSummary,
GameType,
Paged,
+ PopularGamesKind,
SearchGamesOptions,
} from "./types";
diff --git a/lib/igdb/normalize.test.ts b/lib/igdb/normalize.test.ts
index 03cdc32..073e4dd 100644
--- a/lib/igdb/normalize.test.ts
+++ b/lib/igdb/normalize.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ artUrl,
coverUrl,
normalizeGame,
normalizeSummary,
@@ -28,6 +29,46 @@ describe("coverUrl", () => {
});
});
+describe("artUrl (spec 0028, AC-6)", () => {
+ const at = (id: string) =>
+ `https://images.igdb.com/igdb/image/upload/t_1080p/${id}.jpg`;
+
+ it("picks the artwork with the lowest image id, whatever the order", () => {
+ expect(
+ artUrl({
+ artworks: [
+ { image_id: "arc" },
+ { image_id: "ara" },
+ { image_id: "arb" },
+ ],
+ screenshots: [{ image_id: "sc0" }],
+ }),
+ ).toBe(at("ara"));
+ });
+
+ it("falls back to the lowest screenshot when no artwork has an id", () => {
+ expect(
+ artUrl({
+ artworks: [{ image_id: " " }, null, {}],
+ screenshots: [{ image_id: "scb" }, { image_id: "sca" }],
+ }),
+ ).toBe(at("sca"));
+ });
+
+ it("answers null with no art at all", () => {
+ expect(artUrl({})).toBeNull();
+ expect(artUrl({ artworks: [], screenshots: null })).toBeNull();
+ });
+
+ it("is carried on a summary, and survives an odd art list", () => {
+ expect(
+ normalizeSummary({ id: 1, name: "G", artworks: [{ image_id: "ar1" }] })
+ .artUrl,
+ ).toBe(at("ar1"));
+ expect(normalizeSummary({ id: 1, name: "G" }).artUrl).toBeNull();
+ });
+});
+
describe("releaseDay", () => {
it("reads 23:30 UTC on 31 December as that day and year", () => {
expect(releaseDay(Date.UTC(2017, 11, 31, 23, 30) / 1000)).toEqual({
diff --git a/lib/igdb/normalize.ts b/lib/igdb/normalize.ts
index 6dd9eee..6afd473 100644
--- a/lib/igdb/normalize.ts
+++ b/lib/igdb/normalize.ts
@@ -1,6 +1,7 @@
import "server-only";
import { isCatalogVisible } from "./catalog-filter";
import {
+ IGDB_ART_SIZE,
IGDB_COVER_SIZE,
IGDB_IMAGE_BASE,
IGDB_VISIBLE_GAME_TYPES,
@@ -29,6 +30,36 @@ export function coverUrl(imageId: string | null | undefined): string | null {
return id ? `${IGDB_IMAGE_BASE}/${IGDB_COVER_SIZE}/${id}.jpg` : null;
}
+/** Artworks or screenshots as `schemas.ts` leaves them. */
+type ImageList =
+ | readonly ({ image_id?: string | null } | null | undefined)[]
+ | null
+ | undefined;
+
+/**
+ * The wide art of a game (spec 0028, AC-6): the artwork with the lowest
+ * `image_id`, else the screenshot with the lowest, built at `t_1080p`, the
+ * ceiling the loader steps down from. IGDB promises no array order, so the
+ * lowest id in string order is what keeps the pick the same on every read.
+ */
+export function artUrl(record: {
+ artworks?: ImageList;
+ screenshots?: ImageList;
+}): string | null {
+ const id =
+ lowestImageId(record.artworks) ?? lowestImageId(record.screenshots);
+ return id === null ? null : `${IGDB_IMAGE_BASE}/${IGDB_ART_SIZE}/${id}.jpg`;
+}
+
+function lowestImageId(images: ImageList): string | null {
+ let lowest: string | null = null;
+ for (const image of images ?? []) {
+ const id = image?.image_id?.trim();
+ if (id && (lowest === null || id < lowest)) lowest = id;
+ }
+ return lowest;
+}
+
/**
* The UTC calendar day and year of a unix seconds timestamp. IGDB's earliest
* date can be a placeholder (a year or a quarter); that inaccuracy is accepted
@@ -141,6 +172,7 @@ export function normalizeSummary(record: GameSummaryRecord): GameSummary {
id: record.id,
name: record.name,
coverUrl: coverUrl(record.cover?.image_id),
+ artUrl: artUrl(record),
...releaseDay(record.first_release_date),
communityRating: communityRating(record.rating),
communityRatingCount: positiveOrNull(record.rating_count),
diff --git a/lib/igdb/popularity.test.ts b/lib/igdb/popularity.test.ts
new file mode 100644
index 0000000..09e9394
--- /dev/null
+++ b/lib/igdb/popularity.test.ts
@@ -0,0 +1,89 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ installIgdbMock,
+ restoreIgdbMock,
+ settle,
+} from "./__fixtures__/helpers";
+import { fetchPopularGames as fetchValidPopularGames } from "./popularity";
+import type { PopularGamesKind } from "./types";
+import { assertPopularKind } from "./validation";
+
+/** The inner read, given the input `reads.ts` would check first. */
+const fetchPopularGames = async (kind: PopularGamesKind) =>
+ fetchValidPopularGames(assertPopularKind(kind));
+
+/**
+ * covers: spec 0028, AC-4
+ *
+ * The popularity lists: the primitives in rank order, then one batch, with
+ * hidden and missing games dropped and the rank kept.
+ */
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+afterEach(restoreIgdbMock);
+
+const EDITION = { id: 30, name: "Deluxe", version_parent: 1 };
+
+describe("fetchPopularGames", () => {
+ it("asks for the top 500 of its type, then batches those ids", async () => {
+ const mock = installIgdbMock({
+ igdb: [
+ { body: [{ game_id: 2, value: 0.9 }] },
+ { body: [{ id: 2, name: "Two" }] },
+ ],
+ });
+
+ await settle(fetchPopularGames("popular"));
+
+ expect(mock.igdbCalls[0].url).toBe(
+ "https://api.igdb.com/v4/popularity_primitives",
+ );
+ expect(mock.body(0)).toBe(
+ "fields game_id,value; where (popularity_type = 3); sort value desc; limit 500;",
+ );
+ expect(mock.igdbCalls[1].url).toBe("https://api.igdb.com/v4/games");
+ expect(mock.body(1)).toContain("where (id = (2)); limit 500;");
+ });
+
+ it("reads Visits for trending", async () => {
+ const mock = installIgdbMock({ igdb: [{ body: [] }] });
+ await settle(fetchPopularGames("trending"));
+ expect(mock.body(0)).toContain("where (popularity_type = 1);");
+ });
+
+ it("keeps rank order, drops hidden, missing and repeated games", async () => {
+ installIgdbMock({
+ igdb: [
+ {
+ body: [
+ { game_id: 10, value: 0.9 },
+ { game_id: 30, value: 0.8 },
+ { game_id: 40, value: 0.7 },
+ { game_id: 10, value: 0.6 },
+ { value: 0.5 },
+ { game_id: 20, value: 0.4 },
+ ],
+ },
+ {
+ body: [{ id: 20, name: "Twenty" }, EDITION, { id: 10, name: "Ten" }],
+ },
+ ],
+ });
+
+ const { value } = await settle(fetchPopularGames("trending"));
+
+ expect(value?.map((game) => game.id)).toEqual([10, 20]);
+ });
+
+ it("fails as a whole when the batch fails, never a short list", async () => {
+ installIgdbMock({
+ igdb: [{ body: [{ game_id: 10, value: 0.9 }] }, { status: 400 }],
+ });
+
+ const { error } = await settle(fetchPopularGames("popular"));
+
+ expect(error).toBeDefined();
+ });
+});
diff --git a/lib/igdb/popularity.ts b/lib/igdb/popularity.ts
new file mode 100644
index 0000000..8875675
--- /dev/null
+++ b/lib/igdb/popularity.ts
@@ -0,0 +1,58 @@
+import "server-only";
+import { fetchGamesByIds } from "./batch";
+import { igdbRequest } from "./client";
+import { IGDB_BATCH_LIMIT, IGDB_POPULARITY_TYPES } from "./constants";
+import { buildQuery, cond } from "./query";
+import { popularityPrimitiveSchema, recordsSchema } from "./schemas";
+import type { GameSummary, PopularGamesKind } from "./types";
+import { canonicalGameIds, type ValidPopularKind } from "./validation";
+
+/**
+ * A popularity list, the source of the games landing's Trending and Popular
+ * (spec 0028, AC-4).
+ *
+ * IGDB ranks games in `popularity_primitives`, one row per game and
+ * popularity type, with ids only. So the read is two requests: the top 500
+ * rows of one type, then those games through the batch read. A hidden game
+ * (the catalog filter, evaluated on a read by id) and one the batch did not
+ * return are dropped, which can only shorten the list, never fail it. The
+ * rest keep the primitives' rank order, and since the whole visible list is
+ * known its length is an exact count.
+ *
+ * Both requests run inside one cached function in `reads.ts`, so a failure of
+ * either is one failure and a half read list is never cached as a success.
+ *
+ * @param kind Checked by `assertPopularKind` in `reads.ts`, before the cache.
+ * @returns At most 500 visible games, in rank order, each once.
+ */
+export async function fetchPopularGames(
+ kind: ValidPopularKind,
+): Promise {
+ // The brand blocks indexing by a branded string; the plain kind does not.
+ const plainKind: PopularGamesKind = kind;
+ const { data } = await igdbRequest(
+ "popularity_primitives",
+ buildQuery({
+ fields: ["game_id", "value"],
+ where: [cond.is("popularity_type", IGDB_POPULARITY_TYPES[plainKind].id)],
+ sort: { field: "value", direction: "desc" },
+ limit: IGDB_BATCH_LIMIT,
+ }),
+ recordsSchema,
+ );
+
+ const ranked: number[] = [];
+ const seen = new Set();
+ for (const row of data) {
+ const parsed = popularityPrimitiveSchema.safeParse(row);
+ if (!parsed.success || seen.has(parsed.data.game_id)) continue;
+ seen.add(parsed.data.game_id);
+ ranked.push(parsed.data.game_id);
+ }
+
+ const { found } = await fetchGamesByIds(canonicalGameIds(ranked));
+ const byId = new Map(found.map((game) => [game.id, game]));
+ return ranked
+ .map((id) => byId.get(id))
+ .filter((game): game is GameSummary => game !== undefined && !game.hidden);
+}
diff --git a/lib/igdb/query.ts b/lib/igdb/query.ts
index 2335dea..4ab17c2 100644
--- a/lib/igdb/query.ts
+++ b/lib/igdb/query.ts
@@ -23,7 +23,9 @@ export type IgdbField =
| "first_release_date"
| "rating"
| "rating_count"
- | "hypes";
+ | "hypes"
+ | "popularity_type"
+ | "value";
declare const conditionBrand: unique symbol;
diff --git a/lib/igdb/reads.test.ts b/lib/igdb/reads.test.ts
index eb066e6..ba22dbd 100644
--- a/lib/igdb/reads.test.ts
+++ b/lib/igdb/reads.test.ts
@@ -23,6 +23,7 @@ const inner = {
fetchDiscoverGames: vi.fn(),
fetchGameGenres: vi.fn(),
fetchGamePlatforms: vi.fn(),
+ fetchPopularGames: vi.fn(),
};
vi.mock("next/cache", () => ({
@@ -40,6 +41,9 @@ vi.mock("./search", () => ({
vi.mock("./discover", () => ({
fetchDiscoverGames: (options: unknown) => inner.fetchDiscoverGames(options),
}));
+vi.mock("./popularity", () => ({
+ fetchPopularGames: (kind: unknown) => inner.fetchPopularGames(kind),
+}));
vi.mock("./reference", () => ({
fetchGameGenres: () => inner.fetchGameGenres(),
fetchGamePlatforms: () => inner.fetchGamePlatforms(),
@@ -51,6 +55,7 @@ const {
getGameGenres,
getGamePlatforms,
getGamesByIds,
+ getPopularGames,
searchGames,
} = await import("./reads");
@@ -74,6 +79,13 @@ const READS = [
inner.fetchDiscoverGames,
"minutes",
],
+ // spec 0028, AC-4: both requests settle as one hours entry.
+ [
+ "getPopularGames",
+ () => getPopularGames("trending"),
+ inner.fetchPopularGames,
+ "hours",
+ ],
["getGameGenres", () => getGameGenres(), inner.fetchGameGenres, "max"],
[
"getGamePlatforms",
@@ -124,6 +136,15 @@ describe("validation, outside any cached scope", () => {
["getGamesByIds", () => getGamesByIds([1, -2])],
["searchGames", () => searchGames({ query: " " })],
["discoverGames", () => discoverGames({ sort: "rating_desc", page: 51 })],
+ [
+ "discoverGames with a page size of 30",
+ () =>
+ discoverGames({
+ sort: "rating_desc",
+ pageSize: 30 as unknown as 24,
+ }),
+ ],
+ ["getPopularGames", () => getPopularGames("hot" as unknown as "trending")],
])("%s throws IgdbValidationError and caches nothing", async (_n, call) => {
await expect(call()).rejects.toBeInstanceOf(IgdbValidationError);
expect(cacheLife).not.toHaveBeenCalled();
diff --git a/lib/igdb/reads.ts b/lib/igdb/reads.ts
index 62d363e..a97a408 100644
--- a/lib/igdb/reads.ts
+++ b/lib/igdb/reads.ts
@@ -3,6 +3,7 @@ import { cacheLife } from "next/cache";
import { fetchGamesByIds } from "./batch";
import { fetchDiscoverGames } from "./discover";
import { fetchGame } from "./games";
+import { fetchPopularGames } from "./popularity";
import { fetchGameGenres, fetchGamePlatforms } from "./reference";
import { failureProfile, type IgdbResult, toFailure, unwrap } from "./result";
import { fetchSearchGames } from "./search";
@@ -13,14 +14,17 @@ import type {
GameRef,
GameSummary,
Paged,
+ PopularGamesKind,
SearchGamesOptions,
} from "./types";
import {
assertGameId,
+ assertPopularKind,
type CanonicalGameIds,
canonicalGameIds,
type GameId,
type ValidDiscoverOptions,
+ type ValidPopularKind,
type ValidSearchOptions,
validateDiscoverOptions,
validateSearchOptions,
@@ -38,8 +42,9 @@ import {
* function that returns an `IgdbResult` and unwraps it on this side of the
* boundary, which keeps `IgdbError` and its `kind` intact.
*
- * Lifetimes follow how fast each answer changes: `hours` for a game and a
- * batch, so a new release date unlocks Playing within hours; `minutes` for
+ * Lifetimes follow how fast each answer changes: `hours` for a game, a
+ * batch and a popularity list, so a new release date unlocks Playing within
+ * hours and the heaviest read in the app runs a few times a day; `minutes` for
* search and discover; `max` for the genre and platform lists. A failure takes
* `failureProfile(kind)`.
*
@@ -159,6 +164,28 @@ export async function discoverGames(
return unwrap(await discoverGamesCached(validateDiscoverOptions(options)));
}
+/** One entry per kind: the primitives and their batch, settled together. */
+async function getPopularGamesCached(
+ kind: ValidPopularKind,
+): Promise> {
+ "use cache";
+ return settle(() => fetchPopularGames(kind), "hours");
+}
+
+/**
+ * A popularity list, Trending (`Visits`) or Popular (`Playing`), as the
+ * visible games in IGDB's rank order, cached for `hours` (spec 0028, AC-4).
+ * A list page slices it, so the count is exact.
+ *
+ * @throws {IgdbValidationError} A kind that is neither, before any request.
+ * @throws {IgdbError} When either of its two requests fails.
+ */
+export async function getPopularGames(
+ kind: PopularGamesKind,
+): Promise {
+ return unwrap(await getPopularGamesCached(assertPopularKind(kind)));
+}
+
async function getGameGenresCached(): Promise> {
"use cache";
return settle(() => fetchGameGenres(), "max");
diff --git a/lib/igdb/schemas.ts b/lib/igdb/schemas.ts
index ba90034..484abfa 100644
--- a/lib/igdb/schemas.ts
+++ b/lib/igdb/schemas.ts
@@ -46,6 +46,15 @@ const coverSchema = z
.nullish()
.catch(null);
+/**
+ * Artworks or screenshots, each `{ image_id }`. An entry of another shape is
+ * dropped, and an absent or odd list reads as none (spec 0028, AC-6).
+ */
+const imageListSchema = z
+ .array(z.object({ image_id: z.string().nullish() }).nullish().catch(null))
+ .nullish()
+ .catch(null);
+
/** The fields every game record shares. `id` and `name` are required. */
const baseGameSchema = z.object({
id: z.number().int().positive(),
@@ -67,12 +76,25 @@ export const gameDetailSchema = baseGameSchema.extend({
themes: looseArray,
});
-/** Batch, search and discover: platforms, genres and themes as ids. */
+/**
+ * Batch, search and discover: platforms, genres and themes as ids, plus the
+ * art a wide card shows.
+ */
export const gameSummarySchema = baseGameSchema.extend({
platforms: looseArray,
genres: looseArray,
themes: looseArray,
+ artworks: imageListSchema,
+ screenshots: imageListSchema,
});
export type GameDetailRecord = z.infer;
export type GameSummaryRecord = z.infer;
+
+/**
+ * One `popularity_primitives` row: only the game id is read, since the rows
+ * already come in rank order. A row without a positive id is dropped.
+ */
+export const popularityPrimitiveSchema = z.object({
+ game_id: z.number().int().positive(),
+});
diff --git a/lib/igdb/search.test.ts b/lib/igdb/search.test.ts
index 01c77ac..783f77b 100644
--- a/lib/igdb/search.test.ts
+++ b/lib/igdb/search.test.ts
@@ -50,7 +50,7 @@ describe("fetchSearchGames", () => {
const { value } = await settle(fetchSearchGames({ query: " witcher " }));
expect(mock.body()).toBe(
- `search "witcher"; fields name,cover.image_id,first_release_date,rating,rating_count,genres,platforms,themes,game_type,version_parent; where ${FILTER}; limit 21;`,
+ `search "witcher"; fields name,cover.image_id,artworks.image_id,screenshots.image_id,first_release_date,rating,rating_count,genres,platforms,themes,game_type,version_parent; where ${FILTER}; limit 21;`,
);
expect(value?.results.map((game) => game.id)).toEqual(
searchWitcher.map((game) => game.id),
@@ -175,6 +175,8 @@ describe("fetchDiscoverGames", () => {
],
[{ sort: "rating_desc", minRatingCount: -1 }, "minRatingCount"],
[{ sort: "rating_desc", minHypes: 0.5 }, "minHypes"],
+ [{ sort: "rating_desc", pageSize: 21 }, "pageSize"],
+ [{ sort: "rating_desc", pageSize: "24" }, "pageSize"],
])("refuses %j before any request", async (options, field) => {
const mock = installIgdbMock();
const error = await fetchDiscoverGames(
@@ -185,6 +187,32 @@ describe("fetchDiscoverGames", () => {
});
});
+describe("fetchDiscoverGames page size (spec 0028, AC-5)", () => {
+ it("asks for 25 and keeps 24, offsetting by 24, so pages never overlap", async () => {
+ const mock = installIgdbMock({ igdb: [{ body: rows(25) }] });
+
+ const { value } = await settle(
+ fetchDiscoverGames({ sort: "rating_desc", page: 3, pageSize: 24 }),
+ );
+
+ expect(mock.body()).toContain("limit 25; offset 48;");
+ expect(value?.results).toHaveLength(24);
+ expect(value?.hasNextPage).toBe(true);
+ });
+
+ it("keeps 20 by default", async () => {
+ const mock = installIgdbMock({ igdb: [{ body: rows(20) }] });
+
+ const { value } = await settle(
+ fetchDiscoverGames({ sort: "rating_desc", page: 2 }),
+ );
+
+ expect(mock.body()).toContain("limit 21; offset 20;");
+ expect(value?.results).toHaveLength(20);
+ expect(value?.hasNextPage).toBe(false);
+ });
+});
+
describe("the reference lists", () => {
it("reads genres in one request, sorted by name", async () => {
const mock = installIgdbMock({
diff --git a/lib/igdb/search.ts b/lib/igdb/search.ts
index 2f2d2c8..0891de3 100644
--- a/lib/igdb/search.ts
+++ b/lib/igdb/search.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { IGDB_PAGE_SIZE } from "./constants";
import { cond } from "./query";
import { readSummaryPage, sharedConditions } from "./summary-page";
import type { GameSummary, Paged } from "./types";
@@ -35,5 +36,10 @@ export async function fetchSearchGames(
]),
);
}
- return readSummaryPage({ search: valid.query, where, page: valid.page });
+ return readSummaryPage({
+ search: valid.query,
+ where,
+ page: valid.page,
+ pageSize: IGDB_PAGE_SIZE,
+ });
}
diff --git a/lib/igdb/summary-page.ts b/lib/igdb/summary-page.ts
index bb01b43..86a3fb6 100644
--- a/lib/igdb/summary-page.ts
+++ b/lib/igdb/summary-page.ts
@@ -1,7 +1,7 @@
import "server-only";
import { catalogWhereClause } from "./catalog-filter";
import { igdbRequest } from "./client";
-import { IGDB_PAGE_SIZE, IGDB_SUMMARY_FIELDS } from "./constants";
+import { IGDB_SUMMARY_FIELDS } from "./constants";
import { normalizeSummary } from "./normalize";
import { buildQuery, type Condition, cond, type Sort } from "./query";
import { gameSummarySchema, recordsSchema } from "./schemas";
@@ -42,15 +42,19 @@ export function sharedConditions(options: GameFilters): Condition[] {
}
/**
- * One page of summaries. It asks for 21 and keeps 20, so `hasNextPage` is
- * always known; `totalResults` is IGDB's count header or null, never a guess.
+ * One page of summaries. It asks for one more than `pageSize` and keeps
+ * `pageSize`, so `hasNextPage` is always known; `totalResults` is IGDB's count
+ * header or null, never a guess. The offset is counted in the same page size,
+ * so pages never overlap (spec 0028, AC-5).
*/
export async function readSummaryPage(query: {
search?: string;
where: readonly Condition[];
sort?: Sort;
page: number;
+ pageSize: number;
}): Promise> {
+ const { pageSize } = query;
const { data, totalCount } = await igdbRequest(
"games",
buildQuery({
@@ -58,14 +62,14 @@ export async function readSummaryPage(query: {
fields: IGDB_SUMMARY_FIELDS,
where: query.where,
sort: query.sort,
- limit: IGDB_PAGE_SIZE + 1,
- offset: (query.page - 1) * IGDB_PAGE_SIZE,
+ limit: pageSize + 1,
+ offset: (query.page - 1) * pageSize,
}),
recordsSchema,
);
const results: GameSummary[] = [];
- for (const record of data.slice(0, IGDB_PAGE_SIZE)) {
+ for (const record of data.slice(0, pageSize)) {
const parsed = gameSummarySchema.safeParse(record);
if (parsed.success) results.push(normalizeSummary(parsed.data));
}
@@ -73,7 +77,7 @@ export async function readSummaryPage(query: {
return {
page: query.page,
results,
- hasNextPage: data.length > IGDB_PAGE_SIZE,
+ hasNextPage: data.length > pageSize,
totalResults: totalCount,
};
}
diff --git a/lib/igdb/surface.test.ts b/lib/igdb/surface.test.ts
index 8fa7f0e..ed35bee 100644
--- a/lib/igdb/surface.test.ts
+++ b/lib/igdb/surface.test.ts
@@ -24,6 +24,7 @@ describe("lib/igdb/index.ts", () => {
"getGameGenres",
"getGamePlatforms",
"getGamesByIds",
+ "getPopularGames",
"isIgdbNotFound",
"searchGames",
].sort(),
@@ -47,7 +48,7 @@ function exportedTypeNames(source: string): string[] {
}
describe("lib/igdb/index.ts types", () => {
- it("exports exactly the types in spec 0023's design section", () => {
+ it("exports exactly the types in spec 0023's design section, plus spec 0028's", () => {
const source = readFileSync(join("lib", "igdb", "index.ts"), "utf8");
expect(exportedTypeNames(source)).toEqual(
[
@@ -60,6 +61,7 @@ describe("lib/igdb/index.ts types", () => {
"GameType",
"IgdbErrorKind",
"Paged",
+ "PopularGamesKind",
"SearchGamesOptions",
].sort(),
);
diff --git a/lib/igdb/types.ts b/lib/igdb/types.ts
index a9846e6..9ad6524 100644
--- a/lib/igdb/types.ts
+++ b/lib/igdb/types.ts
@@ -1,3 +1,5 @@
+import type { IGDB_DISCOVER_PAGE_SIZES } from "./constants";
+
/**
* The app's own game types, the only shapes that leave `index.ts` (spec 0023,
* AC-15). camelCase, closed unions, and `null` wherever IGDB may know nothing:
@@ -44,6 +46,11 @@ export type GameSummary = {
id: number;
name: string;
coverUrl: string | null;
+ /**
+ * Wide 16:9 art for the games hero and wide cards: an artwork, else a
+ * screenshot, else null (spec 0028, AC-6).
+ */
+ artUrl: string | null;
releaseDate: string | null;
releaseYear: number | null;
communityRating: number | null;
@@ -54,6 +61,12 @@ export type GameSummary = {
hidden: boolean;
};
+/**
+ * The two popularity lists (spec 0028, AC-2): `trending` is what IGDB
+ * members look at most, `popular` what the most of them are playing.
+ */
+export type PopularGamesKind = "trending" | "popular";
+
/** `found` is in no promised order; look games up by id. */
export type BatchResult = { found: T[]; missingIds: number[] };
@@ -99,4 +112,12 @@ export type DiscoverGamesOptions = GameFilters & {
minHypes?: number;
sort: DiscoverSort;
page?: number;
+ /**
+ * Results per page, 20 or 24 (spec 0028, AC-5). 24 fills a games list
+ * page's grid; the default 20 keeps G7 and the search read unchanged.
+ */
+ pageSize?: DiscoverPageSize;
};
+
+/** The page sizes discover serves (spec 0028, AC-5). */
+export type DiscoverPageSize = (typeof IGDB_DISCOVER_PAGE_SIZES)[number];
diff --git a/lib/igdb/validation.test.ts b/lib/igdb/validation.test.ts
index ac8a3d5..8c010b6 100644
--- a/lib/igdb/validation.test.ts
+++ b/lib/igdb/validation.test.ts
@@ -147,9 +147,19 @@ describe("validateDiscoverOptions (AC-12b, AC-13)", () => {
expect(validateDiscoverOptions({ sort: "hypes_desc" })).toEqual({
sort: "hypes_desc",
page: 1,
+ pageSize: 20,
});
});
+ it("keeps a page size of 24 and fills an absent one with 20 (spec 0028, AC-5)", () => {
+ expect(
+ validateDiscoverOptions({ sort: "hypes_desc", pageSize: 24 }),
+ ).toMatchObject({ pageSize: 24 });
+ expect(
+ validateDiscoverOptions({ sort: "hypes_desc", pageSize: 20 }),
+ ).toEqual(validateDiscoverOptions({ sort: "hypes_desc" }));
+ });
+
it("accepts a one day window, from equal to to", () => {
expect(
validateDiscoverOptions({
@@ -200,6 +210,7 @@ describe("validateDiscoverOptions (AC-12b, AC-13)", () => {
"releasedFrom",
],
["page 51", { sort: "rating_desc", page: 51 }, "page"],
+ ["a page size of 30", { sort: "rating_desc", pageSize: 30 }, "pageSize"],
[
"a minimum rating of 11",
{ sort: "rating_desc", minRating: 11 },
diff --git a/lib/igdb/validation.ts b/lib/igdb/validation.ts
index af0ce36..ed22e63 100644
--- a/lib/igdb/validation.ts
+++ b/lib/igdb/validation.ts
@@ -1,16 +1,20 @@
import {
IGDB_BATCH_LIMIT,
+ IGDB_DISCOVER_PAGE_SIZES,
IGDB_MAX_FILTER_IDS,
IGDB_MAX_PAGE,
IGDB_MIN_YEAR,
+ IGDB_PAGE_SIZE,
IGDB_YEARS_AHEAD,
} from "./constants";
import { IgdbValidationError } from "./errors";
import { cleanSearchText } from "./query";
import type {
DiscoverGamesOptions,
+ DiscoverPageSize,
DiscoverSort,
GameFilters,
+ PopularGamesKind,
SearchGamesOptions,
} from "./types";
@@ -69,6 +73,21 @@ export function assertGameId(id: unknown): GameId {
return id as GameId;
}
+/** A checked popularity list kind. */
+export type ValidPopularKind = Valid;
+
+/**
+ * `trending` or `popular`, or `IgdbValidationError` (spec 0028, AC-4). Typed
+ * callers cannot pass anything else; the check keeps an untyped one from
+ * reaching a cache key.
+ */
+export function assertPopularKind(kind: unknown): ValidPopularKind {
+ if (kind !== "trending" && kind !== "popular") {
+ throw new IgdbValidationError("kind", "kind must be trending or popular.");
+ }
+ return kind as ValidPopularKind;
+}
+
/** 0 to 500 positive integers, deduped and sorted (AC-11). */
export function canonicalGameIds(ids: readonly unknown[]): CanonicalGameIds {
if (!Array.isArray(ids) || !ids.every(isPositiveInt)) {
@@ -116,6 +135,21 @@ function page(value: unknown): number {
return value;
}
+/**
+ * 20 or 24, filled with 20 when absent so both spellings share one cache
+ * entry (spec 0028, AC-5).
+ */
+function pageSize(value: unknown): DiscoverPageSize {
+ if (value === undefined) return IGDB_PAGE_SIZE;
+ if (!(IGDB_DISCOVER_PAGE_SIZES as readonly unknown[]).includes(value)) {
+ throw new IgdbValidationError(
+ "pageSize",
+ `pageSize must be one of ${IGDB_DISCOVER_PAGE_SIZES.join(", ")}.`,
+ );
+ }
+ return value as DiscoverPageSize;
+}
+
function minRating(value: unknown): number | undefined {
if (value === undefined) return undefined;
if (
@@ -220,12 +254,13 @@ export function validateSearchOptions(
/** The validated discover options, canonical like the search options. */
export type ValidDiscoverOptions = Valid<
- DiscoverGamesOptions & { page: number }
+ DiscoverGamesOptions & { page: number; pageSize: DiscoverPageSize }
>;
/**
* Checks and canonicalizes discover options, as `validateSearchOptions` does,
- * plus a known `sort` and a date window whose start is not after its end.
+ * plus a known `sort`, a date window whose start is not after its end, and a
+ * page size of 20 or 24, filled with 20.
*
* @throws {IgdbValidationError} Naming the first bad field.
*/
@@ -254,5 +289,6 @@ export function validateDiscoverOptions(
minHypes: nonNegative("minHypes", options.minHypes),
sort: options.sort,
page: page(options.page),
+ pageSize: pageSize(options.pageSize),
});
}
diff --git a/lib/images/igdb-loader.test.ts b/lib/images/igdb-loader.test.ts
index 106a7d8..95208d0 100644
--- a/lib/images/igdb-loader.test.ts
+++ b/lib/images/igdb-loader.test.ts
@@ -5,13 +5,14 @@ import { IGDB_IMAGE_BASE } from "@/lib/igdb/constants";
import { igdbImageLoader } from "./igdb-loader";
/**
- * covers: spec 0025, AC-12
+ * covers: spec 0025, AC-12; spec 0028, AC-6
*
* A wrong rung is either a heavier cover than the field needs or an upgrade
* past the size the URL named; neither shows up until someone looks.
*/
const at = (size: string) => `${IGDB_IMAGE_BASE}/${size}/co1abc.jpg`;
const same = (size: string) => `${at(size)}#same-size`;
+const art = (size: string) => `${IGDB_IMAGE_BASE}/${size}/ar1abc.jpg`;
describe("igdbImageLoader", () => {
it.each([
@@ -49,8 +50,26 @@ describe("igdbImageLoader", () => {
);
});
+ // spec 0028, AC-6: art steps down its own ladder, never the cover one.
it.each([
- `${IGDB_IMAGE_BASE}/t_screenshot_big/sc1.jpg`,
+ [300, "t_screenshot_med"],
+ [555, "t_screenshot_med"],
+ [800, "t_screenshot_big"],
+ [1200, "t_720p"],
+ [1600, "t_1080p"],
+ ])("serves 1080p art at width %i from %s", (width, size) => {
+ const url = igdbImageLoader({ src: art("t_1080p"), width });
+ expect(url.replace("#same-size", "")).toBe(art(size));
+ });
+
+ it("never steps art past the size the URL names", () => {
+ expect(igdbImageLoader({ src: art("t_screenshot_big"), width: 1600 })).toBe(
+ `${art("t_screenshot_big")}#same-size`,
+ );
+ });
+
+ it.each([
+ `${IGDB_IMAGE_BASE}/t_screenshot_huge/sc1.jpg`,
`${IGDB_IMAGE_BASE}/t_cover_big/co1.png`,
`${IGDB_IMAGE_BASE}/co1.jpg`,
`${IGDB_IMAGE_BASE}/t_cover_big/a/co1.jpg`,
diff --git a/lib/images/igdb-loader.ts b/lib/images/igdb-loader.ts
index 1bec119..581a93f 100644
--- a/lib/images/igdb-loader.ts
+++ b/lib/images/igdb-loader.ts
@@ -2,7 +2,11 @@
import type { ImageLoaderProps } from "next/image";
-import { IGDB_COVER_LADDER, IGDB_IMAGE_BASE } from "@/lib/igdb/constants";
+import {
+ IGDB_ART_LADDER,
+ IGDB_COVER_LADDER,
+ IGDB_IMAGE_BASE,
+} from "@/lib/igdb/constants";
/**
* Appended when the fitting size is the source's own, for the reason the TMDB
@@ -11,19 +15,26 @@ import { IGDB_COVER_LADDER, IGDB_IMAGE_BASE } from "@/lib/igdb/constants";
*/
const SAME_SIZE_FRAGMENT = "#same-size";
-/** The rest of a cover URL after the base: `{size}/{image_id}.jpg`. */
-const COVER_PATH = /^([^/]+)\/([^/?#]+\.jpg)$/;
+/** The rest of an image URL after the base: `{size}/{image_id}.jpg`. */
+const IMAGE_PATH = /^([^/]+)\/([^/?#]+\.jpg)$/;
/**
- * Maps a `next/image` width request onto IGDB's own pre-sized covers, so the
- * browser fetches straight from IGDB's CDN and the closed optimizer never runs
- * (spec 0025, AC-11, AC-12).
+ * The two ladders, each matched only against its own sizes, so a cover never
+ * steps onto an art rung or the other way round (spec 0028, AC-6).
+ */
+const LADDERS = [IGDB_COVER_LADDER, IGDB_ART_LADDER];
+
+/**
+ * Maps a `next/image` width request onto IGDB's own pre-sized covers and art,
+ * so the browser fetches straight from IGDB's CDN and the closed optimizer
+ * never runs (spec 0025, AC-11, AC-12; spec 0028, AC-6).
*
- * The size in the URL is the ceiling: G1 builds covers at `t_cover_big_2x`,
- * so a dense screen gets 528px and a small card steps down, but nothing is
- * ever upgraded past what the URL named. `quality` is ignored because IGDB has
- * no such parameter. Any URL that is not an IGDB cover on the ladder comes back
- * unchanged, so the loader never builds a URL it was not handed.
+ * The size in the URL is the ceiling: G1 builds covers at `t_cover_big_2x`
+ * and G6 builds art at `t_1080p`, so a dense screen gets the full size and a
+ * small card steps down its own ladder, but nothing is ever upgraded past what
+ * the URL named. `quality` is ignored because IGDB has no such parameter. Any
+ * URL that is not an IGDB image on a ladder comes back unchanged, so the
+ * loader never builds a URL it was not handed.
*
* Lives in `lib/images/`, not `lib/igdb/`, because it runs in the browser and
* every file in `lib/igdb/` with behaviour is server only (spec 0023, AC-2).
@@ -34,14 +45,17 @@ export function igdbImageLoader({ src, width }: ImageLoaderProps): string {
: src;
const prefix = `${IGDB_IMAGE_BASE}/`;
if (!source.startsWith(prefix)) return src;
- const match = COVER_PATH.exec(source.slice(prefix.length));
+ const match = IMAGE_PATH.exec(source.slice(prefix.length));
if (!match) return src;
const [, size, file] = match;
- const ceiling = IGDB_COVER_LADDER.findIndex((rung) => rung.size === size);
- if (ceiling === -1) return src;
+ const ladder = LADDERS.find((rungs) =>
+ rungs.some((rung) => rung.size === size),
+ );
+ if (!ladder) return src;
+ const ceiling = ladder.findIndex((rung) => rung.size === size);
- const allowed = IGDB_COVER_LADDER.slice(0, ceiling + 1);
+ const allowed = ladder.slice(0, ceiling + 1);
const fit = allowed.find((rung) => rung.width >= width) ?? allowed[ceiling];
const url = `${IGDB_IMAGE_BASE}/${fit.size}/${file}`;
return url === source ? `${url}${SAME_SIZE_FRAGMENT}` : url;
diff --git a/lib/landing-metadata.test.ts b/lib/landing-metadata.test.ts
new file mode 100644
index 0000000..bfe9960
--- /dev/null
+++ b/lib/landing-metadata.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+
+import { catalogList, GAME_LIST_SLUGS } from "@/lib/catalog/lists";
+import { gameListMetadata, gamesMetadata } from "./landing-metadata";
+
+/**
+ * covers: spec 0028, AC-13
+ *
+ * Games stay out of search engines until G10: `/games` and every games list
+ * page is `noindex`, with no canonical and no share card of its own. The
+ * metadata is static, so it holds for every `?page` and costs no IGDB read.
+ */
+describe("gamesMetadata", () => {
+ it("keeps its title and description and marks the page noindex", () => {
+ const metadata = gamesMetadata({
+ title: "Games",
+ description: "Browse games.",
+ });
+
+ expect(metadata).toEqual({
+ title: "Games",
+ description: "Browse games.",
+ robots: { index: false },
+ });
+ });
+
+ it("names no canonical and no share card", () => {
+ const metadata = gamesMetadata({ title: "Games", description: "x" });
+
+ expect(metadata.alternates).toBeUndefined();
+ expect(metadata.openGraph).toBeUndefined();
+ expect(metadata.twitter).toBeUndefined();
+ });
+});
+
+describe("gameListMetadata", () => {
+ it.each(GAME_LIST_SLUGS)(
+ "titles %s with its h1 and note, noindex",
+ (slug) => {
+ const list = catalogList("game", slug);
+
+ expect(gameListMetadata(list)).toEqual({
+ title: list.heading,
+ description: list.note,
+ robots: { index: false },
+ });
+ },
+ );
+
+ it("returns metadata directly, not a promise, so it never reads IGDB", () => {
+ const metadata = gameListMetadata(catalogList("game", "top-rated"));
+
+ expect(metadata).not.toBeInstanceOf(Promise);
+ });
+});
diff --git a/lib/landing-metadata.ts b/lib/landing-metadata.ts
index 49c2b8f..36ff9e2 100644
--- a/lib/landing-metadata.ts
+++ b/lib/landing-metadata.ts
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
-import { type CatalogList, listPageHref } from "@/lib/catalog/lists";
+import {
+ type GameCatalogList,
+ listPageHref,
+ type TmdbCatalogList,
+} from "@/lib/catalog/lists";
import { parsePageParam } from "@/lib/catalog/pages";
-import { readTaggedCatalogGridPage } from "@/lib/catalog-list-read";
+import {
+ lastListPage,
+ readTaggedCatalogGridPage,
+} from "@/lib/catalog-list-read";
import { landingMetadata } from "@/lib/seo/metadata";
import { TmdbError } from "@/lib/tmdb";
@@ -58,15 +65,38 @@ export async function landingPageMetadata({
* (spec 0022, AC-21).
*/
export async function catalogListMetadata(
- list: CatalogList,
+ list: TmdbCatalogList,
searchParams: Promise>,
): Promise {
return landingPageMetadata({
pageParam: (await searchParams).page,
- discover: async ({ page }) =>
- (await readTaggedCatalogGridPage(list, page)).paged,
+ discover: async ({ page }) => ({
+ totalPages:
+ lastListPage(await readTaggedCatalogGridPage(list, page)) ?? 0,
+ }),
title: list.heading,
description: list.note,
pageHref: (page) => listPageHref(list, page),
});
}
+
+/**
+ * A games page's metadata (spec 0028, AC-13): its title and description, and
+ * `noindex` with no canonical and no share card of its own, until G10 opens
+ * games to search engines. Static: it reads neither `?page` nor IGDB, so it
+ * costs no request and holds for every page of a list.
+ */
+export function gamesMetadata({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}): Metadata {
+ return landingMetadata({ title, description, path: null });
+}
+
+/** `gamesMetadata` for one games list page: its h1 and its note. */
+export function gameListMetadata(list: GameCatalogList): Metadata {
+ return gamesMetadata({ title: list.heading, description: list.note });
+}
diff --git a/lib/tracking/messages.test.ts b/lib/tracking/messages.test.ts
index cb4bee2..e6ede22 100644
--- a/lib/tracking/messages.test.ts
+++ b/lib/tracking/messages.test.ts
@@ -264,13 +264,14 @@ describe("game copy (spec 0026, AC-2, AC-4, AC-5)", () => {
/** covers: spec 0027, AC-9, AC-14 */
describe("the games tabs' copy (spec 0027)", () => {
- it("pins each empty games tab's text, with no browse button (AC-14)", () => {
+ it("pins each empty games tab's text and its Browse games button (AC-14; spec 0028, AC-17)", () => {
expect(LIBRARY_COPY.watchlist.game).toEqual({
grid: "Games you want to play",
empty: {
title: "No games to play yet",
description: "Games you mark Want to play show up here.",
},
+ browse: "Browse games",
});
expect(LIBRARY_COPY.upcoming.game).toEqual({
grid: "Games you're playing",
@@ -278,6 +279,7 @@ describe("the games tabs' copy (spec 0027)", () => {
title: "Nothing in progress",
description: "Games you mark Playing show up here.",
},
+ browse: "Browse games",
});
expect(LIBRARY_COPY.watched.game).toEqual({
grid: "Games you finished",
@@ -285,6 +287,7 @@ describe("the games tabs' copy (spec 0027)", () => {
title: "No finished games yet",
description: "Games you mark Finished show up here.",
},
+ browse: "Browse games",
});
});
diff --git a/lib/tracking/messages.ts b/lib/tracking/messages.ts
index dd76a61..41962f7 100644
--- a/lib/tracking/messages.ts
+++ b/lib/tracking/messages.ts
@@ -191,13 +191,13 @@ export const UPCOMING_MESSAGES = {
/**
* What a list page's grid, empty state and notes say (spec 0020, AC-18).
- * `browse` is the empty state's button, absent where there is no catalog to
- * browse yet (the games tabs until G6, spec 0027, AC-14).
+ * `browse` is the empty state's button, leading to the type's landing
+ * (spec 0028, AC-17, for the games tabs).
*/
type ListCopy = {
grid: string;
empty: { title: string; description: string };
- browse?: string;
+ browse: string;
};
/**
@@ -233,6 +233,7 @@ export const LIBRARY_COPY: Record<
title: "No games to play yet",
description: "Games you mark Want to play show up here.",
},
+ browse: "Browse games",
},
failed: "Couldn't load your watchlist",
},
@@ -260,6 +261,7 @@ export const LIBRARY_COPY: Record<
title: "Nothing in progress",
description: "Games you mark Playing show up here.",
},
+ browse: "Browse games",
},
failed: "Couldn't load your upcoming titles",
},
@@ -286,6 +288,7 @@ export const LIBRARY_COPY: Record<
title: "No finished games yet",
description: "Games you mark Finished show up here.",
},
+ browse: "Browse games",
},
failed: "Couldn't load your watched titles",
},
diff --git a/proxy.test.ts b/proxy.test.ts
index e52d9df..51bffe7 100644
--- a/proxy.test.ts
+++ b/proxy.test.ts
@@ -212,6 +212,12 @@ describe("the proxy's catalog id rule", () => {
"/games/5.0",
"/games/2147483648",
"/games/1.jpg",
+ // spec 0028, AC-12: only the five slugs, exactly as written.
+ "/games/Trending",
+ "/games/coming_soon",
+ "/games/trending.jpg",
+ "/games/now-playing",
+ "/games/top%2Drated",
])("answers %s with a 404 rewrite", async (path) => {
const response = await run(path);
@@ -280,8 +286,14 @@ describe("the proxy's catalog id rule", () => {
"/movies/now-playing",
"/movies/upcoming",
"/movies/top-rated",
- // spec 0025, AC-6: `/games` and deeper paths match no route, so the
- // plain 404 answers them, not this rule.
+ // spec 0028, AC-12: the five games list pages.
+ "/games/trending",
+ "/games/popular",
+ "/games/recently-released",
+ "/games/coming-soon",
+ "/games/top-rated",
+ // spec 0025, AC-6: deeper paths match no route, so the plain 404
+ // answers them, not this rule; `/games` is the landing (spec 0028).
"/games/1942",
"/games/2147483647",
"/games",
diff --git a/proxy.ts b/proxy.ts
index d49b563..ed62329 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { isPrivatePath } from "@/lib/auth/private-paths";
import { parseGameId, parseSeasonNumber, parseTmdbId } from "@/lib/catalog/ids";
import { isCatalogListSlug } from "@/lib/catalog/lists";
-import type { MediaType } from "@/lib/catalog/media-type";
+import type { LibraryType } from "@/lib/catalog/media-type";
import { getPublicEnv, publicEnvProblems } from "@/lib/env";
import { sessionCookieOptions } from "@/lib/supabase/cookie-options";
@@ -167,15 +167,15 @@ function signInRedirect(request: NextRequest): NextResponse {
* its page uses, one per captured segment. Deeper or other paths already
* match no route, so they are not listed.
*
- * `listMediaType` marks the two paths that also hold a catalog's list pages
- * (`/shows/top-rated`, spec 0021, AC-13): a segment naming one of that media
- * type's lists passes before the id parser judges it. Any other non numeric
- * segment is still a 404.
+ * `listMediaType` marks the three paths that also hold a catalog's list
+ * pages (`/shows/top-rated`, spec 0021, AC-13; `/games/coming-soon`, spec
+ * 0028, AC-12): a segment naming one of that media type's lists passes before
+ * the id parser judges it. Any other non numeric segment is still a 404.
*/
const CATALOG_PATHS: {
pattern: RegExp;
parsers: ((segment: string) => number | null)[];
- listMediaType?: MediaType;
+ listMediaType?: LibraryType;
}[] = [
{
pattern: /^\/movies\/([^/]+)$/,
@@ -191,10 +191,11 @@ const CATALOG_PATHS: {
pattern: /^\/shows\/([^/]+)\/season\/([^/]+)$/,
parsers: [parseTmdbId, parseSeasonNumber],
},
- // spec 0025, AC-6. No list pages live under `/games/` yet, so no slug passes.
+ // spec 0025, AC-6; spec 0028, AC-12 for the list slugs.
{
pattern: /^\/games\/([^/]+)$/,
parsers: [parseGameId],
+ listMediaType: "game",
},
];