From e6f0feaf05e25a679618b62f23eb0179f56838a6 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 17:18:04 +0100 Subject: [PATCH 01/25] docs: add spec 0028 for the games home and list pages Co-Authored-By: Claude Opus 5.5 --- docs/scope/games.md | 19 +- .../specs/0028-games-home-list-pages/index.md | 198 ++++++++++++++++++ .../0028-games-home-list-pages/rationale.md | 64 ++++++ 3 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 docs/specs/0028-games-home-list-pages/index.md create mode 100644 docs/specs/0028-games-home-list-pages/rationale.md diff --git a/docs/scope/games.md b/docs/scope/games.md index 6f54890..baef657 100644 --- a/docs/scope/games.md +++ b/docs/scope/games.md @@ -29,7 +29,7 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md | G3 | Game page (tracer) | Slice G1 | done | | G4 | Game status and rating | Slice G1 | done | | G5 | Games tab and library pages | Slice G2 | done | -| G6 | Games home and list pages | Slice G3 | planned | +| G6 | Games home and list pages | Slice G3 | in-progress | | G7 | Game search and filters | Slice G4 | planned | | G8 | Game cards everywhere | Slice G4 | planned | | G9 | Richer game page | Slice G5 | planned | @@ -117,10 +117,19 @@ spec [0027](../specs/0027-games-library-pages/index.md) · code in [components/l ## Slice G3: Discovery -### G6. Games home and list pages · needs a decision -`/games` becomes the games landing, with rows of carousels like `/shows` and `/movies`. -**Done when:** `/games` shows four rows (Popular right now, Recently released in the last 60 days, Coming soon in the next 120 days by hype, Top rated with a minimum vote count), each streaming and failing on its own with Retry; each See all link opens a paged list page; the proxy accepts the known slugs and 404s anything else. -- [ ] Design it (spec): `/architect games home and list pages` +### G6. Games home and list pages · in-progress +`/games` becomes the games landing, built on the parallax landing `/shows` and `/movies` use (the carousel rows were retired on 2026-10-18). +**Done when:** `/games` shows the hero and five sections (Trending, Popular, Recently released in the last 60 days, Coming soon in the next 120 days by hype, Top rated with at least 100 ratings), each streaming and failing on its own with Retry; each See all link opens a paged list page of 24; the proxy accepts the known slugs and 404s anything else; GAMES shows on every page while the flag is on; every games page stays `noindex` until G10. +- [x] Design it (spec): `/architect games home and list pages` +- [ ] Build it: `/develop games home and list pages` + - [ ] Thin thread: `pageSize` on discover, the registry, proxy and `noindex` metadata, and `/games/top-rated` end to end, AC-2, AC-5, AC-8 to AC-13, AC-18 + - [ ] Module strands: `artUrl` and the art ladder, `getPopularGames`, the live checks, then the other four list pages, AC-2, AC-4, AC-6, AC-9, AC-10 + - [ ] The landing: widened `LandingItem` and slots, `/games` on `CatalogLanding`, the hero with `GameTrackingSlot`, AC-1, AC-3, AC-7, AC-8, AC-11, AC-18, AC-19 + - [ ] Navigation: GAMES everywhere, search hidden on games pages, the logo memory, Browse games buttons, AC-14 to AC-17 + - [ ] Proof: `/showcase`, 375px and 1280px, keyboard, reduced motion, all checks, AC-20, AC-21 +- [ ] Verify it: `/check verify games home and list pages` +- [ ] Test it: `/test games home and list pages` +spec [0028](../specs/0028-games-home-list-pages/index.md) · code in `app/games/`, `components/catalog/landing/`, `lib/catalog/`, `lib/igdb/` ## Slice G4: Search and cards diff --git a/docs/specs/0028-games-home-list-pages/index.md b/docs/specs/0028-games-home-list-pages/index.md new file mode 100644 index 0000000..638312b --- /dev/null +++ b/docs/specs/0028-games-home-list-pages/index.md @@ -0,0 +1,198 @@ +# 0028. Games home and list pages: the parallax landing at /games and five paged IGDB lists + +**Date**: 2026-10-25 +**Status**: In Progress + +Scope feature: [G6. Games home and list pages](../../scope/games.md) · Beta tier + +## Summary + +`/games` becomes the games landing, built from the same parallax landing that `/shows` and `/movies` use today, filled with five IGDB lists: Trending, Popular, Recently released, Coming soon and Top rated. Each list also gets its own paged See all page under `/games/{slug}`. The landing sections learn to draw a game the way they draw a show or a movie, so there is one landing, not a copy. Nothing is stored and no schema changes; the work is new IGDB reads (a popularity read, an art field, a page size), five public routes, a proxy rule, and the navigation pieces spec 0027 left for G6. Until the G10 launch every games page stays `noindex`, out of the sitemap, and unlinked in production. + +## Requirements + +**User stories**: + +- As a visitor, I want a games landing that shows what is trending, popular, new, coming soon and top rated, so that I can find a game without searching. +- As a visitor, I want a See all page for each list with pages, so that I can go deeper into the one list I care about. +- As a signed in user, I want the hero's game to offer the same status control the game page has, so that I can mark it Want to play straight from the landing. +- As a user with the games library on, I want the GAMES tab on every page and a Browse games button on an empty games tab, so that I can always get to games. + +**Acceptance criteria** (the contract, each independently checkable): + +- **AC-1**: `/games` renders, in order: an `sr-only` h1 `Games`, the hero, Trending (the pinned row), Popular (the staggered grid), Recently released (the sticky sidebar with three columns, sidebar label `New releases`), Coming soon (the counter moving rows) and Top rated (the numbered list). Each section uses the same component, layout, motion, reduced motion behaviour, mobile layout, skeleton and See all link as on `/shows` and `/movies` (prompts/home-parallax-landing.md, decisions 6, 9, 11, 14 and 16). The section headings are the `rowTitle` values in AC-9. +- **AC-2**: Each landing section and its list page read exactly this source: + + | Slug | Row title | Source | + |---|---|---| + | `trending` | Trending now | `getPopularGames("trending")`: IGDB `popularity_primitives` of popularity type `Visits` (IGDB source) | + | `popular` | Popular | `getPopularGames("popular")`: popularity type `Playing` (IGDB source) | + | `recently-released` | Recently released | `discoverGames({ sort: "hypes_desc", minHypes: 1, releasedFrom: today − 59 days, releasedTo: today, pageSize: 24 })` | + | `coming-soon` | Coming soon | `discoverGames({ sort: "hypes_desc", minHypes: 1, releasedFrom: today + 1 day, releasedTo: today + 120 days, pageSize: 24 })` | + | `top-rated` | Top rated | `discoverGames({ sort: "rating_desc", minRatingCount: 100, pageSize: 24 })` | + + `today` is the current UTC day. Each landing section and each list page reads it right after its own `connection()` call; two sections that straddle midnight UTC may use different days, which is accepted. Both windows are inclusive, so `today − 59` to `today` is 60 days. Because the options carry the day, the "same cached call" promise of AC-3 holds within one UTC day. The popularity reads do not depend on `today`. The popularity type ids are pinned in `lib/igdb/constants.ts` and checked by `pnpm igdb:live` against IGDB's `popularity_types` (AC-6). The live check also counts the Recently released window with `minHypes: 1`; if it holds fewer than 12 games, Recently released switches to `sort: "release_desc"` with no hype floor, and this spec is amended to say so. +- **AC-3**: The landing shows Trending 9, Popular 14, Recently released 12, Coming soon 40 and Top rated 8 games, fewer when the list holds fewer, never padded. Coming soon fills rows 1 and 2 from the first 20 games of list page 1 and rows 3 and 4 from up to the first 20 of list page 2 (a short page 2 gives short rows), drops any game of page 2 already shown from page 1, and a failed page 2 leaves rows 3 and 4 out (decision 13 of the parallax plan, unchanged). Every landing read is the very same cached call as the matching list page (page 1, and page 2 for Coming soon), so opening See all costs no extra IGDB request while the entry is fresh. +- **AC-4**: `getPopularGames(kind)` reads `popularity_primitives` with `fields game_id,value; where popularity_type = {id}; sort value desc; limit 500`, removes duplicate `game_id`s keeping the first, reads those ids (at most 500, the batch limit) through the inner batch read, drops every game that is `hidden` or missing (a short list, never an error), and returns the rest in primitive rank order. Both requests run inside one cached function, `getPopularGamesCached(kind)`, through one `settle`, so the result is one cache entry per kind with lifetime `hours`, a failure of either request takes `failureProfile(kind)`, and a partial result is never cached as a success. Trending and Popular may share games; no game is removed across sections, as on the TMDB landings. A list page slices it: page N is items `(N − 1) × 24` to `N × 24`, and `totalResults` is the number of games kept, so the count is exact. The same game never appears twice in one list. +- **AC-5**: `discoverGames` accepts an optional `pageSize` of 20 or 24 (default 20, so G7 and spec 0023 callers are unchanged), validated before the cache like every other option (`IgdbValidationError` for any other value). The validated `pageSize` is part of `ValidDiscoverOptions`, so it is in the cache key. `readSummaryPage` takes `pageSize`, asks for `pageSize + 1`, keeps `pageSize`, and computes `offset = (page − 1) × pageSize`, so pages never overlap; `search.ts` passes 20. `page` stays 1 to 50. +- **AC-6**: `GameSummary` gains `artUrl: string | null`: the artwork with the lowest `image_id` (string order, so the pick is stable, since IGDB promises no array order), else the screenshot with the lowest `image_id`, else null, built at `t_1080p`. `IGDB_SUMMARY_FIELDS` gains `artworks.image_id` and `screenshots.image_id`; `schemas.ts` accepts each as an optional array of `{ image_id }` and treats an absent or empty array as none. Adding the field follows the module's three place rule (`schemas.ts`, `types.ts`, `normalize.ts`), and every `GameSummary` fixture and test across the repo gains `artUrl`. `lib/images/igdb-loader.ts` steps an art URL down a new `IGDB_ART_LADDER` (`t_screenshot_med`, `t_screenshot_big`, `t_720p`, `t_1080p`) the way it steps covers, never past the URL's own size; it matches an art size against the art ladder, not the cover ladder, so `t_1080p` does step down. `pnpm igdb:live` checks each art rung's real pixel width and the two popularity type ids and names. +- **AC-7**: The hero shows the first game of Trending over its `artUrl` (falling back to the cover cropped to fill, then the existing missing art state), the chip `#1 Trending`, the `IgdbRatingBadge` (labelled as the IGDB community rating) when rated, its genre names from the ids through `getGameGenres` (left out when that read fails), the name as an h2 linked to `/games/{id}`, and `GameTrackingSlot`, the G4 status control with its release gate and signed out behaviour exactly as on the game page. There is no Trailer button (G9). When Trending fails or is empty there is no hero; the Trending section below shows the one heading and the one error or empty state, as on `/shows`. +- **AC-8**: Every game card on the landing and the list pages links to `/games/{id}` and shows the cover (wide cards: `artUrl`, then cover cropped, then missing art), the name, and the `IgdbRatingBadge` when the game is rated. Coming soon cards show the release date as a UTC day (`Nov 6`, or `Nov 6, 2027` when the year is not the current UTC year; a null date cannot occur inside the window); a game with no art and no cover shows the existing missing art state. Every other place that shows a year uses `releaseYear`. Game cards carry no personal status badge and no controls; G8 adds them. +- **AC-9**: Each slug has a public list page at `/games/{slug}`: a back link `← Games` to `/games`, the h1, the one line note, then a `PosterGrid` of up to 24 game cards and `PaginationLinks` (page 1 is the bare path, page N is `?page=N`). Copy, all in the list registry: + + | Slug | `rowTitle` | h1 | Note | `pageNoun` | + |---|---|---|---|---| + | `trending` | Trending now | Trending games | The games people are looking at most on IGDB right now. | trending games | + | `popular` | Popular | Popular games | The games the most IGDB members are playing right now. | popular games | + | `recently-released` | Recently released | Recently released games | Released in the last 60 days, most anticipated first, per IGDB. | recently released games | + | `coming-soon` | Coming soon | Games coming soon | Releasing in the next 120 days, most anticipated first, per IGDB. | upcoming games | + | `top-rated` | Top rated | Top rated games | Ordered by IGDB community rating, among games with at least 100 ratings. | top rated games | + + The sidebar line under Recently released reads `The {n} most anticipated games of the last 60 days.` (`{n}` the rendered count; `game` when 1; no line when 0), written as an explicit `game` branch of `nowBlurb`, never a fall through to the movie text. The back link label is `Games` (`LANDING_LABELS.game`). The `/games` metadata description is `Browse trending, popular, new, upcoming and top rated games on BeStats, and keep track of what you play.` +- **AC-10**: Pagination is truthful. A popularity list shows `Page N of M` with `M = ceil(kept / 24)`. A discover list shows `Page N of M` with `M = min(50, ceil(totalResults / 24))` when IGDB sent a count, and `Page N` with a Next link only when `hasNextPage` when it did not. A page past the last, or a `page` value the existing `parsePageParam` rejects, shows the existing "no such page" panel with the list's `pageNoun`. Page 1 of an empty list shows the empty panel (AC-11), not "no such page". Page 50 has no Next link, whatever the count says. +- **AC-11**: An `IgdbError` in one landing section shows that section's compact error with Try again while every other section renders. On a list page it shows the `StatePanel` `Couldn't reach IGDB` / `IGDB didn't respond. Try again in a moment.` with a `RetryLink` to the current page's URL. An empty list shows the section's existing one line empty state, or on a list page the existing empty panel with `pageNoun`. Any error that is not an `IgdbError` (including `IgdbValidationError`) is rethrown. +- **AC-12**: `proxy.ts` passes `/games/{slug}` for exactly the five slugs, matched case sensitively, before `parseGameId`; `/games/Trending`, `/games/coming_soon` and `/games/1.jpg` keep today's real 404, and `/games/1942` still reaches the game page. `/games` itself is a route. +- **AC-13**: `/games` and every list page set a title (`Games`, or the list's h1), the description (the landing description, or the list's note) and `robots: { index: false }`, with no canonical and no `openGraph` or `twitter` fields of their own (spec 0025, AC-10, carried over). No `/games` URL is in `sitemap.xml`; `app/sitemap.test.ts` asserts it. +- **AC-14**: With `NEXT_PUBLIC_GAMES_LIBRARY` on, the GAMES tab shows on every page. It is lit on `/games` and every `/games/*` path; on `/shows`, `/movies`, the title pages and `/search` it links to `/games`; on the three library pages it switches `type` in place as in G5. With the flag off or unset, no GAMES tab shows anywhere (as today), and `/games` and its list pages still answer by URL. +- **AC-15**: The navbar search is not shown on `/games` or any `/games/*` path, the same rule G5 set for a games library tab (spec 0027, AC-4). The check uses `usePathname` only; `useSearchParams` stays limited to the four typed paths, so `/games` and its list pages keep their prerendered shells. Every other page keeps the search. +- **AC-16**: The logo follows a games page: on `/games`, `/games/*` and a `?type=game` library tab it links to `/games` and remembers `game`. On a page with no type, a remembered `game` sends the logo to `/games` only while the flag is on, and to `/shows` otherwise: the stored value is parsed as `LibraryType` and `game` is accepted only when `gamesLibraryEnabled()` is true, so a stale value never leads to a hidden tab. +- **AC-17**: Each empty games library tab (spec 0027, AC-14) gains a `Browse games` button linking to `/games`, and the game not found panel (spec 0025, AC-7) changes its button to `Browse games`, linking to `/games`. +- **AC-18**: The landing's headings, labels and See all links and each list page's back link, h1 and note are in the prerendered shell. Every IGDB read runs at request time (`await connection()` before it, which is also where `today` is read), so `pnpm build` makes no IGDB request and passes with no `IGDB_*` variables set; the build reports `/games` and the five list pages as partially prerendered. +- **AC-19**: No games page writes anything when it loads, and none reads the session outside the hero's `GameTrackingSlot`, inside its own Suspense boundary. No user data enters a cached scope. The request scope test covers `/games` and one list page. +- **AC-20**: At 375px there is no horizontal page scroll on `/games` or a list page; Tab reaches every card in order; reduced motion gives a static page; no colour literal exists outside `app/globals.css`. +- **AC-21**: `/showcase` shows a game landing card, a wide game card with and without art, and a game list page's error and empty states. + +## Decision + +**Chosen option**: Option 1: One landing over a widened list registry, with games as a third source behind the same read seam. + +The landing sections, the list registry, the list page and the read helpers learn a third media type, `game`, read from IGDB through `lib/igdb/`; `lib/igdb/` gains a popularity read, an art field and a page size. + +**Implementation skills**: `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) · `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) + +## Feature design + +**Data model sketch**: no schema change, no migration. The new shapes are in code only: + +| Shape | Where | Fields | +|---|---|---| +| `GameSummary.artUrl` | `lib/igdb/types.ts` | `string \| null`, AC-6 | +| `PopularGamesKind` | `lib/igdb/types.ts` | `"trending" \| "popular"` | +| `DiscoverGamesOptions.pageSize` | `lib/igdb/types.ts` | `20 \| 24`, optional, default 20 | +| `GameListSlug` | `lib/catalog/lists.ts` | the five slugs of AC-9 | +| `GameCatalogList` | `lib/catalog/lists.ts` | `ListText & { mediaType: "game"; slug: GameListSlug }`, joining the `CatalogList` union | +| `CATALOG_PATHS.game` | `lib/catalog/media-type.ts` | `"/games"`; the record is keyed by `LibraryType`, `MediaType` stays `"tv" \| "movie"` | +| `TaggedCatalogPage` game branch | `lib/catalog-list-read.ts` | `{ mediaType: "game"; paged: Paged }` | +| `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`. +- [ ] 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). From 8f000fcff4a089b24375dd876a0fc45179cd5c68 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 17:19:58 +0100 Subject: [PATCH 02/25] feat(igdb): let discover serve pages of 24 Co-Authored-By: Claude Opus 5.5 --- lib/igdb/constants.ts | 6 ++++++ lib/igdb/discover.ts | 1 + lib/igdb/search.test.ts | 28 ++++++++++++++++++++++++++++ lib/igdb/search.ts | 8 +++++++- lib/igdb/summary-page.ts | 18 +++++++++++------- lib/igdb/types.ts | 8 ++++++++ lib/igdb/validation.test.ts | 11 +++++++++++ lib/igdb/validation.ts | 24 ++++++++++++++++++++++-- 8 files changed, 94 insertions(+), 10 deletions(-) diff --git a/lib/igdb/constants.ts b/lib/igdb/constants.ts index 584edbf..f553c6d 100644 --- a/lib/igdb/constants.ts +++ b/lib/igdb/constants.ts @@ -92,6 +92,12 @@ export const IGDB_BATCH_LIMIT = 500; /** Results per page of a search or discover read (AC-12). */ export const IGDB_PAGE_SIZE = 20; +/** + * The page sizes discover accepts: the default, and 24 for a games list page, + * whose grid fills every row at 24 (spec 0028, AC-5). + */ +export const IGDB_DISCOVER_PAGE_SIZES = [IGDB_PAGE_SIZE, 24] as const; + /** The last page a caller may ask for; a later one throws (AC-12b). */ export const IGDB_MAX_PAGE = 50; 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/search.test.ts b/lib/igdb/search.test.ts index 01c77ac..ee1e886 100644 --- a/lib/igdb/search.test.ts +++ b/lib/igdb/search.test.ts @@ -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/types.ts b/lib/igdb/types.ts index a9846e6..d31c7bd 100644 --- a/lib/igdb/types.ts +++ b/lib/igdb/types.ts @@ -99,4 +99,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 = 20 | 24; 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..4b18847 100644 --- a/lib/igdb/validation.ts +++ b/lib/igdb/validation.ts @@ -1,14 +1,17 @@ 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, SearchGamesOptions, @@ -116,6 +119,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 +238,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 +273,6 @@ export function validateDiscoverOptions( minHypes: nonNegative("minHypes", options.minHypes), sort: options.sort, page: page(options.page), + pageSize: pageSize(options.pageSize), }); } From 777d2654fa85d9232f8373d068b8b7726aad4228 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 17:21:18 +0100 Subject: [PATCH 03/25] feat(igdb): add wide art to every game summary Co-Authored-By: Claude Opus 5.5 --- components/library/game-library-tab.test.tsx | 1 + lib/igdb/constants.ts | 25 ++++++++++- lib/igdb/normalize.test.ts | 41 ++++++++++++++++++ lib/igdb/normalize.ts | 32 ++++++++++++++ lib/igdb/schemas.ts | 16 ++++++- lib/igdb/search.test.ts | 2 +- lib/igdb/types.ts | 5 +++ lib/images/igdb-loader.test.ts | 23 +++++++++- lib/images/igdb-loader.ts | 44 +++++++++++++------- 9 files changed, 169 insertions(+), 20 deletions(-) diff --git a/components/library/game-library-tab.test.tsx b/components/library/game-library-tab.test.tsx index 6629243..327fb52 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, diff --git a/lib/igdb/constants.ts b/lib/igdb/constants.ts index f553c6d..4e97e55 100644 --- a/lib/igdb/constants.ts +++ b/lib/igdb/constants.ts @@ -37,6 +37,26 @@ 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 16:9 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. `pnpm igdb:live` checks each + * real width. + */ +export const IGDB_ART_LADDER: readonly { size: string; width: number }[] = [ + { size: "t_screenshot_med", width: 569 }, + { size: "t_screenshot_big", width: 889 }, + { 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, @@ -131,11 +151,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/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/schemas.ts b/lib/igdb/schemas.ts index ba90034..4a538bd 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,11 +76,16 @@ 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; diff --git a/lib/igdb/search.test.ts b/lib/igdb/search.test.ts index ee1e886..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), diff --git a/lib/igdb/types.ts b/lib/igdb/types.ts index d31c7bd..ea2235d 100644 --- a/lib/igdb/types.ts +++ b/lib/igdb/types.ts @@ -44,6 +44,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; diff --git a/lib/images/igdb-loader.test.ts b/lib/images/igdb-loader.test.ts index 106a7d8..1b9dca3 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"], + [569, "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; From 68668f73a8330299621265e61f5528729a067f48 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 17:24:32 +0100 Subject: [PATCH 04/25] feat(igdb): read the trending and popular lists from IGDB popularity Co-Authored-By: Claude Opus 5.5 --- lib/igdb/constants.ts | 26 +++++++-- lib/igdb/igdb.live.ts | 100 +++++++++++++++++++++++++++++++-- lib/igdb/index.ts | 2 + lib/igdb/popularity.test.ts | 89 +++++++++++++++++++++++++++++ lib/igdb/popularity.ts | 57 +++++++++++++++++++ lib/igdb/query.ts | 4 +- lib/igdb/reads.test.ts | 21 +++++++ lib/igdb/reads.ts | 31 +++++++++- lib/igdb/schemas.ts | 8 +++ lib/igdb/surface.test.ts | 4 +- lib/igdb/types.ts | 6 ++ lib/igdb/validation.ts | 16 ++++++ lib/images/igdb-loader.test.ts | 2 +- 13 files changed, 350 insertions(+), 16 deletions(-) create mode 100644 lib/igdb/popularity.test.ts create mode 100644 lib/igdb/popularity.ts diff --git a/lib/igdb/constants.ts b/lib/igdb/constants.ts index 4e97e55..7a621c5 100644 --- a/lib/igdb/constants.ts +++ b/lib/igdb/constants.ts @@ -45,14 +45,17 @@ export const IGDB_COVER_LADDER: readonly { size: string; width: number }[] = [ export const IGDB_ART_SIZE = "t_1080p"; /** - * IGDB's 16:9 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. `pnpm igdb:live` checks each - * real width. + * 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: 569 }, - { size: "t_screenshot_big", width: 889 }, + { size: "t_screenshot_med", width: 555 }, + { size: "t_screenshot_big", width: 940 }, { size: "t_720p", width: 1280 }, { size: IGDB_ART_SIZE, width: 1920 }, ]; @@ -70,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; 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/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..aab1b47 --- /dev/null +++ b/lib/igdb/popularity.ts @@ -0,0 +1,57 @@ +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 { + const type: PopularGamesKind = kind; + const { data } = await igdbRequest( + "popularity_primitives", + buildQuery({ + fields: ["game_id", "value"], + where: [cond.is("popularity_type", IGDB_POPULARITY_TYPES[type].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 4a538bd..484abfa 100644 --- a/lib/igdb/schemas.ts +++ b/lib/igdb/schemas.ts @@ -90,3 +90,11 @@ export const gameSummarySchema = baseGameSchema.extend({ 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/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 ea2235d..8fe942a 100644 --- a/lib/igdb/types.ts +++ b/lib/igdb/types.ts @@ -59,6 +59,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[] }; diff --git a/lib/igdb/validation.ts b/lib/igdb/validation.ts index 4b18847..ed22e63 100644 --- a/lib/igdb/validation.ts +++ b/lib/igdb/validation.ts @@ -14,6 +14,7 @@ import type { DiscoverPageSize, DiscoverSort, GameFilters, + PopularGamesKind, SearchGamesOptions, } from "./types"; @@ -72,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)) { diff --git a/lib/images/igdb-loader.test.ts b/lib/images/igdb-loader.test.ts index 1b9dca3..95208d0 100644 --- a/lib/images/igdb-loader.test.ts +++ b/lib/images/igdb-loader.test.ts @@ -53,7 +53,7 @@ describe("igdbImageLoader", () => { // spec 0028, AC-6: art steps down its own ladder, never the cover one. it.each([ [300, "t_screenshot_med"], - [569, "t_screenshot_med"], + [555, "t_screenshot_med"], [800, "t_screenshot_big"], [1200, "t_720p"], [1600, "t_1080p"], From 6d7f2389e8a201c2dda262f2957eadd2a5883d66 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 17:32:38 +0100 Subject: [PATCH 05/25] feat(games): add the five paged games list pages and teach the landing sections games Co-Authored-By: Claude Opus 5.5 --- app/catalog-lists.test.ts | 14 ++- app/games/coming-soon/page.tsx | 17 +++ app/games/popular/page.tsx | 17 +++ app/games/recently-released/page.tsx | 17 +++ app/games/top-rated/page.tsx | 17 +++ app/games/trending/page.tsx | 17 +++ app/sitemap.test.ts | 8 ++ components/catalog/carousel-row-states.tsx | 9 +- components/catalog/catalog-landing.tsx | 8 +- components/catalog/catalog-list-cards.tsx | 15 ++- components/catalog/catalog-list-page.test.tsx | 114 +++++++++++++++++- components/catalog/catalog-list-page.tsx | 52 ++++++-- .../catalog/landing/landing-badge.test.tsx | 55 ++++++++- components/catalog/landing/landing-badge.tsx | 46 +++++-- .../catalog/landing/landing-hero.test.tsx | 80 ++++++++++++ components/catalog/landing/landing-hero.tsx | 43 ++++--- components/catalog/landing/landing-items.ts | 55 +++++++-- .../catalog/landing/landing-sections.test.tsx | 103 ++++++++++++++++ components/catalog/landing/upcoming-rows.tsx | 6 +- components/catalog/list-cards.tsx | 28 ++++- components/layout/media-type-tabs.tsx | 2 +- components/pagination-links.tsx | 12 +- lib/catalog-list-read.test.ts | 112 ++++++++++++++++- lib/catalog-list-read.ts | 102 +++++++++++++++- lib/catalog/game-lists.test.ts | 49 ++++++++ lib/catalog/game-lists.ts | 85 +++++++++++++ lib/catalog/landing.test.ts | 29 +++++ lib/catalog/landing.ts | 37 +++++- lib/catalog/lists.test.ts | 2 +- lib/catalog/lists.ts | 90 ++++++++++++-- lib/catalog/media-type.ts | 7 +- lib/landing-metadata.ts | 40 +++++- proxy.test.ts | 16 ++- proxy.ts | 15 +-- 34 files changed, 1204 insertions(+), 115 deletions(-) create mode 100644 app/games/coming-soon/page.tsx create mode 100644 app/games/popular/page.tsx create mode 100644 app/games/recently-released/page.tsx create mode 100644 app/games/top-rated/page.tsx create mode 100644 app/games/trending/page.tsx create mode 100644 lib/catalog/game-lists.test.ts create mode 100644 lib/catalog/game-lists.ts 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(") { + 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/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/sitemap.test.ts b/app/sitemap.test.ts index 6f3de75..f66c724 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -157,6 +157,14 @@ describe("the sitemap on an indexable deployment", () => { } }); + 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/catalog/carousel-row-states.tsx b/components/catalog/carousel-row-states.tsx index d01d1c3..e1300ec 100644 --- a/components/catalog/carousel-row-states.tsx +++ b/components/catalog/carousel-row-states.tsx @@ -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 (

Couldn't load {list.rowTitle}

-

TMDB didn't respond.

+

+ {list.mediaType === "game" ? "IGDB" : "TMDB"} didn't respond. +

); diff --git a/components/catalog/catalog-landing.tsx b/components/catalog/catalog-landing.tsx index b12cbea..1244f6f 100644 --- a/components/catalog/catalog-landing.tsx +++ b/components/catalog/catalog-landing.tsx @@ -6,11 +6,11 @@ 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 + * 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. * @@ -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..ba4f2c7 100644 --- a/components/catalog/catalog-list-cards.tsx +++ b/components/catalog/catalog-list-cards.tsx @@ -1,9 +1,13 @@ -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 + * 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 @@ -42,6 +46,13 @@ function CatalogListCards({
  • )); } + if (result.mediaType === "game") { + return result.paged.results.slice(0, limit).map((game, index) => ( +
  • + +
  • + )); + } 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..972d5f6 100644 --- a/components/catalog/catalog-list-page.test.tsx +++ b/components/catalog/catalog-list-page.test.tsx @@ -17,10 +17,16 @@ 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/catalog-list-read", async (importActual) => ({ + lastListPage: (await importActual()) + .lastListPage, readTaggedCatalogGridPage: (...args: unknown[]) => read(...args), })); vi.mock("@/components/catalog/list-cards", () => ({ @@ -38,6 +44,9 @@ vi.mock("@/components/catalog/list-cards", () => ({ {props.show.name} ), + GameListCard: (props: { game: { name: string }; priority: boolean }) => ( + {props.game.name} + ), MovieListCard: (props: { movie: { title: string }; priority: boolean; @@ -50,6 +59,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 +358,105 @@ 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("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 nothing here 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..27bcc1f 100644 --- a/components/catalog/catalog-list-page.tsx +++ b/components/catalog/catalog-list-page.tsx @@ -14,18 +14,37 @@ import { type CatalogList, listPageHref, listPath } from "@/lib/catalog/lists"; import { CATALOG_PATHS } from "@/lib/catalog/media-type"; import { parsePageParam } from "@/lib/catalog/pages"; import { + lastListPage, readTaggedCatalogGridPage, type TaggedCatalogPage, } from "@/lib/catalog-list-read"; +import { IgdbError } from "@/lib/igdb"; +import { IGDB_MAX_PAGE } from "@/lib/igdb/constants"; import { TmdbError } from "@/lib/tmdb"; -const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const; +const LANDING_LABELS = { + tv: "Shows", + movie: "Movies", + game: "Games", +} as const satisfies Record; + +/** The catalog a list's titles come from, named in its error and empty copy. */ +const SOURCE_NAMES = { + tv: "TMDB", + movie: "TMDB", + game: "IGDB", +} as const satisfies Record; + +/** A failure of the list's own catalog, which the retry panel answers. */ +function isCatalogError(error: unknown): boolean { + return error instanceof TmdbError || error instanceof IgdbError; +} 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 + * 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 @@ -81,13 +100,14 @@ async function ListGrid({ try { result = await readTaggedCatalogGridPage(list, page); } catch (error) { - if (!(error instanceof TmdbError)) throw error; + if (!isCatalogError(error)) throw error; + const source = SOURCE_NAMES[list.mediaType]; return (
    } />
    @@ -100,14 +120,21 @@ async function ListGrid({ ); } - 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 = + lastPage === null && + result.mediaType === "game" && + result.paged.hasNextPage && + page < IGDB_MAX_PAGE; return (
    @@ -119,7 +146,12 @@ async function ListGrid({ /> - +
    ); } 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..91151dd 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, @@ -40,7 +41,7 @@ const LANDING_SLOTS: Record< badge: (item, ids) => ( ), @@ -61,7 +62,7 @@ const LANDING_SLOTS: Record< ), @@ -85,16 +86,32 @@ const LANDING_SLOTS: Record< }, }; +/** + * 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 }) { + if (item.mediaType === "game") { + return ; + } return ( - }> + }> {LANDING_SLOTS[item.mediaType].badge(item, ids)} ); @@ -107,7 +124,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,6 +136,7 @@ function LandingControls({ ids: Ids; releasePill?: boolean; }) { + if (item.mediaType === "game") return null; return ( {LANDING_SLOTS[item.mediaType].controls(item, ids, releasePill)} @@ -128,14 +147,23 @@ function LandingControls({ /** * 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 ( - {LANDING_SLOTS[item.mediaType].tracking(item)} + {item.mediaType === "game" ? ( + + ) : ( + LANDING_SLOTS[item.mediaType].tracking(item) + )} ); } -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..08722b1 100644 --- a/components/catalog/landing/landing-hero.tsx +++ b/components/catalog/landing/landing-hero.tsx @@ -5,18 +5,17 @@ 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 { getGameGenres, IgdbError } 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 +84,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 +145,32 @@ 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); both shapes are `{ id, name }`. + */ +const GENRE_LISTS: Record< + LibraryType, + () => Promise<{ id: number; name: string }[]> +> = { 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[]; + let genres: { id: number; name: string }[]; try { genres = await GENRE_LISTS[item.mediaType](); } catch (error) { - if (!(error instanceof TmdbError)) throw error; + if (!(error instanceof TmdbError) && !(error instanceof IgdbError)) { + throw error; + } return []; } const names = new Map(genres.map((genre) => [genre.id, genre.name])); diff --git a/components/catalog/landing/landing-items.ts b/components/catalog/landing/landing-items.ts index 83379c5..5980b2d 100644 --- a/components/catalog/landing/landing-items.ts +++ b/components/catalog/landing/landing-items.ts @@ -1,28 +1,38 @@ 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 { readTaggedCatalogList, type TaggedCatalogPage, } from "@/lib/catalog-list-read"; +import { IgdbError } from "@/lib/igdb"; 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 +47,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 +62,17 @@ export async function readLandingList( try { return await readTaggedCatalogList(list, page); } catch (error) { - if (!(error instanceof TmdbError)) throw error; + if (!(error instanceof TmdbError) && !(error instanceof IgdbError)) { + 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 +88,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 +115,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..55582e3 100644 --- a/components/catalog/landing/upcoming-rows.tsx +++ b/components/catalog/landing/upcoming-rows.tsx @@ -108,9 +108,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") { diff --git a/components/catalog/list-cards.tsx b/components/catalog/list-cards.tsx index 925b038..a484829 100644 --- a/components/catalog/list-cards.tsx +++ b/components/catalog/list-cards.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; import { PosterCard } from "@/components/poster-card"; -import { TmdbRatingBadge } from "@/components/rating-badges"; +import { IgdbRatingBadge, TmdbRatingBadge } from "@/components/rating-badges"; import { MovieCardBadge, MovieCardControls, @@ -11,6 +11,8 @@ import { ShowCardControls, ShowCardMeta, } from "@/components/tracking/show-card-slots"; +import { gameHref } from "@/lib/catalog/media-type"; +import type { GameSummary } from "@/lib/igdb"; import type { MovieSummary, TvShowSummary } from "@/lib/tmdb"; type ListCardProps = { @@ -126,4 +128,26 @@ 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. + */ +function GameListCard({ + game, + priority, + sizes, +}: Omit & { game: GameSummary }) { + return ( + } + priority={priority} + sizes={sizes} + /> + ); +} + +export { GameListCard, MovieListCard, ShowListCard }; diff --git a/components/layout/media-type-tabs.tsx b/components/layout/media-type-tabs.tsx index 3fcd64f..a8d6b25 100644 --- a/components/layout/media-type-tabs.tsx +++ b/components/layout/media-type-tabs.tsx @@ -101,7 +101,7 @@ const TABS = [ */ function MediaTypeTabsView({ selected, - hrefs = CATALOG_PATHS, + hrefs = { tv: CATALOG_PATHS.tv, movie: CATALOG_PATHS.movie }, className, }: { selected: LibraryType | null; 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 (