From f674af69dd6053067edbc7eb64d47988ce07bce4 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 19:12:42 +0100 Subject: [PATCH 01/22] docs(specs): add spec 0029 for game search and filters Co-Authored-By: Claude Opus 5.5 --- docs/scope/games.md | 15 +- docs/specs/0010-search-and-filters/index.md | 1 + .../0023-igdb-integration-module/index.md | 1 + docs/specs/0027-games-library-pages/index.md | 1 + .../specs/0028-games-home-list-pages/index.md | 1 + docs/specs/0029-game-search-filters/index.md | 220 ++++++++++++++++++ .../0029-game-search-filters/rationale.md | 68 ++++++ 7 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 docs/specs/0029-game-search-filters/index.md create mode 100644 docs/specs/0029-game-search-filters/rationale.md diff --git a/docs/scope/games.md b/docs/scope/games.md index 38eb9a2a..3629ea05 100644 --- a/docs/scope/games.md +++ b/docs/scope/games.md @@ -30,7 +30,7 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md | 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 | done | -| G7 | Game search and filters | Slice G4 | planned | +| G7 | Game search and filters | Slice G4 | in-progress | | G8 | Game cards everywhere | Slice G4 | planned | | G9 | Richer game page | Slice G5 | planned | | G10 | Games launch | Slice G6 | planned | @@ -133,10 +133,19 @@ spec [0028](../specs/0028-games-home-list-pages/index.md) · code in `app/games/ ## Slice G4: Search and cards -### G7. Game search and filters · needs a decision +### G7. Game search and filters · in-progress Games in `/search?type=game` with a title query and filters for genre, release year, minimum IGDB rating and platform. **Done when:** every shown result meets every selected filter; filters live in shareable URL parameters and survive opening a game and coming back; pagination works and counts stay truthful (omitted or marked partial when unknown); loading, empty and failure states have a retry path. -- [ ] Design it (spec): `/architect game search and filters` +- [x] Design it (spec): `/architect game search and filters` +- [ ] Build it: `/develop game search and filters` + - [ ] Thin thread: `SearchType` widened behind the flag, `pageSize` on `searchGames`, a titled game search to a grid of `GameListCard`s with an exact count and pages, AC-1, AC-3, AC-6, AC-8, AC-12 + - [ ] Module and URL: genres as all of, `minRatingCount` on search, `requireSortField`, the live checks, then every games parameter, discover and browse, AC-2 to AC-5, AC-7, AC-8, AC-19 + - [ ] Filter bar and states: the IGDB genres, `PlatformPicker` with its filter box and the 20 cap, the IGDB rating labels, list failures, the games copy, AC-9 to AC-11, AC-13 + - [ ] Navigation and quick search: `Search games` on games pages, GAMES in place on `/search`, the logo, `/api/search` for games and the `rating` rename, AC-14 to AC-18 + - [ ] Proof: `/showcase`, request scope tests, the prerendered shell, 375px and 1280px, keyboard, all checks, AC-20, AC-21 +- [ ] Verify it: `/check verify game search and filters` +- [ ] Test it: `/test game search and filters` +spec [0029](../specs/0029-game-search-filters/index.md) · code in `app/search/`, `components/search/`, `lib/search/`, `lib/igdb/` ### G8. Game cards everywhere · needs a decision Game cards match the movie card: IGDB rating badge, your score when set, release year, and a status chip with a small control. diff --git a/docs/specs/0010-search-and-filters/index.md b/docs/specs/0010-search-and-filters/index.md index e61a0015..55b17509 100644 --- a/docs/specs/0010-search-and-filters/index.md +++ b/docs/specs/0010-search-and-filters/index.md @@ -2,6 +2,7 @@ **Date**: 2026-09-24 **Status**: Accepted +**Amended**: 2026-10-25 by [spec 0029](../0029-game-search-filters/index.md). `/search` also takes `type=game` while the games flag is on, with a repeatable `platform` parameter and its own three modes (AC-7, AC-18); `/api/search` accepts `game`, renames `tmdbRating` to `rating` and may send a null `totalResults` (AC-20). Scope feature: [11. Search and filters](../../scope/scope.md) · Beta tier diff --git a/docs/specs/0023-igdb-integration-module/index.md b/docs/specs/0023-igdb-integration-module/index.md index 34faec38..5de28ace 100644 --- a/docs/specs/0023-igdb-integration-module/index.md +++ b/docs/specs/0023-igdb-integration-module/index.md @@ -2,6 +2,7 @@ **Date**: 2026-10-17 **Status**: Accepted +**Amended**: 2026-10-25 by [spec 0029](../0029-game-search-filters/index.md), AC-19. AC-12: several genres now match all of them (`genres = [ids]`), platforms still any; `searchGames` takes `minRatingCount` and `pageSize` 20 or 24. AC-13: `discoverGames` takes `requireSortField` (default `true`). Scope feature: [G1. IGDB integration module](../../scope/games.md#g1-igdb-integration-module) · Beta tier diff --git a/docs/specs/0027-games-library-pages/index.md b/docs/specs/0027-games-library-pages/index.md index 79da7f55..0ac17928 100644 --- a/docs/specs/0027-games-library-pages/index.md +++ b/docs/specs/0027-games-library-pages/index.md @@ -3,6 +3,7 @@ **Date**: 2026-10-24 **Status**: Accepted (amended 2026-10-25 after code review: `LibraryErrorBoundary` written in, the card decisions moved to the tab, the shared focus hook and counted page read) **Amended**: 2026-10-25 by [spec 0028](../0028-games-home-list-pages/index.md). AC-4: on a games tab the logo links to `/games` and remembers `game`, accepted only while the games flag is on (0028, AC-16). AC-14: each empty games tab gains a `Browse games` button linking to `/games` (0028, AC-17). +**Amended**: 2026-10-25 by [spec 0029](../0029-game-search-filters/index.md). AC-2: `typedHref("/search", "game")` now compiles. AC-4: the navbar search shows on a games tab and searches games. Scope feature: [G5. Games tab and library pages](../../scope/games.md) · GA tier diff --git a/docs/specs/0028-games-home-list-pages/index.md b/docs/specs/0028-games-home-list-pages/index.md index 76832dc9..8669c6c9 100644 --- a/docs/specs/0028-games-home-list-pages/index.md +++ b/docs/specs/0028-games-home-list-pages/index.md @@ -2,6 +2,7 @@ **Date**: 2026-10-25 **Status**: Accepted +**Amended**: 2026-10-25 by [spec 0029](../0029-game-search-filters/index.md). AC-14: on `/search` the GAMES tab switches `type` in place. AC-15: the navbar search shows on `/games` and every `/games/*` path and searches games. Scope feature: [G6. Games home and list pages](../../scope/games.md) · Beta tier diff --git a/docs/specs/0029-game-search-filters/index.md b/docs/specs/0029-game-search-filters/index.md new file mode 100644 index 00000000..38dcd433 --- /dev/null +++ b/docs/specs/0029-game-search-filters/index.md @@ -0,0 +1,220 @@ +# 0029. Game search and filters: games on /search and in the navbar quick search + +**Date**: 2026-10-25 +**Status**: In Progress + +Scope feature: [G7. Game search and filters](../../scope/games.md) · Beta tier + +## Summary + +This adds games to search. `/search?type=game` finds games by title and narrows them by genre, platform, release year and minimum IGDB community rating, and the navbar quick search now works on every games page. IGDB applies every filter inside the search query itself and returns an exact count, so unlike shows and movies, game search needs no page scan and no partial counts: every result meets every filter, and every count is IGDB's own. The work extends the existing `/search` page, its filter bar and its quick search, plus four small changes to the IGDB module. Nothing is stored, and no session is read. + +## Requirements + +**User stories**: + +- As a visitor, I want to type a game's title in the navbar on any games page and jump straight to it, so that finding a game feels like finding a show or a movie. +- As a visitor, I want to narrow games by genre, the platforms I own, a release year and a minimum IGDB rating, with or without a title, so that I can discover something to play. +- As a visitor, I want the URL to hold my game search, so that I can share it, reload it, and come back to it after opening a game. +- As a visitor, I want every game shown and every count to be true to my filters, so that I can trust the page. + +**Acceptance criteria** (the contract, each independently checkable): + +- **AC-1**: `/search` accepts `type=game` only while `NEXT_PUBLIC_GAMES_LIBRARY` is `on`, read on the server through `getGamesLibrary()` exactly as `LibrarySection` reads it, and only when the raw `type` is `game`: a shows or movies search never calls it, so an invalid flag value can never take shows and movies search down. The result is passed to `parseSearchParams` as `gamesEnabled`, which parses `type` with `parseLibraryTypeParam`. With the flag off or unset, `/search?type=game` shows the existing `That filter isn't valid` panel naming `type`, as it does today. `SearchType` becomes `LibraryType` (`tv | movie | game`), but every TMDB read (`readSearchPage`, `readDiscoverPage`, `readSearchGridPage`, `scanFilteredSearch`, `toQuickSearchResponse`) keeps taking `MediaType`, so the compiler forces a narrowing before each call and `game` can never fall into a TMDB `else` branch; `mediaNoun` and every other function that switches on the type is an exhaustive switch. `typedHref("/search", "game")` compiles; the `@ts-expect-error` line spec 0027 added to prove it could not is replaced by a test that it writes `type=game`. +- **AC-2**: For `type=game` the URL parameters are `q` (trimmed, then cleaned by the IGDB module's own `cleanSearchText` rule, so a `q` that holds a control character or is empty after cleaning is invalid here and never reaches the module as an `IgdbValidationError`; 1 to 100 characters), `genre` (repeatable, each an id in `getGameGenres()`, at most 20), `platform` (repeatable, each an id in `getGamePlatforms()`, at most 20; for both lists, repeated ids are removed before the 20 is counted), `year` (a whole year from 1950 to next UTC year), `rating` (one of `5`, `6`, `7`, `8`, `9`) and `page` (1 to 500, as `parsePageParam` reads it). A repeated single value, an id not in its list, more than 20 ids in one list, or a value out of range shows `That filter isn't valid` naming the parameter, with no IGDB result request (the genre and platform list reads are allowed). A `platform` on `type=tv` or `type=movie` is invalid the same way. `searchHref` writes `platform` ids sorted ascending after the genres, so one search has one URL. Opening a game and pressing Back returns the same URL with the same results. +- **AC-3**: A games search has three modes, decided only by the parsed URL: **browse** (no `q`, no filter), **discover** (no `q`, at least one of genre, platform, year or rating) and **search** (`q` set, with any filters). There is no filtered search mode for games, because IGDB applies every filter in the same query. Every displayed game meets every selected filter, and no game the catalog filter hides ever shows. +- **AC-4**: Browse mode shows IGDB's Popular list, read through `getPopularGames("popular")` (the cached read `/games/popular` uses, so one shared cache entry) and cut into 24 game pages by a pure slicing helper that `readGameListPage` also uses, with no clock read. It shows the games in rank order, headed by the line `Popular games on IGDB`, with numbered `PaginationLinks` over its exact length. If the list is empty on page 1, the empty `StatePanel` reads `No popular games right now` with no `Clear filters` action (there is nothing to clear); a page past the end shows `That page doesn't exist`. +- **AC-5**: Discover mode calls `discoverGames` with `sort: "rating_count_desc"`, `requireSortField: false`, `pageSize: 24`, the genres, the platforms, the year as `releasedFrom: "{year}-01-01"` and `releasedTo: "{year}-12-31"`, and, when a rating is set, `minRating: rating` and `minRatingCount: 10`. The most rated games come first, and games with no ratings follow them instead of being dropped, so the count includes them. IGDB takes one sort key, so the order inside the unrated tail is IGDB's own; as spec 0023, AC-13 already accepts for ties, a game in that tail may repeat or be skipped across pages, and that is accepted. +- **AC-6**: Search mode calls `searchGames` with the query, every filter (genres, platforms, year, and with a rating `minRating` and `minRatingCount: 10`) and `pageSize: 24`, in IGDB's relevance order, with no other sort. +- **AC-7**: Selecting several genres means a game must have all of them (IGDB `genres = [ids]`). Selecting several platforms means a game must be on any of them (IGDB `platforms = (ids)`). Different filters combine with AND. +- **AC-8**: In discover and search mode the count line reads `{n} games` (`1 game`) with thousands separators, IGDB's exact count, with no `+` cap wording. When IGDB sends no count (`totalResults` is null) the count line is left out. Paging uses `PaginationLinks` with the last page `min(ceil(total / 24), 50)`; with no count, it offers Next only while `hasNextPage` is true, as the games list pages do (spec 0028, AC-10). That rule is extracted from `lastListPage` into a pure helper over `IgdbPaged`, which both the list pages and search call. A `page` past the last page, or above 50, shows `That page doesn't exist` with a link to page 1; above 50 it is decided before any IGDB request. +- **AC-9**: On `type=game` the filter bar shows, built from the existing glass controls: the text field, `Genres` (IGDB genres, legend `MUST HAVE ALL OF`), `Platforms` (AC-10), the year select (`Any year`, then next year down to 1950), the rating select labelled as the IGDB rating (`Any IGDB rating`, `5+` to `9+`), and `Clear filters` when anything is set. When a rating is set, `Only games with at least 10 IGDB ratings` shows beside it. The bar keeps every behaviour of spec 0010, AC-9: a real `GET` form, `Apply` before hydration, instant replace after it, the 400 ms text pause, `page` reset to 1, and the pending indicator. On `type=game`, genre and platform checkbox toggles also wait for a 400 ms pause after the last toggle before they apply, so several quick clicks make one IGDB read; the boxes show checked at once. `Clear filters` shows when a platform is set too (`hasAnyFilter` and the bar's own check count `platformIds`). +- **AC-10**: `Platforms` is a popover like `Genres`: the trigger reads `Platforms` or `Platforms · {n}`; inside, the legend `ON ANY OF`, a text box `Filter platforms` that gets focus when the popover opens and narrows the list by a case insensitive match anywhere in the name, then one checkbox per platform in name order (all of `getGamePlatforms()`, about 220). A filter that matches nothing shows `No platforms match`. Clearing the box shows the full list again, and checked platforms stay checked while hidden by the filter. Each row is a 44px target on touch. Both pickers stop at 20 (the module's cap): once 20 are checked, every unchecked box is disabled and the hint `Up to 20` shows, so the bar can never build a URL that is invalid for that reason. Selected platforms travel as hidden `platform` inputs, so a submit before hydration keeps them. +- **AC-11**: If the genre list read fails, `Genres` is disabled with `Genres unavailable`; if the platform list read fails, `Platforms` is disabled with `Platforms unavailable`; the rest of the bar works either way. If the URL has a `genre` (or `platform`) and that list failed, only the results area shows the IGDB error panel, because the ids cannot be checked. A failed list whose parameter is not in the URL is passed to the parser as `[]`, so a search that needs only the other list still runs, as the TMDB genre code already does. +- **AC-12**: Every game result is a `GameListCard` in the existing `PosterGrid`, linking to `/games/{id}`, with the cover (or the missing art state), the name, the release year as its meta line (left out when unknown) and the `IgdbRatingBadge` when rated. Game cards carry no status and no controls, and read no session, until G8. +- **AC-13**: The games copy of the states: `No games match these filters` (`Try fewer genres or platforms, another year or a lower rating.`) or `No games match “{q}”` (`Check the spelling, or try a shorter title.`), each with `Clear filters` to `/search?type=game`; `That filter isn't valid` naming the parameter; and on any IGDB failure `Couldn't reach IGDB` (`IGDB didn't respond. Try again in a moment.`) with a `Try again` `RetryLink` to the same URL. The heading, the navbar and the filter bar stay usable in every state. The loading skeleton keeps the footprint of spec 0010, AC-21. +- **AC-14**: On `/search`, while the flag is on, the navbar tabs are SHOWS | MOVIES | GAMES and switch `type` in place through `typedHref("/search", type, { current })`: `q`, `year` and `rating` are kept, `genre`, `platform` and `page` are dropped. A kept `year` the new type cannot take (below 1950 for `game`) is dropped too, so a switch never lands on an invalid panel. GAMES is lit on `/search?type=game`. +- **AC-15**: While the flag is on, the navbar search (the desktop field and the mobile search icon) shows on `/games`, on every `/games/*` path, on the three library pages with `type=game` and on `/search?type=game`, and searches games there, with the placeholder `Search games`. The search reads `libraryType` through `WithLibraryTypedPage`, the field the tabs already use, and `PageMediaType.type` stays as it is for the logo. Its Suspense fallback takes the placeholder from the pathname where the prerendered shell knows it, so it reads `Search games` on `/games` and the five list pages. On `/games/{id}` and on the typed paths the shell cannot know the type, so `Search shows` shows once before the type resolves, the one time flash spec 0027, AC-4 already accepted. Only the pathname is read off the four typed paths, so `/games` and its list and game pages keep their prerendered shells. With the flag off, nothing changes from today. +- **AC-16**: Games quick search keeps every behaviour of spec 0010, AC-2 to AC-6 (debounce, cancellation, combobox keys, mobile overlay, skeleton and error rows). Each row shows the cover in the 3:4 frame the game cards use (or the game missing cover icon), the name, the release year (left out when unknown) and the amber star with the IGDB community rating, its screen reader label `IGDB community rating`. The row takes its label from `CATALOG_SOURCES[type]` and uses the same amber colour token as `IgdbRatingBadge`. The cover's `sizes` resolves to a real `IGDB_COVER_LADDER` rung, checked by the loader test. The footer reads `{n} games` from IGDB's count, or just `See all` when IGDB sends no count; See all opens `/search?type=game&q={query}`. No match reads `No games match “{query}”`, and a failure reads `Couldn't reach IGDB` with `Try again`. +- **AC-17**: `GET /api/search` accepts `type=game` only while the flag is on (otherwise 400 `{ error: "invalid" }`), reads only the cached `searchGames` page 1 at the default 20 and returns its first 5 games. The response field `tmdbRating` is renamed `rating` for every type, and `totalResults` is `number | null`. An `IgdbError` answers 502 `{ error: "upstream", kind }`, and an `IgdbValidationError` (a class of its own, not an `IgdbError`) answers 400 `{ error: "invalid" }`, never a 500. `getGamesLibrary()` is read only when the `type` parameter is `game`. The cache headers, the `no-store` errors and the rule that it reads no cookie, header or session stay as spec 0010, AC-20 sets them. +- **AC-18**: On `/search?type=game` the logo links to `/games` and remembers `game`, as on a games tab (spec 0028, AC-16): `libraryTypeForLocation` treats `/search` like a library page for `game`. +- **AC-19**: The IGDB module changes, each validated before the cache and part of the cache key: genres match all of the ids (`cond.allOf`, writing `genres = [ids]`) while platforms keep any of; `minRatingCount` moves into `GameFilters`, so `searchGames` accepts it; `searchGames` accepts `pageSize` 20 or 24 like `discoverGames`; and `discoverGames` accepts `requireSortField` (boolean, default `true`), which with `false` leaves out the `{sort field} != null` condition. G6's lists pass none of these and are unchanged. In `validation.ts`, `minRatingCount` moves into the shared `filters()` validator and its condition into `sharedConditions`, and the discover only copies in `validateDiscoverOptions` and `fetchDiscoverGames` are removed, so the condition is written once; `requireSortField` joins `ValidDiscoverOptions`; `pageSize` joins `ValidSearchOptions` with the same 20 or 24 check and default 20, and `fetchSearchGames` reads it instead of `IGDB_PAGE_SIZE`. The fixture suite covers each, and `pnpm igdb:live` gains two checks: a two genre `[]` filter returns only games with both genres, and a `rating_count_desc` discover with `requireSortField: false` puts every unrated game after every rated one and counts them. +- **AC-20**: `/search` still serves a prerendered shell (`pnpm build` reports it partially prerendered, no `instant = false`, no IGDB request at build time). No route or handler here reads `cookies()`, `headers()` or a session for a games search, no `use cache` scope receives a user value, and the request scope and layout purity tests cover `type=game`. `/search` stays `noindex, follow`. +- **AC-21**: `/showcase` shows the platform picker open with a filter typed, its `No platforms match` state and its `Platforms unavailable` state, and a games quick search row with and without a cover. At 375px there is no horizontal page scroll on `/search?type=game`, with the bar wrapping as it does for shows; a keyboard only pass reaches every control and card in order; no colour literal exists outside `app/globals.css`. + +## Decision + +**Chosen option**: Option 1: a games branch inside the existing `/search`, with every filter sent to IGDB. + +`/search?type=game` reuses the page, the filter bar, the result states and quick search of spec 0010, with its own three modes that send every filter to IGDB, so every result and every count is IGDB's own. Four small IGDB module options make that possible. + +**Implementation skills**: `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) for verifying the games filter bar and quick search in the running app · `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) for keeping `/search` and `/games` in their prerendered shells while the navbar search reads the games type. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +**Data model sketch**: no tables and no migration. Nothing about a search is stored. The app's own types change: + +| Type | Change | +|---|---| +| `SearchType` (`lib/search/params.ts`) | `LibraryType` instead of `MediaType` | +| `SearchParams` | gains `platformIds: number[]` (deduplicated, sorted ascending, always `[]` for `tv` and `movie`) | +| `SearchParamName` | gains `"platform"` | +| `FilterBar` props (`components/search/filter-bar.tsx`) | `genres: { tv, movie, game }`, each `CatalogRef[] \| null`, plus `platforms: CatalogRef[] \| null`; `CatalogRef = { id: number; name: string }` (TMDB `Genre` and IGDB `GameRef` both fit it), the one type both pickers take | +| `typedHref` | one overload for `/search` and the library paths taking `LibraryType`; the `TypedPath` with `MediaType` overload is removed | +| `SearchResult` (`lib/search/read.ts`) | unchanged for TMDB; games use `GameSummary` directly through their own read (below) | +| `QuickResult` (`lib/search/quick.ts`) | `tmdbRating` renamed `rating` | +| `QuickSearchResponse` | `totalResults: number \| null` | +| `CountEndpoint` (`lib/search/count.ts`) | gains `"igdb"`: exact, never capped | +| `GameFilters` (`lib/igdb/types.ts`) | gains `minRatingCount?: number` (moved from `DiscoverGamesOptions`) | +| `SearchGamesOptions` | gains `pageSize?: 20 \| 24` | +| `DiscoverGamesOptions` | gains `requireSortField?: boolean` (default `true`) | + +**Constants** (`lib/search/constants.ts`, pure): `GAME_MIN_RATING_COUNT = 10`; the game year floor is `IGDB_MIN_YEAR` (1950) and the id list cap is the module's 20 (spec 0023, AC-12b), both read from `lib/igdb/constants.ts`, which is pure and may be imported outside the module. The game page size is `IGDB_LIST_PAGE_SIZE` (24) and the page ceiling `IGDB_MAX_PAGE` (50). + +**State transitions**: none stored. The games mode is a pure function of the parsed parameters: + +| `q` | genre, platform, year or rating | Mode | IGDB call | +|---|---|---|---| +| none | none | browse | `readGameListPage(popular, page)` | +| none | any | discover | `discoverGames`, `rating_count_desc`, `requireSortField: false`, page size 24 | +| set | any or none | search | `searchGames`, every filter, page size 24 | + +`searchMode(params)` returns one of these for `type=game` and never `filtered_search`; shows and movies keep their four modes unchanged. + +**API surface** (no mutations, no Server Actions): + +| Endpoint or function | Method | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| `/search?type=game` (page) | GET | `q`, `genre[]`, `platform[]`, `year`, `rating`, `page` (AC-2) | filter bar, mode results, count, paging | public, no session | invalid filter, no such page, `Couldn't reach IGDB` | +| `/api/search?type=game&q=` | GET | `type` (`game` only with the flag on), `q` 2 to 100 | `{ totalResults: number \| null, results[≤5] }` | public, no session | 400 `invalid`, 502 `upstream` with `kind` | +| `parseSearchParams(raw, lists, currentYear, gamesEnabled)` (pure) | fn | raw params; for `game` the genre and platform lists, for `tv`/`movie` the genre list; current UTC year; the flag, read only for a raw `type=game` | `{ ok, params } \| { ok: false, param }` | n/a | first invalid parameter name | +| `searchMode(params)` (pure) | fn | `SearchParams` | `SearchMode` | n/a | none | +| `searchHref(params, overrides)` (pure) | fn | params plus changes | canonical URL, `platform` after `genre` | n/a | none | +| `typedHref("/search", type, { current })` (pure) | fn | `LibraryType`, current query | URL keeping `q`, `year` (if valid for the type), `rating` | n/a | none | +| `readGameSearchPage(mode, params, today)` (`lib/search/game-read.ts`, server only) | fn | parsed games params | `IgdbPaged` (24 games) | n/a | rethrows `IgdbError` | +| `searchGames` (module, amended) | read | adds `minRatingCount`, `pageSize` | `Paged` | server only | `IgdbValidationError` before the cache | +| `discoverGames` (module, amended) | read | adds `requireSortField` | `Paged` | server only | as above | +| `cond.allOf(field, ids)` (`lib/igdb/query.ts`, pure) | fn | field, validated ids | `field = [ids]` condition | n/a | none | + +**Value sourcing**: + +| Action | Value produced / displayed | Source | +|---|---|---| +| `/search` | whether `game` is accepted | `getGamesLibrary() === "on"`, read on the server in the results and filter bar boundaries and the Route Handler, only when the raw `type` is `game` | +| `/search` games | genre options, names, valid ids | cached `getGameGenres()` (`max` lifetime) | +| `/search` games | platform options, names, valid ids | cached `getGamePlatforms()` (`max` lifetime) | +| `/search` games | year options and upper bound | `new Date().getUTCFullYear() + 1`, read at request time inside the streamed boundaries, floor `IGDB_MIN_YEAR` | +| `/search` games | mode | `searchMode(params)` | +| browse | 24 games, exact length, pages | `getPopularGames("popular")` sliced by the shared helper (AC-4); no clock read | +| discover | 24 games, count, `hasNextPage` | `discoverGames({ genreIds, platformIds, releasedFrom, releasedTo, minRating, minRatingCount, sort: "rating_count_desc", requireSortField: false, pageSize: 24, page })`; `releasedFrom`/`releasedTo` are `{year}-01-01`/`{year}-12-31` built from `params.year` | +| search | 24 games, count, `hasNextPage` | `searchGames({ query: q, genreIds, platformIds, year, minRating, minRatingCount, pageSize: 24, page })` | +| discover, search | `minRatingCount` | `GAME_MIN_RATING_COUNT` when `params.rating` is set, else absent | +| discover, search | count line | `formatResultCount(totalResults, "game", "igdb")`, left out when `totalResults` is null | +| discover, search | last page | `min(ceil(totalResults / 24), IGDB_MAX_PAGE)`, or unknown when `totalResults` is null (then `hasNextPage`), through the same rule as `lastListPage` | +| browse | heading line | `Popular {noun} on {CATALOG_SOURCES.game}` | +| card | name, cover, year, rating, link | `GameSummary.name`, `coverUrl`, `releaseYear`, `communityRating`; `gameHref(id)` | +| error and empty copy | source name, noun | `CATALOG_SOURCES[type]`, `mediaNoun(type)` (gains `game`/`games`) | +| navbar search | type and placeholder | `PageMediaType.libraryType` through `WithLibraryTypedPage`: `game` on `/games*` by pathname, and on the typed paths by `type` through `libraryTypeForLocation`; the fallback reads the pathname only (AC-15) | +| tabs on `/search` | each tab's href | `typedHref("/search", type, { current })` | +| logo on `/search?type=game` | target and remembered type | `libraryTypeForLocation` (AC-18) | +| quick search | rows, count | `/api/search` body: `searchGames({ query, page: 1 })` cut to 5; `coverUrl` passed as built (the IGDB loader picks the rung from `sizes`); `releaseYear`; `communityRating` as `rating`; `gameHref(id)` | +| Route Handler | accepted types | `quickSearchRequestSchema`, built with `game` only when `getGamesLibrary() === "on"` | + +**Key invariants**: + +- Every displayed game meets every selected filter, because every filter is in the IGDB query; nothing is filtered locally. +- A games count is IGDB's exact count or absent. It is never estimated, capped or shown for different filters. +- `include_adult` has no IGDB twin; the catalog rule (`CATALOG_RULE`) is in every search and discover query, so a hidden game never shows. +- Games search keeps IGDB relevance order; discover is most rated first with unrated games last; browse is the Popular rank. No other order exists. +- A `page` above 50 never reaches IGDB. +- No search parameter reaches a `use cache` scope except as the module's validated plain options, which are public. +- `MediaType` stays `tv | movie` for the TMDB reads, the title pages and the proxy; only search and the library pages take `LibraryType`. + +**Security model**: everything is public catalog data. The Twitch credentials stay in `lib/igdb/env.ts`, and the page and the Route Handler import only `lib/igdb/index.ts`. Every parameter is validated with Zod and the URL parser before any IGDB call, then again by the module before its cache (spec 0023, AC-12b), and visitor text reaches an IGDB body only through `buildQuery`'s cleaning and escaping (spec 0023, AC-5). The Route Handler still reads no cookie or session, so its responses stay safe for a shared CDN cache, and the existing Vercel Firewall rate limit on `/api/search` covers `type=game` with no change. IGDB receives only search text and filter ids, never user data. + +**Configuration required**: none. `NEXT_PUBLIC_GAMES_LIBRARY` (spec 0027) gates this too; G10 decides when it goes away. + +**Components** (changed unless noted): + +- `components/search/search-results.tsx`: a games branch (`GameResults`) with browse, discover and search, the count, paging and the games states; the TMDB branch is unchanged. +- `components/search/search-filters.tsx`: for `type=game`, reads `getGameGenres` and `getGamePlatforms` (settled to null on `IgdbError`) instead of the TMDB lists. +- `components/search/filter-bar.tsx`: the games controls, the IGDB rating labels and note; `PlatformPicker` (new, beside `GenrePicker`) with its filter box. +- `components/search/quick-search.tsx`, `quick-search-row.tsx`, `mobile-search-overlay.tsx`, `navbar-search.tsx`: the `game` type, the rating rename, the game fallback icon, `Search games`, the null count. +- `components/search/result-count.tsx`: the `igdb` endpoint and a browse line that names its source. +- `components/catalog/list-cards.tsx`: `GameListCard` gains an optional `meta`. +- `components/search/navbar-search.tsx`, `components/layout/page-media-type.tsx`: the search on `WithLibraryTypedPage` and `libraryType`; `type` unchanged. +- `components/layout/media-type-tabs.tsx`: `typedHrefs` builds `/search` hrefs for all three types instead of linking GAMES to `CATALOG_PATHS.game` off the library paths. `brand-link.tsx`: the logo on `/search?type=game`. +- `lib/catalog/media-type.ts`: one `typedHref` overload taking `LibraryType`, the year drop on a switch (importing `IGDB_MIN_YEAR` from the pure `lib/igdb/constants.ts`), `libraryTypeForLocation` for `/search`. +- `lib/catalog-list-read.ts`: the slicing helper and the last page helper over `IgdbPaged`, shared with search. +- `lib/search/`: `params.ts`, `mode.ts`, `count.ts`, `quick.ts`, `constants.ts`, and `game-read.ts` (new, server only). +- `lib/igdb/`: `query.ts`, `summary-page.ts`, `search.ts`, `discover.ts`, `types.ts`, `validation.ts`, fixtures, `igdb.live.ts`. +- `app/api/search/route.ts`: the games read and `IgdbError` mapping. +- `app/showcase/page.tsx`: AC-21's states. + +**Critical test scenarios**: + +- Happy path: with the flag on, `/search?type=game&q=zelda` shows 24 game cards, `{n} games` from IGDB's count and numbered pages; page 2 has no game from page 1, verifies **AC-3**, **AC-6**, **AC-8**, **AC-12**. +- Combined filters: `/search?type=game&q=mario&genre=8&platform=130&platform=508&rating=7` builds one IGDB body with the search text, `genres = [8]`, `platforms = (130,508)`, `rating >= 70` and `rating_count >= 10`; every fixture result passes, verifies **AC-6**, **AC-7**, **AC-19**. +- Discover keeps unrated games: `/search?type=game&platform=508` sends no `rating_count != null`, and the live check proves unrated games come after rated ones and are counted, verifies **AC-5**, **AC-19**. +- Browse: `/search?type=game` shows `Popular games on IGDB` and the same first 24 games as `/games/popular`, from one cache entry, verifies **AC-4**. +- Platform picker: typing `switch` narrows to the Switch platforms, a checked platform hidden by the filter stays in the URL, `zzz` shows `No platforms match`, verifies **AC-10**. +- Invalid input: `?type=game&platform=0`, `?type=game&year=1949`, `?type=movie&platform=6`, 21 genre ids each show `That filter isn't valid` with no IGDB result request; `?type=game&page=51` shows `That page doesn't exist` with no request, verifies **AC-2**, **AC-8**. +- Flag off: `/search?type=game` shows the invalid type panel, `/api/search?type=game&q=zelda` answers 400, no GAMES tab and no search on `/games`, verifies **AC-1**, **AC-15**, **AC-17**. +- Tab switch: `/search?type=movie&q=dune&year=1940&genre=878` and GAMES gives `/search?type=game&q=dune` (year and genre dropped); back to MOVIES keeps `q`, verifies **AC-14**. +- Null count: a fixture with no `X-Count` shows no count line and Next only while `hasNextPage`, in both `/search` and quick search, verifies **AC-8**, **AC-16**. +- Failure case: with IGDB unreachable, `/search?type=game&q=zelda` shows `Couldn't reach IGDB` with the bar usable; a failed platform list disables only `Platforms`; with `platform=` in the URL the results show the error panel, verifies **AC-11**, **AC-13**. +- Type narrowing: no TMDB read accepts `LibraryType` (a type test), `mediaNoun("game")` is `games`, and `resultsKey` keeps two `platform` values, verifies **AC-1**, **AC-2**. +- Edge inputs: `?type=game&q=%01` shows `That filter isn't valid` naming `q`, and `/api/search?type=game&q=%01%01` answers 400, never 500; with an invalid flag value, `/search?type=movie&q=dune` still works, verifies **AC-1**, **AC-2**, **AC-17**. +- Picker cap: after 20 platforms every unchecked box is disabled with `Up to 20`; three quick toggles make one navigation, verifies **AC-9**, **AC-10**. +- Navbar: with the flag on, `/games/top-rated` shows `Search games` and stays prerendered; with the flag off, `/search?type=game` lights SHOWS and the logo goes to `/shows`; `/watchlist?type=game` searches games; the fallback on `/games` reads `Search games`, verifies **AC-15**, **AC-20**. +- Auth/permission: a signed in request to `/api/search?type=game&q=zelda` carries no `Set-Cookie` and the same body as a signed out one; the request scope test for `/search?type=game` finds no session read, verifies **AC-17**, **AC-20**. + +## Build plan + +Tracer Bullet: first one thin real thread, a titled game search from the URL to IGDB and back to a grid, then each strand thickened end to end. + +1. **Thin thread**: `SearchType` widened behind `getGamesLibrary()`, `typedHref("/search", "game")`, `parseSearchParams` for `game` with `q` and `page` only at first, `pageSize` on `searchGames` (types, validation, cache key, tests), `readGameSearchPage` for search mode, the `GameResults` branch with `GameListCard` (and its `meta`), the exact count and numbered paging, proven on `/search?type=game&q=zelda` in the running app, satisfies **AC-1**, **AC-3**, **AC-6**, **AC-8**, **AC-12** +2. **Module strands**: `cond.allOf` and genres as all of, `minRatingCount` in `GameFilters`, `requireSortField` on discover, fixtures for each, and the two new `pnpm igdb:live` checks run against real IGDB, satisfies **AC-7**, **AC-19** +3. **The full URL**: `genre`, `platform`, `year`, `rating` parsing with their caps and the `platform` refusal on TMDB types, `searchHref` with platforms, the discover and browse modes through `readGameSearchPage`, the no such page and above 50 rules, satisfies **AC-2**, **AC-3**, **AC-4**, **AC-5**, **AC-8** +4. **The filter bar**: the games branch of `SearchFilters`, the IGDB genre list and its legend, `PlatformPicker` with its filter box, the year range, the IGDB rating labels and note, hidden inputs, and the independent list failures, satisfies **AC-9**, **AC-10**, **AC-11** +5. **States**: the games empty, invalid and `Couldn't reach IGDB` copy through `CATALOG_SOURCES` and `mediaNoun`, the skeleton footprint, satisfies **AC-13** +6. **Navigation**: the navbar search on `WithLibraryTypedPage`, `Search games` and its fallback, the single `typedHref` overload and `typedHrefs` in the tabs, GAMES in place on `/search` with the year drop, the logo on `/search?type=game`, with the navbar, tabs and brand link tests, satisfies **AC-14**, **AC-15**, **AC-18** +7. **Quick search**: the Route Handler's games branch and flag check, the `rating` rename and null count across the TMDB and games rows, the game row icon and label, the mobile overlay, satisfies **AC-16**, **AC-17** +8. **Proof**: `/showcase` states, request scope and layout purity tests for `type=game`, `pnpm build` reporting `/search` partially prerendered, 375px and 1280px, a keyboard pass, then typecheck, lint, test and build, satisfies **AC-20**, **AC-21** + +## Consequences + +**Positive**: + +- Every game shown and every count is IGDB's own answer to the full filter set, which is the AGENTS.md section 10 rule held with less code than TMDB needs. +- One `/search` serves three catalogs with one URL grammar, one filter bar and one quick search, so share, reload and Back work the same for games. +- Browse shares `/games/popular`'s cache entry, so it costs no extra IGDB request. +- The games tabs and pages finally have a search, removing the gap spec 0027 accepted. + +**Negative / tradeoffs**: + +- Genres mean all of while platforms mean any of; the legends (`MUST HAVE ALL OF`, `ON ANY OF`) carry that, but it is two rules in one bar. +- A 220 entry platform list is long even with the filter box, and it includes long retired platforms nobody owns. +- IGDB pages stop at 50, so a broad search or filter reaches at most 1,200 games by paging, even when the count is higher. +- Discover sorts by rating count, a stand in for popularity; unrated games sit after rated ones in an order IGDB does not document, and it was stable only in a live test. +- The 10 rating floor is far lower than the 100 TMDB vote floor, so a `9+` game list can include games rated by a small group. +- `SearchType` widening touches many search files at once, and the TMDB quick search rows change shape with the `rating` rename. + +**Neutral**: + +- `/search`'s description and share card still name only movies and shows until G10 adds games to SEO. +- Game cards on `/search` are plain until G8 adds the status chip to every grid at once. +- Four IGDB module options are added; G6's callers pass none and behave as before. + +## Follow-up + +- [x] Amend spec 0010 (AC-7, AC-18, AC-20), spec 0023 (AC-12: genres all of, `minRatingCount` and `pageSize` on search; AC-13: `requireSortField`), spec 0027 (AC-2, AC-4: game search on `/search` and on the games tabs) and spec 0028 (AC-14, AC-15: GAMES in place on `/search`, the search on `/games*`) with a one line pointer to this spec. +- [ ] Through `/sync`, update `AGENTS.md`: `getGamesLibrary()` is now also read by `/search` and `/api/search`; `game` reaches `/search`; and `lib/igdb/AGENTS.md`: the genre all of rule, the two new options. +- [ ] G8: give game cards on `/search` the status chip and control with the other grids. +- [ ] G10: add games to `/search`'s description and share copy when games reach search engines. +- [ ] Before G10, decide whether the Vercel Firewall rule (today only `/api/search`) should also cover `/search` page renders, since each new filter combination on `type=game` is an uncached IGDB read against a 4 a second limiter shared with the game pages. +- [ ] Rerun `pnpm igdb:live` before launch; the unrated games order and the count header are IGDB behaviours the docs do not promise. diff --git a/docs/specs/0029-game-search-filters/rationale.md b/docs/specs/0029-game-search-filters/rationale.md new file mode 100644 index 00000000..5907e2d1 --- /dev/null +++ b/docs/specs/0029-game-search-filters/rationale.md @@ -0,0 +1,68 @@ +# 0029. Game search and filters: rationale + +## Context + +Games have a landing, five list pages, a game page and three library tabs, but no way to find one game by name. Spec 0027 hid the navbar search on the games tabs and spec 0028 hid it on `/games*`, both "until G7", so today a person who wants a particular game has to scroll lists. Games scope feature G7 asks for `/search?type=game` with a title query and filters for genre, release year, minimum IGDB rating and platform, with every result meeting every filter, shareable URLs, truthful counts and recoverable failures. + +The forces are mostly set by the two catalogs. `/search` (spec 0010) was built around a TMDB limit: TMDB's search endpoint ignores genre and rating, so a query with those filters is scanned and filtered on the server over whole pages, with a partial count and a cursor instead of page numbers. IGDB is different. Its `search` clause takes a `where` beside it (confirmed live on 2026-10-17 and again today), and it returns an exact `X-Count` for both search and plain filtered reads. The IGDB module (spec 0023) already exposes `searchGames` and `discoverGames` with genre, platform, year and rating filters, but it fixed three details that do not fit a search page: genres match any of the ids, search has no rating count floor and a fixed page size of 20, and a discover sort silently drops every game without the sort field. + +AGENTS.md section 10 governs the result: every displayed result meets the filters, counts are never an unfiltered total, upstream relevance order is kept, filters survive opening a title and coming back, and there is a retry path. The games rules add that IGDB's community rating is always labelled as such, hidden games never show in search, and `game` widens `/search` only on purpose (spec 0027, AC-2). The games library flag `NEXT_PUBLIC_GAMES_LIBRARY` keeps every games surface off in production until launch. + +Not deciding leaves the games tabs with a missing search, which spec 0027 accepted only as temporary, and leaves G8's "cards everywhere" without a search grid to cover. + +## Options considered + +### Option 1: A games branch inside `/search`, every filter sent to IGDB + +`/search?type=game` keeps the page, the filter bar, the state panels and the quick search of spec 0010. The URL parser, the mode function and the result reader gain a games branch with three modes (browse, discover, search), each a single IGDB read with every filter in the query. The IGDB module gains four small options so the reads match the page: genres as all of, a rating count floor on search, a page size of 24 on search, and a discover sort that keeps unrated games. + +**Pros**: +- One URL grammar, one filter bar and one quick search for all three catalogs; the navbar tabs switch `type` in place exactly as they do between shows and movies. +- No scan, no cursor and no partial count for games: IGDB's own filtering and count make every result and number true. +- Reuses every tested state, the keyed Suspense skeleton, the GET form and the CDN cached Route Handler. + +**Cons**: +- `SearchType` widening touches many files, and the parser must refuse a `platform` on a TMDB type. +- The filter bar grows type specific controls (platforms only for games, different labels and floors). + +### Option 2: A separate `/games/search` page + +A games only search page under `/games`, with its own parser, filter bar and quick search endpoint, leaving `/search` as the TMDB page. + +**Pros**: +- No change to the TMDB search code or its types. +- The games page can diverge freely (different controls, layout). + +**Cons**: +- Two search pages, two filter bars and two quick search endpoints to keep consistent, which AGENTS.md's "reuse existing components and patterns first" argues against. +- The navbar tabs could no longer switch the search catalog in place; a tab switch would jump between routes and lose the query. +- A second public endpoint spending an API credential needs its own firewall rule. + +### Option 3: Reuse spec 0010's filtered search scan for games + +Treat IGDB like TMDB: send the title upstream, filter genre, platform and rating locally over whole pages, with a partial count. + +**Pros**: +- One code path for every catalog's filtered search. + +**Cons**: +- Throws away IGDB's ability to filter inside the search, trading exact counts and page numbers for partial counts and a cursor. +- Up to five IGDB requests per page under a limiter of four requests a second, where one is enough. + +## Rationale + +Option 1 wins because IGDB removes the very problem spec 0010's complexity exists for. With every filter in one query and an exact count, the games branch is the simplest of the three: one read per page, numbered pages, a real total. Keeping it inside `/search` holds the pattern people already use for shows and movies, keeps the tab switch in place (the engineer chose to keep `q`, `year` and `rating` across it), and lets the existing firewall rule and CDN cache cover games quick search with no new surface. Option 2 duplicates a large, carefully tested page for no capability gain, and Option 3 would make game search worse than IGDB allows. + +The engineer settled the behaviour in the design conversation. Quick search ships with the page, so the games tabs and `/games*` get the navbar search back. Several genres mean all of them, matching shows and movies (spec 0010, AC-10); several platforms mean any of them, because the question a person asks is "can I play this on something I own". The platform picker lists all of IGDB's platforms with a filter box rather than a hand picked short list, so no platform is unreachable and the list never needs upkeep. Browse shows the Popular list `/games` already uses, so a bare `/search?type=game` matches what the games landing calls popular and costs no extra request. Filters without a title sort by rating count, the closest stand in for popularity a filtered IGDB query can sort by, and keep unrated games after rated ones (the live check below showed a strict sort would hide 481 of Nintendo Switch 2's 770 games and make the count untrue). A minimum rating brings a floor of 10 IGDB ratings, because IGDB's counts are far smaller than TMDB's: at 8 and above, a 100 floor keeps 816 games and a 10 floor keeps 2,274, while no floor lets games rated by two people top a `9+` filter. The year select runs from 1950 (the module's floor) to next year, matching shows and movies. Result cards stay plain until G8 gives every grid its status chip at once. + +The small calls made while writing: the server reads the flag through `getGamesLibrary()`, the same reader `LibrarySection` uses, so an invalid value fails loudly rather than quietly hiding games (the runner up, `gamesLibraryEnabled()`, reads an invalid value as off, which suits only the navbar). The quick search response renames `tmdbRating` to `rating` for every type instead of adding a parallel games field, because the row already knows its type and therefore its label. A tab switch drops a kept year the new type cannot take, so switching never lands on an invalid panel; the runner up, keeping it and showing the panel, punishes a click on a tab. Counts get an `igdb` endpoint with no cap wording, because no IGDB cap has been observed; a null count simply hides the line, as the games list pages already do. + +## Evidence: live IGDB checks on 2026-10-25 + +Run against real IGDB with the project's Twitch credentials, through `pnpm igdb:live` (17 passed, 1 skipped) and direct read only count queries with the catalog rule applied. + +- `X-Count` is present on search (`zelda` on Switch: 8) and on a plain `where` (30,883 rated visible games). +- 23 genres and 220 platforms. +- Genres: `genres = [5,12]` (all of) counts 2,229 games; `genres = (5,12)` (any of) counts 77,969. +- Rating floors: rating at least 7 counts 16,364 games with no floor, 6,507 with at least 10 ratings, 1,615 with at least 100; at least 8 counts 5,397, 2,274 and 816. +- Nintendo Switch 2 (platform 508) has 770 visible games, 289 with any rating. Sorting by `rating_count desc` without requiring the field returns the rated games first and the unrated ones after them (offsets 289 to 769 are all unrated). Reading offsets 289 to 769 as one request and as two requests returned the same ids in the same order, each once. From 89e5ec79ac4dc4d645dedec2213c9664df3c8ca2 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 19:15:28 +0100 Subject: [PATCH 02/22] feat(igdb): match genres as all of and add the game search options Genres now write IGDB's [] list, minRatingCount is a shared filter so search takes it too, search takes pageSize 24, and discover can leave out the sort field condition so unrated games are counted. Co-Authored-By: Claude Opus 5.5 --- lib/igdb/discover.ts | 11 +++---- lib/igdb/igdb.live.ts | 66 +++++++++++++++++++++++++++++++++---- lib/igdb/query.test.ts | 3 ++ lib/igdb/query.ts | 7 ++-- lib/igdb/reads.test.ts | 1 + lib/igdb/search.test.ts | 30 +++++++++++++++-- lib/igdb/search.ts | 3 +- lib/igdb/summary-page.ts | 13 +++++--- lib/igdb/types.ts | 13 ++++++-- lib/igdb/validation.test.ts | 35 ++++++++++++++++++-- lib/igdb/validation.ts | 35 ++++++++++++++++---- 11 files changed, 183 insertions(+), 34 deletions(-) diff --git a/lib/igdb/discover.ts b/lib/igdb/discover.ts index dc9efba6..b8f8f72c 100644 --- a/lib/igdb/discover.ts +++ b/lib/igdb/discover.ts @@ -8,8 +8,10 @@ import type { ValidDiscoverOptions } from "./validation"; * Filtered browsing with an explicit order, which G6's rows and G7's filters * only browse need (spec 0023, AC-13). * - * A sort also requires its field, so an undated or unrated game never sorts to - * the top. The date window is UTC days, both ends inclusive, and the caller + * A sort also requires its field by default, so an undated or unrated game + * never sorts to the top. Game search's discover mode turns that off + * (`requireSortField: false`, spec 0029, AC-5), so its count includes the + * games with no value, which IGDB puts after the rest. The date window is UTC days, both ends inclusive, and the caller * passes them: this module never reads the clock for a filter. IGDB takes one * sort key, so ties can repeat or skip an item across pages; that is accepted. */ @@ -46,13 +48,10 @@ export async function fetchDiscoverGames( cond.below("first_release_date", unixDay(valid.releasedTo) + DAY_SECONDS), ); } - if (valid.minRatingCount !== undefined) { - where.push(cond.atLeast("rating_count", valid.minRatingCount)); - } if (valid.minHypes !== undefined) { where.push(cond.atLeast("hypes", valid.minHypes)); } - where.push(cond.notNull(sort.field)); + if (valid.requireSortField) where.push(cond.notNull(sort.field)); return readSummaryPage({ where, diff --git a/lib/igdb/igdb.live.ts b/lib/igdb/igdb.live.ts index 14d7aa40..14c6835e 100644 --- a/lib/igdb/igdb.live.ts +++ b/lib/igdb/igdb.live.ts @@ -260,24 +260,25 @@ describe.skipIf(!hasCredentials)("IGDB live check (real network)", () => { expect((await fetchGame(record.id)).hidden).toBe(false); }); - it("matches any of a genre list", async () => { - const [shooter, puzzle] = [5, 9]; + it("matches any of a platform list", async () => { + const [switch1, switch2] = [130, 508]; const page = await fetchDiscoverGames({ - genreIds: [shooter, puzzle], + platformIds: [switch1, switch2], sort: "rating_count_desc", }); expect(page.results.length).toBeGreaterThan(0); for (const game of page.results) { expect( - game.genreIds.includes(shooter) || game.genreIds.includes(puzzle), + game.platformIds.includes(switch1) || + game.platformIds.includes(switch2), ).toBe(true); } - const onlyShooter = await fetchDiscoverGames({ - genreIds: [shooter], + const onlySwitch2 = await fetchDiscoverGames({ + platformIds: [switch2], sort: "rating_count_desc", }); expect(page.totalResults ?? 0).toBeGreaterThan( - onlyShooter.totalResults ?? 0, + onlySwitch2.totalResults ?? 0, ); }); @@ -398,6 +399,57 @@ describe.skipIf(!hasCredentials)("IGDB live check (real network)", () => { expect(page.results.length).toBeGreaterThanOrEqual(12); }); + // spec 0029, AC-7, AC-19: a `[]` genre list means all of its ids. + it("matches every genre of a two genre filter", async () => { + const page = await fetchDiscoverGames({ + sort: "rating_count_desc", + genreIds: [5, 31], + pageSize: 24, + }); + expect(page.results.length).toBeGreaterThan(0); + for (const game of page.results) { + expect(game.genreIds, game.name).toEqual(expect.arrayContaining([5, 31])); + } + const onlyShooter = await fetchDiscoverGames({ + sort: "rating_count_desc", + genreIds: [5], + }); + expect(page.totalResults ?? 0).toBeLessThan(onlyShooter.totalResults ?? 0); + }); + + // spec 0029, AC-5, AC-19: with the sort field not required, the unrated + // games follow every rated one and are counted. + it("puts unrated games after rated ones and counts them", async () => { + const options = { + sort: "rating_count_desc", + platformIds: [508], + pageSize: 24, + } as const; + const rated = await fetchDiscoverGames(options); + const all = await fetchDiscoverGames({ + ...options, + requireSortField: false, + }); + const ratedCount = rated.totalResults ?? 0; + console.info( + `Switch 2: ${ratedCount} rated of ${all.totalResults} games in total`, + ); + expect(all.totalResults ?? 0).toBeGreaterThan(ratedCount); + expect(ratedCount).toBeLessThan(24 * 50); + + // The page holding the last rated game and the first unrated one. + const page = Math.floor(ratedCount / 24) + 1; + const boundary = await fetchDiscoverGames({ + ...options, + requireSortField: false, + page, + }); + boundary.results.forEach((game, index) => { + const isRated = (page - 1) * 24 + index < ratedCount; + expect(game.communityRatingCount !== null, game.name).toBe(isRated); + }); + }); + it("reads the genre and platform lists", async () => { const genres = await fetchGameGenres(); const platforms = await fetchGamePlatforms(); diff --git a/lib/igdb/query.test.ts b/lib/igdb/query.test.ts index 8745914a..12122ac3 100644 --- a/lib/igdb/query.test.ts +++ b/lib/igdb/query.test.ts @@ -93,6 +93,7 @@ describe("cond", () => { it("writes each condition from a field and checked numbers only", () => { expect(cond.is("id", 1942)).toBe("id = 1942"); expect(cond.anyOf("genres", [5, 9])).toBe("genres = (5,9)"); + expect(cond.allOf("genres", [5, 9])).toBe("genres = [5,9]"); expect(cond.noneOf("themes", [42])).toBe("themes != (42)"); expect(cond.isNull("game_type")).toBe("game_type = null"); expect(cond.notNull("rating")).toBe("rating != null"); @@ -116,6 +117,8 @@ describe("cond", () => { it("refuses a value that is not a checked number", () => { expect(() => cond.is("id", 1.5)).toThrow(IgdbValidationError); expect(() => cond.anyOf("id", [])).toThrow(IgdbValidationError); + expect(() => cond.allOf("genres", [])).toThrow(IgdbValidationError); + expect(() => cond.allOf("genres", [1.5])).toThrow(IgdbValidationError); expect(() => cond.atLeast("rating", Number.NaN)).toThrow( IgdbValidationError, ); diff --git a/lib/igdb/query.ts b/lib/igdb/query.ts index 4ab17c24..9a24e1b5 100644 --- a/lib/igdb/query.ts +++ b/lib/igdb/query.ts @@ -118,14 +118,17 @@ function asCondition(text: string): Condition { * The only way to write a `where` condition (AC-5). Every value goes through * `int`, `intList` or `finite`, and every field is an `IgdbField`, so a * condition holds nothing a caller typed. IGDB's meaning: `= (…)` matches any - * of the ids, `!= (…)` none of them, and `= null` a missing value or an empty - * list. + * of the ids, `= […]` all of them, `!= (…)` none of them, and `= null` a + * missing value or an empty list. */ export const cond = { is: (field: IgdbField, value: number) => asCondition(`${field} = ${int(value)}`), anyOf: (field: IgdbField, ids: readonly number[]) => asCondition(`${field} = ${intList(ids)}`), + /** Every one of the ids, IGDB's `[…]` list (spec 0029, AC-7). */ + allOf: (field: IgdbField, ids: readonly number[]) => + asCondition(`${field} = [${intList(ids).slice(1, -1)}]`), noneOf: (field: IgdbField, ids: readonly number[]) => asCondition(`${field} != ${intList(ids)}`), isNull: (field: IgdbField) => asCondition(`${field} = null`), diff --git a/lib/igdb/reads.test.ts b/lib/igdb/reads.test.ts index ba22dbdd..88678c3f 100644 --- a/lib/igdb/reads.test.ts +++ b/lib/igdb/reads.test.ts @@ -173,6 +173,7 @@ describe("validation, outside any cached scope", () => { query: "zelda", genreIds: [5, 9], page: 1, + pageSize: 20, }); }); }); diff --git a/lib/igdb/search.test.ts b/lib/igdb/search.test.ts index 783f77b1..1d5c7355 100644 --- a/lib/igdb/search.test.ts +++ b/lib/igdb/search.test.ts @@ -72,6 +72,7 @@ describe("fetchSearchGames", () => { platformIds: [130], year: 2017, minRating: 7.5, + minRatingCount: 10, page: 3, }), ); @@ -79,10 +80,20 @@ describe("fetchSearchGames", () => { const from = Date.UTC(2017, 0, 1) / 1000; const to = Date.UTC(2018, 0, 1) / 1000; expect(mock.body()).toContain( - `where ${FILTER} & (genres = (12,31)) & (platforms = (130)) & (rating >= 75) & (first_release_date >= ${from} & first_release_date < ${to}); limit 21; offset 40;`, + `where ${FILTER} & (genres = [12,31]) & (platforms = (130)) & (rating >= 75) & (rating_count >= 10) & (first_release_date >= ${from} & first_release_date < ${to}); limit 21; offset 40;`, ); }); + it("asks for 25 and keeps 24 at a page size of 24 (spec 0029, AC-19)", async () => { + const mock = installIgdbMock({ igdb: [{ body: rows(25) }] }); + const { value } = await settle( + fetchSearchGames({ query: "game", page: 2, pageSize: 24 }), + ); + expect(mock.body()).toContain("limit 25; offset 24;"); + expect(value?.results).toHaveLength(24); + expect(value?.hasNextPage).toBe(true); + }); + it("asks for 21, returns 20, and knows there is a next page", async () => { installIgdbMock({ igdb: [{ body: rows(21) }] }); const { value } = await settle(fetchSearchGames({ query: "game" })); @@ -157,7 +168,22 @@ describe("fetchDiscoverGames", () => { const from = Date.UTC(2026, 7, 18) / 1000; const toExclusive = Date.UTC(2026, 9, 18) / 1000; expect(mock.body()).toContain( - `(first_release_date >= ${from}) & (first_release_date < ${toExclusive}) & (rating_count >= 10) & (hypes >= 5)`, + `(rating_count >= 10) & (first_release_date >= ${from}) & (first_release_date < ${toExclusive}) & (hypes >= 5)`, + ); + }); + + it("leaves out the sort field condition when it is not required (spec 0029, AC-5)", async () => { + const mock = installIgdbMock({ igdb: [{ body: [] }] }); + await settle( + fetchDiscoverGames({ + sort: "rating_count_desc", + requireSortField: false, + genreIds: [8, 5], + }), + ); + expect(mock.body()).not.toContain("rating_count != null"); + expect(mock.body()).toContain( + `where ${FILTER} & (genres = [5,8]); sort rating_count desc; limit 21;`, ); }); diff --git a/lib/igdb/search.ts b/lib/igdb/search.ts index 0891de31..99afa9d5 100644 --- a/lib/igdb/search.ts +++ b/lib/igdb/search.ts @@ -1,5 +1,4 @@ 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"; @@ -40,6 +39,6 @@ export async function fetchSearchGames( search: valid.query, where, page: valid.page, - pageSize: IGDB_PAGE_SIZE, + pageSize: valid.pageSize, }); } diff --git a/lib/igdb/summary-page.ts b/lib/igdb/summary-page.ts index 86a3fb6f..23619149 100644 --- a/lib/igdb/summary-page.ts +++ b/lib/igdb/summary-page.ts @@ -20,14 +20,16 @@ export function unixDay(day: string): number { } /** - * The filters search and discover share. Within one id list a game matches on - * any id; separate filters combine with AND. `minRating` is on the 0 to 10 - * scale against IGDB's 0 to 100 `rating`, and a minimum of 0 is no minimum. + * The filters search and discover share. A game must have every genre and be + * on any of the platforms (spec 0029, AC-7); separate filters combine with + * AND. `minRating` is on the 0 to 10 scale against IGDB's 0 to 100 `rating`, + * and a minimum of 0 is no minimum. `minRatingCount` is written here once, so + * search and discover cannot drift apart on it (spec 0029, AC-19). */ export function sharedConditions(options: GameFilters): Condition[] { const where = [catalogWhereClause()]; if (options.genreIds?.length) { - where.push(cond.anyOf("genres", options.genreIds)); + where.push(cond.allOf("genres", options.genreIds)); } if (options.platformIds?.length) { where.push(cond.anyOf("platforms", options.platformIds)); @@ -38,6 +40,9 @@ export function sharedConditions(options: GameFilters): Condition[] { cond.atLeast("rating", Number((options.minRating * 10).toFixed(6))), ); } + if (options.minRatingCount !== undefined) { + where.push(cond.atLeast("rating_count", options.minRatingCount)); + } return where; } diff --git a/lib/igdb/types.ts b/lib/igdb/types.ts index 9ad65246..be57ef1e 100644 --- a/lib/igdb/types.ts +++ b/lib/igdb/types.ts @@ -87,6 +87,8 @@ export type GameFilters = { platformIds?: readonly number[]; /** On the 0 to 10 scale. */ minRating?: number; + /** At least this many IGDB ratings (spec 0029, AC-19). */ + minRatingCount?: number; }; export type SearchGamesOptions = GameFilters & { @@ -94,6 +96,8 @@ export type SearchGamesOptions = GameFilters & { /** The UTC year of the first release. */ year?: number; page?: number; + /** Results per page, 20 (the default) or 24 for `/search` (spec 0029). */ + pageSize?: DiscoverPageSize; }; export type DiscoverSort = @@ -108,7 +112,6 @@ export type DiscoverGamesOptions = GameFilters & { releasedFrom?: string; /** A UTC day, `YYYY-MM-DD`, inclusive. */ releasedTo?: string; - minRatingCount?: number; minHypes?: number; sort: DiscoverSort; page?: number; @@ -117,7 +120,13 @@ export type DiscoverGamesOptions = GameFilters & { * page's grid; the default 20 keeps G7 and the search read unchanged. */ pageSize?: DiscoverPageSize; + /** + * Whether a game needs a value in the sort field, true by default. False + * keeps the games with none, after the rest, so the count includes them + * (spec 0029, AC-5). + */ + requireSortField?: boolean; }; -/** The page sizes discover serves (spec 0028, AC-5). */ +/** The page sizes search and discover serve (spec 0028, AC-5; spec 0029). */ export type DiscoverPageSize = (typeof IGDB_DISCOVER_PAGE_SIZES)[number]; diff --git a/lib/igdb/validation.test.ts b/lib/igdb/validation.test.ts index 8c010b6e..c7161d37 100644 --- a/lib/igdb/validation.test.ts +++ b/lib/igdb/validation.test.ts @@ -72,13 +72,23 @@ describe("canonicalGameIds (AC-11)", () => { }); describe("validateSearchOptions (AC-12b)", () => { - it("drops absent options and defaults the page to 1", () => { + it("drops absent options and defaults the page to 1 and the size to 20", () => { expect(validateSearchOptions({ query: " witcher " })).toEqual({ query: "witcher", page: 1, + pageSize: 20, }); }); + it("keeps a page size of 24 and the rating count floor (spec 0029, AC-19)", () => { + expect( + validateSearchOptions({ query: "a", pageSize: 24, minRatingCount: 10 }), + ).toMatchObject({ pageSize: 24, minRatingCount: 10 }); + expect(validateSearchOptions({ query: "a", pageSize: 20 })).toEqual( + validateSearchOptions({ query: "a" }), + ); + }); + it("returns canonical filter ids and drops an empty list", () => { expect( validateSearchOptions({ @@ -86,7 +96,7 @@ describe("validateSearchOptions (AC-12b)", () => { genreIds: [31, 12, 31], platformIds: [], }), - ).toEqual({ query: "a", genreIds: [12, 31], page: 1 }); + ).toEqual({ query: "a", genreIds: [12, 31], page: 1, pageSize: 20 }); }); it.each([ @@ -125,6 +135,12 @@ describe("validateSearchOptions (AC-12b)", () => { { query: "a", platformIds: Array.from({ length: 21 }, (_, i) => i + 1) }, "platformIds", ], + ["a page size of 30", { query: "a", pageSize: 30 }, "pageSize"], + [ + "a negative rating count", + { query: "a", minRatingCount: -1 }, + "minRatingCount", + ], ])("refuses %s, naming the field", (_, options, field) => { expect( fieldOf(() => @@ -148,9 +164,19 @@ describe("validateDiscoverOptions (AC-12b, AC-13)", () => { sort: "hypes_desc", page: 1, pageSize: 20, + requireSortField: true, }); }); + it("keeps requireSortField false and fills an absent one with true (spec 0029, AC-19)", () => { + expect( + validateDiscoverOptions({ sort: "hypes_desc", requireSortField: false }), + ).toMatchObject({ requireSortField: false }); + expect( + validateDiscoverOptions({ sort: "hypes_desc", requireSortField: true }), + ).toEqual(validateDiscoverOptions({ sort: "hypes_desc" })); + }); + 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 }), @@ -211,6 +237,11 @@ describe("validateDiscoverOptions (AC-12b, AC-13)", () => { ], ["page 51", { sort: "rating_desc", page: 51 }, "page"], ["a page size of 30", { sort: "rating_desc", pageSize: 30 }, "pageSize"], + [ + "a text requireSortField", + { sort: "rating_desc", requireSortField: "false" }, + "requireSortField", + ], [ "a minimum rating of 11", { sort: "rating_desc", minRating: 11 }, diff --git a/lib/igdb/validation.ts b/lib/igdb/validation.ts index ed22e63a..5148114f 100644 --- a/lib/igdb/validation.ts +++ b/lib/igdb/validation.ts @@ -150,6 +150,18 @@ function pageSize(value: unknown): DiscoverPageSize { return value as DiscoverPageSize; } +/** A boolean, filled with true when absent so both spellings share one entry. */ +function requireSortField(value: unknown): boolean { + if (value === undefined) return true; + if (typeof value !== "boolean") { + throw new IgdbValidationError( + "requireSortField", + "requireSortField must be a boolean.", + ); + } + return value; +} + function minRating(value: unknown): number | undefined { if (value === undefined) return undefined; if ( @@ -214,6 +226,7 @@ function filters(options: GameFilters): GameFilters { genreIds: filterIds("genreIds", options.genreIds), platformIds: filterIds("platformIds", options.platformIds), minRating: minRating(options.minRating), + minRatingCount: nonNegative("minRatingCount", options.minRatingCount), }; } @@ -232,12 +245,14 @@ function withoutUndefined(value: T): Valid { } /** The validated search options, the query cleaned but not yet escaped. */ -export type ValidSearchOptions = Valid; +export type ValidSearchOptions = Valid< + SearchGamesOptions & { page: number; pageSize: DiscoverPageSize } +>; /** * Checks and canonicalizes search options: the query cleaned (not escaped), - * ids deduped and sorted, absent options dropped, `page` filled with 1. Equal - * searches then share one cache entry. + * ids deduped and sorted, absent options dropped, `page` filled with 1 and + * `pageSize` with 20. Equal searches then share one cache entry. * * @throws {IgdbValidationError} Naming the first bad field. */ @@ -249,18 +264,24 @@ export function validateSearchOptions( ...filters(options), year: year(options.year), page: page(options.page), + pageSize: pageSize(options.pageSize), }); } /** The validated discover options, canonical like the search options. */ export type ValidDiscoverOptions = Valid< - DiscoverGamesOptions & { page: number; pageSize: DiscoverPageSize } + DiscoverGamesOptions & { + page: number; + pageSize: DiscoverPageSize; + requireSortField: boolean; + } >; /** * Checks and canonicalizes discover options, as `validateSearchOptions` does, - * 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. + * plus a known `sort`, a date window whose start is not after its end, a + * page size of 20 or 24, filled with 20, and `requireSortField`, filled with + * true. * * @throws {IgdbValidationError} Naming the first bad field. */ @@ -285,10 +306,10 @@ export function validateDiscoverOptions( ...filters(options), releasedFrom, releasedTo, - minRatingCount: nonNegative("minRatingCount", options.minRatingCount), minHypes: nonNegative("minHypes", options.minHypes), sort: options.sort, page: page(options.page), pageSize: pageSize(options.pageSize), + requireSortField: requireSortField(options.requireSortField), }); } From 551a6db200f950bf81a459a7b0e3014c26ddd27a Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 19:16:02 +0100 Subject: [PATCH 03/22] refactor(catalog): share the games page slice and last page with search Co-Authored-By: Claude Opus 5.5 --- lib/catalog-list-read.test.ts | 19 ++++++++++ lib/catalog-list-read.ts | 65 ++++++++++++++++++++++++++--------- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/lib/catalog-list-read.test.ts b/lib/catalog-list-read.test.ts index 9b48d5f4..f5448059 100644 --- a/lib/catalog-list-read.test.ts +++ b/lib/catalog-list-read.test.ts @@ -31,6 +31,7 @@ const { readGameListPage, readTaggedCatalogGridPage, readTaggedCatalogList, + sliceGamePage, } = await import("./catalog-list-read"); afterEach(() => { @@ -227,3 +228,21 @@ describe("listHasNextPage (spec 0028, AC-10)", () => { ).toBe(false); }); }); + +describe("sliceGamePage (spec 0029, AC-4)", () => { + const games = Array.from({ length: 50 }, (_, index) => ({ + id: index + 1, + })) as unknown as Parameters[0]; + + it("cuts 24 game pages with the whole list's exact count", () => { + expect(sliceGamePage(games, 1)).toMatchObject({ + page: 1, + hasNextPage: true, + totalResults: 50, + }); + expect(sliceGamePage(games, 2).results[0]).toEqual({ id: 25 }); + expect(sliceGamePage(games, 3).results).toHaveLength(2); + expect(sliceGamePage(games, 3).hasNextPage).toBe(false); + expect(sliceGamePage(games, 4).results).toEqual([]); + }); +}); diff --git a/lib/catalog-list-read.ts b/lib/catalog-list-read.ts index a1fd10f6..feb07bd1 100644 --- a/lib/catalog-list-read.ts +++ b/lib/catalog-list-read.ts @@ -121,14 +121,10 @@ export async function readGameListPage( today: string, ): Promise> { if (isPopularGameList(list.slug)) { - const games = await getPopularGames(POPULAR_GAME_LISTS[list.slug]); - const start = (page - 1) * IGDB_LIST_PAGE_SIZE; - return { + return sliceGamePage( + await getPopularGames(POPULAR_GAME_LISTS[list.slug]), page, - results: games.slice(start, start + IGDB_LIST_PAGE_SIZE), - hasNextPage: start + IGDB_LIST_PAGE_SIZE < games.length, - totalResults: games.length, - }; + ); } if (page > IGDB_MAX_PAGE) { return { page, results: [], hasNextPage: false, totalResults: null }; @@ -136,6 +132,50 @@ export async function readGameListPage( return discoverGames({ ...gameListOptions(list.slug, today), page }); } +/** + * One 24 game page cut from a whole list, so its count is the list's exact + * length. Shared by the popularity list pages and `/search`'s games browse + * mode (spec 0029, AC-4), which read the same cached list. + */ +export function sliceGamePage( + games: readonly GameSummary[], + page: number, +): IgdbPaged { + const start = (page - 1) * IGDB_LIST_PAGE_SIZE; + return { + page, + results: games.slice(start, start + IGDB_LIST_PAGE_SIZE), + hasNextPage: start + IGDB_LIST_PAGE_SIZE < games.length, + totalResults: games.length, + }; +} + +/** + * The last 24 game page IGDB serves, or null when it sent no count (spec + * 0028, AC-10; spec 0029, AC-8): the count's pages, capped at IGDB's 50. + * Shared by the games list pages and `/search`. + */ +export function lastGamePage(paged: IgdbPaged): number | null { + if (paged.totalResults === null) return null; + return Math.min( + IGDB_MAX_PAGE, + Math.ceil(paged.totalResults / IGDB_LIST_PAGE_SIZE), + ); +} + +/** + * Whether a pager with no known last page offers Next: only while IGDB says + * there is more, and never past its page 50 (spec 0028, AC-10). + */ +export function gameHasNextPage( + paged: IgdbPaged, + page: number, +): boolean { + return ( + lastGamePage(paged) === null && paged.hasNextPage && page < IGDB_MAX_PAGE + ); +} + /** * `readGameListPage` at request time: `connection()` first, so `pnpm build` * asks IGDB nothing, then today's UTC day (spec 0028, AC-18), read once per @@ -161,9 +201,7 @@ async function readTaggedGamePage( */ export function lastListPage(result: TaggedCatalogPage): number | null { if (result.mediaType !== "game") return result.paged.totalPages; - const { totalResults } = result.paged; - if (totalResults === null) return null; - return Math.min(IGDB_MAX_PAGE, Math.ceil(totalResults / IGDB_LIST_PAGE_SIZE)); + return lastGamePage(result.paged); } /** @@ -176,12 +214,7 @@ export function listHasNextPage( result: TaggedCatalogPage, page: number, ): boolean { - return ( - lastListPage(result) === null && - result.mediaType === "game" && - result.paged.hasNextPage && - page < IGDB_MAX_PAGE - ); + return result.mediaType === "game" && gameHasNextPage(result.paged, page); } /** From 99d87343bc8e8262f3ead0554c916e7a79b6d38a Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 25 Oct 2026 19:22:56 +0100 Subject: [PATCH 04/22] feat(search): search games on /search with genre, platform, year and rating filters type=game is accepted while the games library is on. Every filter is sent to IGDB, so each game shown meets them and the count is IGDB's own; the bar adds a platform picker with a filter box and labels the rating as IGDB's. Co-Authored-By: Claude Opus 5.5 --- components/catalog/list-cards.tsx | 5 + components/search/filter-bar.test.tsx | 145 +++++++++++- components/search/filter-bar.tsx | 308 +++++++++++++++++++++----- components/search/result-count.tsx | 19 +- components/search/search-filters.tsx | 67 ++++-- components/search/search-results.tsx | 195 ++++++++++++++-- lib/catalog/media-type.test.ts | 22 +- lib/catalog/media-type.ts | 43 ++-- lib/search/constants.ts | 6 + lib/search/count.test.ts | 7 +- lib/search/count.ts | 22 +- lib/search/game-read.test.ts | 109 +++++++++ lib/search/game-read.ts | 70 ++++++ lib/search/mode.ts | 12 + lib/search/params.test.ts | 163 +++++++++++++- lib/search/params.ts | 180 ++++++++++++--- lib/search/read.test.ts | 4 +- lib/search/read.ts | 12 +- lib/search/scan.test.ts | 4 +- lib/search/scan.ts | 4 +- 20 files changed, 1213 insertions(+), 184 deletions(-) create mode 100644 lib/search/game-read.test.ts create mode 100644 lib/search/game-read.ts diff --git a/components/catalog/list-cards.tsx b/components/catalog/list-cards.tsx index 2318571b..22d0ac4a 100644 --- a/components/catalog/list-cards.tsx +++ b/components/catalog/list-cards.tsx @@ -139,21 +139,26 @@ function MovieListCard({ * * @param releaseDay The request's UTC day, for the date's year rule; left * out on a list whose cards print no date. + * @param meta The line under the name, the release year on `/search` + * (spec 0029, AC-12); left out on a list. */ function GameListCard({ game, priority, sizes, releaseDay, + meta, }: Omit & { game: GameSummary; releaseDay?: string; + meta?: number; }) { return ( } controls={ releaseDay === undefined ? undefined : ( diff --git a/components/search/filter-bar.test.tsx b/components/search/filter-bar.test.tsx index 814ae740..e3e6cfb1 100644 --- a/components/search/filter-bar.test.tsx +++ b/components/search/filter-bar.test.tsx @@ -2,13 +2,17 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { emptySearchParams, type SearchParams } from "@/lib/search/params"; -import type { Genre } from "@/lib/tmdb/types"; +import { + type CatalogRef, + emptySearchParams, + type GameSearchParams, + type SearchParams, +} from "@/lib/search/params"; import { FilterBar } from "./filter-bar"; import { MobileSearchOverlay } from "./mobile-search-overlay"; -/** covers: spec 0010, AC-6, AC-8, AC-9, AC-19, AC-24 */ +/** covers: spec 0010, AC-6, AC-8, AC-9, AC-19, AC-24; spec 0029, AC-9 to AC-11 */ const replace = vi.fn(); const push = vi.fn(); @@ -21,7 +25,7 @@ afterEach(() => { push.mockClear(); }); -const GENRES: { tv: Genre[] | null; movie: Genre[] | null } = { +const GENRES: Record<"tv" | "movie" | "game", CatalogRef[] | null> = { tv: [ { id: 18, name: "Drama" }, { id: 10766, name: "Soap" }, @@ -30,8 +34,19 @@ const GENRES: { tv: Genre[] | null; movie: Genre[] | null } = { { id: 18, name: "Drama" }, { id: 28, name: "Action" }, ], + game: Array.from({ length: 25 }, (_, index) => ({ + id: index + 1, + name: `Game genre ${index + 1}`, + })), }; +const PLATFORMS: CatalogRef[] = [ + { id: 6, name: "PC (Microsoft Windows)" }, + { id: 130, name: "Nintendo Switch" }, + { id: 508, name: "Nintendo Switch 2" }, + { id: 167, name: "PlayStation 5" }, +]; + function bar(overrides: Partial = {}, genres = GENRES) { return ( { }); it("disables only the genre control when TMDB's genre list failed", () => { - render(bar({}, { tv: null, movie: GENRES.movie })); + render(bar({}, { ...GENRES, tv: null })); expect(screen.getByRole("button", { name: "Genres" })).toBeDisabled(); expect(screen.getByText("Genres unavailable")).toBeInTheDocument(); @@ -188,6 +203,126 @@ describe("FilterBar", () => { }); }); +function gameBar( + overrides: Partial = {}, + platforms: CatalogRef[] | null = PLATFORMS, +) { + return ( + + ); +} + +describe("FilterBar for games (spec 0029)", () => { + it("labels the rating as IGDB's and offers years down to 1950 (AC-9)", () => { + render(gameBar({ rating: 7 })); + const years = screen.getByRole("combobox", { name: "Year" }); + expect([...years.querySelectorAll("option")].at(-1)?.text).toBe("1950"); + const ratings = screen.getByRole("combobox", { + name: "Minimum IGDB rating", + }); + expect(ratings.querySelector("option")?.text).toBe("Any IGDB rating"); + expect( + screen.getByText("Only games with at least 10 IGDB ratings"), + ).toBeInTheDocument(); + expect(screen.getByRole("searchbox", { name: "Title" })).toHaveAttribute( + "placeholder", + "Search games by title", + ); + }); + + it("sends selected platforms as hidden inputs and counts them on the trigger (AC-10)", () => { + const { container } = render(gameBar({ platformIds: [130, 508] })); + expect( + [ + ...container.querySelectorAll('input[type="hidden"][name="platform"]'), + ].map((input) => (input as HTMLInputElement).value), + ).toEqual(["130", "508"]); + expect( + screen.getByRole("button", { name: "Platforms · 2" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Clear filters" }), + ).toBeInTheDocument(); + }); + + it("filters platforms by name, keeping hidden ones checked (AC-10)", async () => { + const user = userEvent.setup(); + render(gameBar({ platformIds: [6] })); + + await user.click(screen.getByRole("button", { name: "Platforms · 1" })); + const filter = await screen.findByRole("searchbox", { + name: "Filter platforms", + }); + await waitFor(() => expect(filter).toHaveFocus()); + expect(screen.getByText("ON ANY OF")).toBeInTheDocument(); + + await user.type(filter, "SWITCH"); + expect(screen.getAllByRole("checkbox")).toHaveLength(2); + expect( + screen.queryByRole("checkbox", { name: "PC (Microsoft Windows)" }), + ).not.toBeInTheDocument(); + + await user.clear(filter); + await user.type(filter, "zzz"); + expect(screen.getByText("No platforms match")).toBeInTheDocument(); + + await user.clear(filter); + expect( + screen.getByRole("checkbox", { name: "PC (Microsoft Windows)" }), + ).toBeChecked(); + }); + + it("applies several quick toggles as one navigation after a pause (AC-9)", async () => { + const user = userEvent.setup(); + render(gameBar()); + + await user.click(screen.getByRole("button", { name: "Platforms" })); + await user.click( + await screen.findByRole("checkbox", { name: "Nintendo Switch" }), + ); + await user.click(screen.getByRole("checkbox", { name: "PlayStation 5" })); + await user.click(screen.getByRole("checkbox", { name: "Nintendo Switch" })); + + expect( + screen.getByRole("checkbox", { name: "PlayStation 5" }), + ).toBeChecked(); + expect(replace).not.toHaveBeenCalled(); + await waitFor(() => + expect(replace).toHaveBeenCalledWith("/search?type=game&platform=167", { + scroll: false, + }), + ); + expect(replace).toHaveBeenCalledTimes(1); + }); + + it("stops at 20 genres, disabling the rest (AC-10)", async () => { + const user = userEvent.setup(); + const twenty = Array.from({ length: 20 }, (_, index) => index + 1); + render(gameBar({ genreIds: twenty })); + + await user.click(screen.getByRole("button", { name: "Genres · 20" })); + expect( + await screen.findByRole("checkbox", { name: "Game genre 21" }), + ).toBeDisabled(); + expect( + screen.getByRole("checkbox", { name: "Game genre 1" }), + ).toBeEnabled(); + expect(screen.getByText("Up to 20")).toBeInTheDocument(); + }); + + it("disables only Platforms when IGDB's platform list failed (AC-11)", () => { + render(gameBar({}, null)); + expect(screen.getByRole("button", { name: "Platforms" })).toBeDisabled(); + expect(screen.getByText("Platforms unavailable")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Genres" })).toBeEnabled(); + }); +}); + describe("MobileSearchOverlay", () => { it("opens a full screen search with the field focused, and returns focus on Escape", async () => { const user = userEvent.setup(); diff --git a/components/search/filter-bar.tsx b/components/search/filter-bar.tsx index f81e57c0..8391ba19 100644 --- a/components/search/filter-bar.tsx +++ b/components/search/filter-bar.tsx @@ -25,24 +25,28 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { typedHref } from "@/lib/catalog/media-type"; +import { CATALOG_SOURCES, typedHref } from "@/lib/catalog/media-type"; +import { IGDB_MAX_FILTER_IDS } from "@/lib/igdb/constants"; import { FILTER_DEBOUNCE_MS, + GAME_MIN_RATING_COUNT, MAX_QUERY_LENGTH, MIN_VOTE_COUNT, - MIN_YEAR, RATING_STEPS, type RatingStep, } from "@/lib/search/constants"; import { mediaNoun } from "@/lib/search/count"; import { + type CatalogRef, emptySearchParams, + minYear, type SearchParams, + type SearchType, searchHref, } from "@/lib/search/params"; -import type { Genre } from "@/lib/tmdb/types"; -type Genres = { tv: Genre[] | null; movie: Genre[] | null }; +/** Each type's genre list, or null when its catalog could not supply it. */ +type Genres = Record; /** The shared look of every control in the bar: a glass pill, 44px on touch. */ const CONTROL = @@ -65,19 +69,26 @@ const CONTROL = * did not produce (a quick search `See all`, Back, a pasted link), it resets * to that URL. * - * Genre checkboxes live in a popover, which only exists once hydrated, so the - * selected genres also travel as hidden inputs and a submit before hydration - * keeps them. The type has no control here: it is the navbar's SHOWS | MOVIES - * tab (feature 22), so it travels as a hidden input too, and a switch arrives - * as a new URL this bar did not produce. + * Genre and platform checkboxes live in popovers, which only exist once + * hydrated, so the selected ids also travel as hidden inputs and a submit + * before hydration keeps them. The type has no control here: it is the + * navbar's SHOWS | MOVIES | GAMES tab (feature 22), so it travels as a hidden + * input too, and a switch arrives as a new URL this bar did not produce. + * + * On `type=game` the bar adds `Platforms`, labels the rating as IGDB's, and + * waits 400 ms after the last genre or platform toggle before it applies, so + * several quick clicks make one IGDB read (spec 0029, AC-9 to AC-11). */ function FilterBar({ initial, genres, + platforms = null, currentYear, }: { initial: SearchParams; genres: Genres; + /** IGDB's platforms, or null when they could not be read (spec 0029). */ + platforms?: CatalogRef[] | null; currentYear: number; }) { const router = useRouter(); @@ -101,16 +112,35 @@ function FilterBar({ } } + // The last URL this bar navigated to, so a delayed list toggle that an + // earlier change already carried is not sent twice. + const applied = useRef(incoming); + function apply(next: SearchParams) { const params = { ...next, page: 1 }; const href = searchHref(params); produced.current.add(href); + applied.current = href; setValues(params); startTransition(() => { router.replace(href, { scroll: false }); }); } + // A games genre or platform toggle shows at once and applies after a pause + // (spec 0029, AC-9). Each toggle bumps this, restarting the timer. + const [listEdits, setListEdits] = useState(0); + const flushLists = useEffectEvent(() => { + const next = { ...values, q: text.trim() || null, page: 1 }; + if (searchHref(next) !== applied.current) apply(next); + }); + + useEffect(() => { + if (listEdits === 0) return; + const timer = setTimeout(() => flushLists(), FILTER_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [listEdits]); + const applyText = useEffectEvent((q: string | null) => { if (q !== values.q) apply({ ...values, q }); }); @@ -122,22 +152,33 @@ function FilterBar({ }, [text]); const current = { ...values, q: text.trim() || null }; + const isGame = values.type === "game"; const typeGenres = genres[values.type]; + const source = CATALOG_SOURCES[values.type]; const anySet = current.q !== null || current.genreIds.length > 0 || + current.platformIds.length > 0 || current.year !== null || current.rating !== null; - function toggleGenre(id: number) { - const genreIds = values.genreIds.includes(id) - ? values.genreIds.filter((value) => value !== id) - : [...values.genreIds, id].sort((a, b) => a - b); - apply({ ...current, genreIds }); + function toggled(ids: number[], id: number) { + return ids.includes(id) + ? ids.filter((value) => value !== id) + : [...ids, id].sort((a, b) => a - b); + } + + function applyList(next: SearchParams) { + if (!isGame) { + apply(next); + return; + } + setValues(next); + setListEdits((count) => count + 1); } const years = Array.from( - { length: currentYear + 1 - MIN_YEAR + 1 }, + { length: currentYear + 1 - minYear(values.type) + 1 }, (_, index) => currentYear + 1 - index, ); @@ -157,6 +198,9 @@ function FilterBar({ {values.genreIds.map((id) => ( ))} + {values.platformIds.map((id) => ( + + ))}
+ applyList({ ...current, genreIds: toggled(values.genreIds, id) }) + } /> + {isGame ? ( + + applyList({ + ...current, + platformIds: toggled(values.platformIds, id), + }) + } + /> + ) : null} + - + {RATING_STEPS.map((step) => (
)} - {full ? : null} + {full ? : null} ); } diff --git a/components/search/quick-search-row.tsx b/components/search/quick-search-row.tsx index 4075eb18..b27499cc 100644 --- a/components/search/quick-search-row.tsx +++ b/components/search/quick-search-row.tsx @@ -1,11 +1,23 @@ import { cn } from "cn"; -import { FilmIcon, Gamepad2Icon, StarIcon } from "lucide-react"; +import { + FilmIcon, + Gamepad2Icon, + type LucideIcon, + StarIcon, +} from "lucide-react"; import Image from "next/image"; import { CATALOG_SOURCES } from "@/lib/catalog/media-type"; import type { SearchType } from "@/lib/search/params"; import type { QuickResult } from "@/lib/search/quick"; +/** The icon a row with no poster or cover shows, per catalog. */ +const FALLBACK_ICONS = { + tv: FilmIcon, + movie: FilmIcon, + game: Gamepad2Icon, +} as const satisfies Record; + /** * One result row in the quick search: the poster or cover, the title over its * year, and the community rating on the right (spec 0010, AC-2; spec 0029, @@ -23,7 +35,7 @@ function QuickSearchRow({ result: QuickResult; type: SearchType; }) { - const FallbackIcon = type === "game" ? Gamepad2Icon : FilmIcon; + const FallbackIcon = FALLBACK_ICONS[type]; return ( <> diff --git a/components/search/quick-search.tsx b/components/search/quick-search.tsx index 1fadaa60..276bfba1 100644 --- a/components/search/quick-search.tsx +++ b/components/search/quick-search.tsx @@ -10,6 +10,7 @@ import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { CATALOG_SOURCES } from "@/lib/catalog/media-type"; +import { SEARCH_CATALOGS } from "@/lib/search/catalogs"; import { QUICK_LIMIT, QUICK_MIN_CHARS } from "@/lib/search/constants"; import { formatResultCount, mediaNoun } from "@/lib/search/count"; import type { SearchType } from "@/lib/search/params"; @@ -181,7 +182,7 @@ function QuickSearch({ ? formatResultCount( view.data.totalResults, type, - type === "game" ? "igdb" : "search", + SEARCH_CATALOGS[type].quickCountEndpoint, ) : null} diff --git a/components/search/search-results.tsx b/components/search/search-results.tsx index 267c2e1d..8a320a4a 100644 --- a/components/search/search-results.tsx +++ b/components/search/search-results.tsx @@ -40,7 +40,7 @@ import { IgdbError, type Paged as IgdbPaged, } from "@/lib/igdb"; -import { IGDB_MAX_PAGE } from "@/lib/igdb/constants"; +import { SEARCH_CATALOGS } from "@/lib/search/catalogs"; import { formatRange, mediaNoun } from "@/lib/search/count"; import { readGameSearchPage } from "@/lib/search/game-read"; import { searchMode } from "@/lib/search/mode"; @@ -183,7 +183,8 @@ async function GameResults({ if (!parsed.ok) return ; // The raw type was `game`, so the parsed one is too. const params = parsed.params as GameSearchParams; - if (params.page > IGDB_MAX_PAGE) return ; + if (params.page > SEARCH_CATALOGS.game.maxPage) + return ; const mode = searchMode(params); if (mode === "filtered_search") @@ -467,6 +468,13 @@ function ShowResultCard({ ); } +/** What to loosen when filters match nothing; games have platforms too. */ +const FILTERED_HINTS = { + tv: "Try fewer genres, another year or a lower rating.", + movie: "Try fewer genres, another year or a lower rating.", + game: "Try fewer genres or platforms, another year or a lower rating.", +} as const satisfies Record; + /** * The two empty answers of AC-15: a query alone, or any filter. A games * search names platforms too (spec 0029, AC-13). @@ -489,9 +497,7 @@ function NoMatches({ params }: { params: SearchParams }) { } description={ filtered - ? params.type === "game" - ? "Try fewer genres or platforms, another year or a lower rating." - : "Try fewer genres, another year or a lower rating." + ? FILTERED_HINTS[params.type] : "Check the spelling, or try a shorter title." } action={} diff --git a/lib/search/catalogs.ts b/lib/search/catalogs.ts new file mode 100644 index 00000000..112d4ed4 --- /dev/null +++ b/lib/search/catalogs.ts @@ -0,0 +1,55 @@ +import { + IGDB_MAX_FILTER_IDS, + IGDB_MAX_PAGE, + IGDB_MIN_YEAR, +} from "@/lib/igdb/constants"; + +import { MIN_YEAR } from "./constants"; +import type { CountEndpoint } from "./count"; +import type { SearchType } from "./params"; + +type SearchCatalog = { + /** The earliest year the year filter offers (spec 0010, AC-7; spec 0029, AC-2). */ + minYear: number; + /** + * The most genre or platform ids one filter takes, or null when the + * catalog sets no cap (spec 0029, AC-2, AC-10). + */ + maxFilterIds: number | null; + /** The last results page a search may ask for, or null for no cap (spec 0029, AC-12). */ + maxPage: number | null; + /** The endpoint a quick search total comes from (spec 0029, AC-16). */ + quickCountEndpoint: CountEndpoint; +}; + +/** + * What differs between the three catalogs `/search` covers, in one map, so + * a new rule is one more field rather than another `type === "game"` branch + * scattered across the parser, the filter bar and quick search. `satisfies` + * keeps it exhaustive, so a new type fails to compile until it has an entry + * (spec 0029, AC-1). + * + * Pure, and the one place search code outside `lib/igdb/` reads IGDB's + * limits, so no component imports the IGDB module (lib/igdb/AGENTS.md, What + * lives where). + */ +export const SEARCH_CATALOGS = { + tv: { + minYear: MIN_YEAR, + maxFilterIds: null, + maxPage: null, + quickCountEndpoint: "search", + }, + movie: { + minYear: MIN_YEAR, + maxFilterIds: null, + maxPage: null, + quickCountEndpoint: "search", + }, + game: { + minYear: IGDB_MIN_YEAR, + maxFilterIds: IGDB_MAX_FILTER_IDS, + maxPage: IGDB_MAX_PAGE, + quickCountEndpoint: "igdb", + }, +} as const satisfies Record; diff --git a/lib/search/params.ts b/lib/search/params.ts index 54ab78cf..16b0bf49 100644 --- a/lib/search/params.ts +++ b/lib/search/params.ts @@ -6,17 +6,12 @@ import { import { parsePageParam } from "@/lib/catalog/pages"; import { IGDB_CONTROL_CHARACTERS, - IGDB_MAX_FILTER_IDS, IGDB_MAX_QUERY_LENGTH, - IGDB_MIN_YEAR, } from "@/lib/igdb/constants"; -import { - MAX_QUERY_LENGTH, - MIN_YEAR, - RATING_STEPS, - type RatingStep, -} from "./constants"; +import { SEARCH_CATALOGS } from "./catalogs"; + +import { MAX_QUERY_LENGTH, RATING_STEPS, type RatingStep } from "./constants"; /** * The three catalogs `/search` covers: the navbar tab's type, so search and @@ -176,20 +171,13 @@ export function parseSearchParams( return { ok: false, param: "q" }; } - const genreIds = idList( - raw.genre, - lists.genres, - isGame ? IGDB_MAX_FILTER_IDS : Number.POSITIVE_INFINITY, - ); + const maxIds = SEARCH_CATALOGS[type].maxFilterIds ?? Number.POSITIVE_INFINITY; + const genreIds = idList(raw.genre, lists.genres, maxIds); if (genreIds === null) return { ok: false, param: "genre" }; let platformIds: number[] = []; if (isGame) { - const ids = idList( - raw.platform, - lists.platforms ?? [], - IGDB_MAX_FILTER_IDS, - ); + const ids = idList(raw.platform, lists.platforms ?? [], maxIds); if (ids === null) return { ok: false, param: "platform" }; platformIds = ids; } else if (raw.platform !== undefined) { @@ -241,7 +229,7 @@ export function parseSearchParams( * (spec 0029, AC-2). */ export function minYear(type: SearchType): number { - return type === "game" ? IGDB_MIN_YEAR : MIN_YEAR; + return SEARCH_CATALOGS[type].minYear; } /** From f31af3e70322d8156516abd9f48ae89626e95c89 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 10:59:53 +0100 Subject: [PATCH 16/22] refactor(search): read the games search flag in one place The results, the filter bar and /api/search each repeated the type === "game" && getGamesLibrary() === "on" check; gameSearchEnabled now holds it. Co-Authored-By: Claude Opus 5.5 --- app/api/search/route.ts | 8 +++---- components/search/search-filters.tsx | 4 ++-- components/search/search-results.tsx | 4 ++-- lib/search/games-flag.test.ts | 35 ++++++++++++++++++++++++++++ lib/search/games-flag.ts | 13 +++++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 lib/search/games-flag.test.ts create mode 100644 lib/search/games-flag.ts diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 86b2a8c7..5644b8e2 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,8 +1,8 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getGamesLibrary } from "@/lib/env"; import { IgdbError, IgdbValidationError, searchGames } from "@/lib/igdb"; import { toGameQuickSearchResponse } from "@/lib/search/game-read"; +import { gameSearchEnabled } from "@/lib/search/games-flag"; import { quickSearchRequestSchema } from "@/lib/search/quick"; import { readSearchPage, toQuickSearchResponse } from "@/lib/search/read"; import { TmdbError } from "@/lib/tmdb"; @@ -34,9 +34,9 @@ const SUCCESS_CACHE = "public, s-maxage=300, stale-while-revalidate=600"; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const rawType = searchParams.get("type"); - const parsed = quickSearchRequestSchema( - rawType === "game" && getGamesLibrary() === "on", - ).safeParse({ type: rawType, q: searchParams.get("q") }); + const parsed = quickSearchRequestSchema(gameSearchEnabled(rawType)).safeParse( + { type: rawType, q: searchParams.get("q") }, + ); if (!parsed.success) return invalid(); const { type, q } = parsed.data; diff --git a/components/search/search-filters.tsx b/components/search/search-filters.tsx index c3c02291..ccf2f341 100644 --- a/components/search/search-filters.tsx +++ b/components/search/search-filters.tsx @@ -1,7 +1,7 @@ import { Skeleton } from "@/components/skeleton"; import { parseLibraryTypeParam } from "@/lib/catalog/media-type"; -import { getGamesLibrary } from "@/lib/env"; import { getGameGenres, getGamePlatforms, IgdbError } from "@/lib/igdb"; +import { gameSearchEnabled } from "@/lib/search/games-flag"; import { type CatalogRef, emptySearchParams, @@ -42,7 +42,7 @@ async function settleList(read: () => Promise) { */ async function SearchFilters({ raw }: { raw: RawParams }) { const currentYear = new Date().getUTCFullYear(); - const gamesEnabled = raw.type === "game" && getGamesLibrary() === "on"; + const gamesEnabled = gameSearchEnabled(raw.type); const type = parseLibraryTypeParam(raw.type, gamesEnabled) ?? "tv"; if (type === "game") { diff --git a/components/search/search-results.tsx b/components/search/search-results.tsx index 8a320a4a..19bde2d0 100644 --- a/components/search/search-results.tsx +++ b/components/search/search-results.tsx @@ -31,7 +31,6 @@ import { typedHref, } from "@/lib/catalog/media-type"; import { gameHasNextPage, lastGamePage } from "@/lib/catalog-list-read"; -import { getGamesLibrary } from "@/lib/env"; import { type GameRef, type GameSummary, @@ -43,6 +42,7 @@ import { import { SEARCH_CATALOGS } from "@/lib/search/catalogs"; import { formatRange, mediaNoun } from "@/lib/search/count"; import { readGameSearchPage } from "@/lib/search/game-read"; +import { gameSearchEnabled } from "@/lib/search/games-flag"; import { searchMode } from "@/lib/search/mode"; import { type GameSearchParams, @@ -86,7 +86,7 @@ type RawParams = Record; * search down (spec 0029, AC-1). A games search has its own branch. */ async function SearchResults({ raw }: { raw: RawParams }) { - const gamesEnabled = raw.type === "game" && getGamesLibrary() === "on"; + const gamesEnabled = gameSearchEnabled(raw.type); const type = parseLibraryTypeParam(raw.type, gamesEnabled); if (type === null) return ; diff --git a/lib/search/games-flag.test.ts b/lib/search/games-flag.test.ts new file mode 100644 index 00000000..21523db6 --- /dev/null +++ b/lib/search/games-flag.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { gameSearchEnabled } from "./games-flag"; + +/** + * covers: spec 0029, AC-1, AC-17 + * + * Only a raw `type=game` reads the flag, so a bad flag value never breaks a + * show or movie search. + */ +describe("gameSearchEnabled", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("accepts type=game while the games library is on", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + expect(gameSearchEnabled("game")).toBe(true); + }); + + it("refuses type=game while the games library is off", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "off"); + expect(gameSearchEnabled("game")).toBe(false); + }); + + it("never reads an invalid flag for another type", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "yes-please"); + for (const type of ["tv", "movie", undefined, null, ["game"]]) { + expect(gameSearchEnabled(type)).toBe(false); + } + expect(() => gameSearchEnabled("game")).toThrow( + "NEXT_PUBLIC_GAMES_LIBRARY", + ); + }); +}); diff --git a/lib/search/games-flag.ts b/lib/search/games-flag.ts new file mode 100644 index 00000000..5f83d3c6 --- /dev/null +++ b/lib/search/games-flag.ts @@ -0,0 +1,13 @@ +import { getGamesLibrary } from "@/lib/env"; + +/** + * Whether this request may search games: its raw `type` is `game` and the + * games library is on (spec 0029, AC-1, AC-17). + * + * The flag is read only for a raw `type=game`, so an invalid flag value can + * never take a show or movie search down. The results, the filter bar and + * `/api/search` all ask here, so the three cannot drift apart. + */ +export function gameSearchEnabled(rawType: unknown): boolean { + return rawType === "game" && getGamesLibrary() === "on"; +} From b9d9b396fb0b526a600769e31ea21ba7ed25da30 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 11:02:42 +0100 Subject: [PATCH 17/22] refactor(search): narrow parsed search params instead of casting - parseSearchParams and quickSearchRequestSchema take { gamesEnabled } rather than a bare boolean; without it the parse returns TmdbSearchParams, so the shows and movies branch needs no cast. - parseGameSearchParams narrows a games parse at runtime, replacing the as GameSearchParams casts in the results and the filter bar. - searchMode is an exhaustive switch, and its games overload leaves out filtered_search, so the "Games never filter locally" throw is gone (spec 0029, AC-1). - settleList and readGameLists replace the two near identical list loaders in the results and the filter bar. Co-Authored-By: Claude Opus 5.5 --- app/api/search/route.ts | 6 +- components/search/search-filters.tsx | 37 +++--------- components/search/search-results.tsx | 43 ++++---------- lib/search/game-read.ts | 4 +- lib/search/lists.ts | 37 ++++++++++++ lib/search/mode.ts | 45 ++++++++++---- lib/search/params.test.ts | 26 ++++++++- lib/search/params.ts | 87 +++++++++++++++++++++++----- lib/search/quick.test.ts | 12 ++-- lib/search/quick.ts | 10 +++- 10 files changed, 206 insertions(+), 101 deletions(-) create mode 100644 lib/search/lists.ts diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 5644b8e2..dab9c08c 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -34,9 +34,9 @@ const SUCCESS_CACHE = "public, s-maxage=300, stale-while-revalidate=600"; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const rawType = searchParams.get("type"); - const parsed = quickSearchRequestSchema(gameSearchEnabled(rawType)).safeParse( - { type: rawType, q: searchParams.get("q") }, - ); + const parsed = quickSearchRequestSchema({ + gamesEnabled: gameSearchEnabled(rawType), + }).safeParse({ type: rawType, q: searchParams.get("q") }); if (!parsed.success) return invalid(); const { type, q } = parsed.data; diff --git a/components/search/search-filters.tsx b/components/search/search-filters.tsx index ccf2f341..9f665258 100644 --- a/components/search/search-filters.tsx +++ b/components/search/search-filters.tsx @@ -1,33 +1,18 @@ import { Skeleton } from "@/components/skeleton"; import { parseLibraryTypeParam } from "@/lib/catalog/media-type"; -import { getGameGenres, getGamePlatforms, IgdbError } from "@/lib/igdb"; import { gameSearchEnabled } from "@/lib/search/games-flag"; +import { readGameLists, settleList } from "@/lib/search/lists"; import { - type CatalogRef, emptySearchParams, + parseGameSearchParams, parseSearchParams, } from "@/lib/search/params"; -import { getMovieGenres, getTvGenres, TmdbError } from "@/lib/tmdb"; +import { getMovieGenres, getTvGenres } from "@/lib/tmdb"; import { FilterBar } from "./filter-bar"; type RawParams = Record; -/** - * A genre or platform list, or null when its catalog could not supply it - * (AC-19; spec 0029, AC-11). - */ -async function settleList(read: () => Promise) { - try { - return await read(); - } catch (error) { - if (!(error instanceof TmdbError || error instanceof IgdbError)) { - throw error; - } - return null; - } -} - /** * The server half of the filter bar (spec 0010, AC-8, AC-19, AC-24). * @@ -46,21 +31,13 @@ async function SearchFilters({ raw }: { raw: RawParams }) { const type = parseLibraryTypeParam(raw.type, gamesEnabled) ?? "tv"; if (type === "game") { - const [genres, platforms] = await Promise.all([ - settleList(getGameGenres), - settleList(getGamePlatforms), - ]); - const parsed = parseSearchParams( - raw, - { genres: genres ?? [], platforms: platforms ?? [] }, - currentYear, - true, - ); + const lists = await readGameLists(); + const parsed = parseGameSearchParams(raw, lists, currentYear); return ( ); diff --git a/components/search/search-results.tsx b/components/search/search-results.tsx index 19bde2d0..409702f7 100644 --- a/components/search/search-results.tsx +++ b/components/search/search-results.tsx @@ -32,10 +32,7 @@ import { } from "@/lib/catalog/media-type"; import { gameHasNextPage, lastGamePage } from "@/lib/catalog-list-read"; import { - type GameRef, type GameSummary, - getGameGenres, - getGamePlatforms, IgdbError, type Paged as IgdbPaged, } from "@/lib/igdb"; @@ -43,9 +40,10 @@ import { SEARCH_CATALOGS } from "@/lib/search/catalogs"; import { formatRange, mediaNoun } from "@/lib/search/count"; import { readGameSearchPage } from "@/lib/search/game-read"; import { gameSearchEnabled } from "@/lib/search/games-flag"; +import { readGameLists } from "@/lib/search/lists"; import { searchMode } from "@/lib/search/mode"; import { - type GameSearchParams, + parseGameSearchParams, parseSearchParams, type SearchParamName, type SearchParams, @@ -115,8 +113,7 @@ async function SearchResults({ raw }: { raw: RawParams }) { new Date().getUTCFullYear(), ); if (!parsed.ok) return ; - // The raw type was not `game`, so neither is the parsed one. - const params = parsed.params as TmdbSearchParams; + const { params } = parsed; try { const mode = searchMode(params); @@ -137,16 +134,6 @@ async function SearchResults({ raw }: { raw: RawParams }) { } } -/** A reference list, or null when IGDB could not supply it (spec 0029, AC-11). */ -async function settleGameList(read: () => Promise) { - try { - return await read(); - } catch (error) { - if (!(error instanceof IgdbError)) throw error; - return null; - } -} - /** * The games half of `/search` (spec 0029, AC-2 to AC-8, AC-11 to AC-13). * @@ -163,32 +150,22 @@ async function GameResults({ raw: RawParams; retryHref: string; }) { - const [genres, platforms] = await Promise.all([ - settleGameList(getGameGenres), - settleGameList(getGamePlatforms), - ]); + const lists = await readGameLists(); if ( - (genres === null && raw.genre !== undefined) || - (platforms === null && raw.platform !== undefined) + (lists.genres === null && raw.genre !== undefined) || + (lists.platforms === null && raw.platform !== undefined) ) { return ; } - const parsed = parseSearchParams( - raw, - { genres: genres ?? [], platforms: platforms ?? [] }, - new Date().getUTCFullYear(), - true, - ); + const parsed = parseGameSearchParams(raw, lists, new Date().getUTCFullYear()); if (!parsed.ok) return ; - // The raw type was `game`, so the parsed one is too. - const params = parsed.params as GameSearchParams; - if (params.page > SEARCH_CATALOGS.game.maxPage) + const { params } = parsed; + if (params.page > SEARCH_CATALOGS.game.maxPage) { return ; + } const mode = searchMode(params); - if (mode === "filtered_search") - throw new Error("Games never filter locally."); let paged: IgdbPaged; try { diff --git a/lib/search/game-read.ts b/lib/search/game-read.ts index 5ca1bfea..26dadf62 100644 --- a/lib/search/game-read.ts +++ b/lib/search/game-read.ts @@ -13,7 +13,7 @@ import { import { IGDB_LIST_PAGE_SIZE } from "@/lib/igdb/constants"; import { GAME_MIN_RATING_COUNT, QUICK_LIMIT } from "./constants"; -import type { SearchMode } from "./mode"; +import type { GameSearchMode } from "./mode"; import type { GameSearchParams } from "./params"; import type { QuickSearchResponse } from "./quick"; @@ -34,7 +34,7 @@ import type { QuickSearchResponse } from "./quick"; * @throws {IgdbError} When IGDB fails; the results panel catches it. */ export async function readGameSearchPage( - mode: Exclude, + mode: GameSearchMode, params: GameSearchParams, ): Promise> { if (mode === "browse") { diff --git a/lib/search/lists.ts b/lib/search/lists.ts new file mode 100644 index 00000000..c617791c --- /dev/null +++ b/lib/search/lists.ts @@ -0,0 +1,37 @@ +import { getGameGenres, getGamePlatforms, IgdbError } from "@/lib/igdb"; +import type { GameLists } from "@/lib/search/params"; +import { TmdbError } from "@/lib/tmdb"; + +/** + * A genre or platform list, or null when its catalog could not supply it, + * so a URL that does not need the list still works (spec 0010, AC-19; spec + * 0029, AC-11). Any other error is a bug and still throws. + * + * No `server-only` line of its own: the TMDB and IGDB modules it reads + * already carry one, and leaving it out lets component tests mock them. + */ +export async function settleList( + read: () => Promise, +): Promise { + try { + return await read(); + } catch (error) { + if (!(error instanceof TmdbError || error instanceof IgdbError)) { + throw error; + } + return null; + } +} + +/** + * IGDB's cached genre and platform lists, each settled on its own, as both + * the filter bar and the results read them, so the two agree within one + * render (spec 0029, AC-11). + */ +export async function readGameLists(): Promise { + const [genres, platforms] = await Promise.all([ + settleList(getGameGenres), + settleList(getGamePlatforms), + ]); + return { genres, platforms }; +} diff --git a/lib/search/mode.ts b/lib/search/mode.ts index e6152223..953c652c 100644 --- a/lib/search/mode.ts +++ b/lib/search/mode.ts @@ -1,4 +1,8 @@ -import type { SearchParams } from "./params"; +import type { + GameSearchParams, + SearchParams, + TmdbSearchParams, +} from "./params"; /** * How `/search` answers a URL (spec 0010, state transitions table). @@ -14,17 +18,38 @@ import type { SearchParams } from "./params"; */ export type SearchMode = "browse" | "discover" | "search" | "filtered_search"; -/** The mode is a pure function of the parsed URL, never of client state. */ +/** The modes a games search can be in, so its read never meets `filtered_search`. */ +export type GameSearchMode = Exclude; + +/** + * The mode is a pure function of the parsed URL, never of client state. A + * games search's return type leaves out `filtered_search`, so its caller + * needs no runtime guard against it (spec 0029, AC-1, AC-3). + */ +export function searchMode(params: GameSearchParams): GameSearchMode; +export function searchMode(params: TmdbSearchParams): SearchMode; +export function searchMode(params: SearchParams): SearchMode; export function searchMode(params: SearchParams): SearchMode { - if (params.type === "game") { - if (params.q !== null) return "search"; - const anyFilter = - params.genreIds.length > 0 || - params.platformIds.length > 0 || - params.year !== null || - params.rating !== null; - return anyFilter ? "discover" : "browse"; + switch (params.type) { + case "game": + return gameSearchMode(params); + case "tv": + case "movie": + return tmdbSearchMode(params); } +} + +function gameSearchMode(params: GameSearchParams): GameSearchMode { + if (params.q !== null) return "search"; + const anyFilter = + params.genreIds.length > 0 || + params.platformIds.length > 0 || + params.year !== null || + params.rating !== null; + return anyFilter ? "discover" : "browse"; +} + +function tmdbSearchMode(params: TmdbSearchParams): SearchMode { const localFilters = params.genreIds.length > 0 || params.rating !== null; if (params.q === null) { return localFilters || params.year !== null ? "discover" : "browse"; diff --git a/lib/search/params.test.ts b/lib/search/params.test.ts index 943eee47..f838e2f9 100644 --- a/lib/search/params.test.ts +++ b/lib/search/params.test.ts @@ -8,6 +8,7 @@ import { emptySearchParams, type GameSearchParams, hasAnyFilter, + parseGameSearchParams, parseSearchParams, searchHref, type TmdbSearchParams, @@ -53,7 +54,7 @@ function parseGame( { type: "game", ...raw }, { genres: GAME_GENRES, platforms: PLATFORMS }, YEAR, - gamesEnabled, + { gamesEnabled }, ); } @@ -312,11 +313,32 @@ describe("parseSearchParams for games (spec 0029)", () => { { type: "game", genre: "5" }, { genres: GAME_GENRES, platforms: [] }, YEAR, - true, + { gamesEnabled: true }, ).ok, ).toBe(true); }); + it("narrows a games parse, refusing a URL that is not a games search (AC-1)", () => { + const lists = { genres: GAME_GENRES, platforms: PLATFORMS }; + const parsed = parseGameSearchParams( + { type: "game", q: "zelda" }, + lists, + YEAR, + ); + expect(parsed).toEqual({ ok: true, params: gameParams({ q: "zelda" }) }); + expect(parseGameSearchParams({ type: "tv" }, lists, YEAR)).toEqual({ + ok: false, + param: "type", + }); + expect( + parseGameSearchParams( + { type: "game", platform: "130" }, + { genres: GAME_GENRES, platforms: null }, + YEAR, + ), + ).toEqual({ ok: false, param: "platform" }); + }); + it("writes platforms after the genres and round trips (AC-2)", () => { const original = gameParams({ q: "mario", diff --git a/lib/search/params.ts b/lib/search/params.ts index 16b0bf49..119ba733 100644 --- a/lib/search/params.ts +++ b/lib/search/params.ts @@ -73,10 +73,20 @@ export type SearchLists = { platforms?: readonly CatalogRef[]; }; -export type ParseResult = - | { ok: true; params: SearchParams } +/** + * A parse's answer. `P` narrows to `TmdbSearchParams` when games were not + * accepted, so a caller never casts what the parser already proved. + */ +export type ParseResult

= + | { ok: true; params: P } | { ok: false; param: SearchParamName }; +/** + * @param gamesEnabled Whether `type=game` is accepted: `gameSearchEnabled`, + * which reads the flag only for a raw `type=game` (spec 0029, AC-1). + */ +export type ParseOptions = { gamesEnabled?: boolean }; + type RawValue = string | string[] | undefined; type RawParams = Record; @@ -148,14 +158,27 @@ function isCleanGameQuery(query: string): boolean { * catalog is refused. * @param currentYear The current UTC year, read at request time by the caller, * so the year range moves on New Year's Day without a deploy. - * @param gamesEnabled Whether `type=game` is accepted: `getGamesLibrary()`, - * read by the caller only when the raw `type` is `game` (spec 0029, AC-1). + * @param options.gamesEnabled Whether `type=game` is accepted (spec 0029, + * AC-1). Without it the result can only be a show or movie search, and the + * return type says so. */ export function parseSearchParams( raw: RawParams, lists: SearchLists, currentYear: number, - gamesEnabled = false, + options?: { gamesEnabled?: false }, +): ParseResult; +export function parseSearchParams( + raw: RawParams, + lists: SearchLists, + currentYear: number, + options: ParseOptions, +): ParseResult; +export function parseSearchParams( + raw: RawParams, + lists: SearchLists, + currentYear: number, + { gamesEnabled = false }: ParseOptions = {}, ): ParseResult { const type = parseLibraryTypeParam(raw.type, gamesEnabled); if (type === null) return { ok: false, param: "type" }; @@ -210,20 +233,56 @@ export function parseSearchParams( const page = parsePageParam(raw.page); if (page === null) return { ok: false, param: "page" }; + const common = { + q: q.value, + genreIds, + year: yearValue, + rating: ratingValue, + page, + }; return { ok: true, - params: { - type, - q: q.value, - genreIds, - platformIds, - year: yearValue, - rating: ratingValue, - page, - } as SearchParams, + params: + type === "game" + ? { ...common, type, platformIds } + : { ...common, type, platformIds: [] }, }; } +/** + * IGDB's genre and platform lists as the games search reads them, each null + * when IGDB could not supply it (spec 0029, AC-11). + */ +export type GameLists = { + genres: CatalogRef[] | null; + platforms: CatalogRef[] | null; +}; + +/** + * `parseSearchParams` for a request already known to be a games search, as + * the results and the filter bar both call it (spec 0029, AC-1, AC-2). A + * missing list is read as empty, so only a URL that names one of its ids + * fails. The result is narrowed rather than cast: a URL whose `type` is not + * `game` is refused by name. + */ +export function parseGameSearchParams( + raw: RawParams, + lists: GameLists, + currentYear: number, +): ParseResult { + const parsed = parseSearchParams( + raw, + { genres: lists.genres ?? [], platforms: lists.platforms ?? [] }, + currentYear, + { gamesEnabled: true }, + ); + if (!parsed.ok) return parsed; + const { params } = parsed; + return params.type === "game" + ? { ok: true, params } + : { ok: false, param: "type" }; +} + /** * The earliest year a type's year filter offers: TMDB's 1900, IGDB's 1950 * (spec 0029, AC-2). diff --git a/lib/search/quick.test.ts b/lib/search/quick.test.ts index 385c25f1..fdd84a61 100644 --- a/lib/search/quick.test.ts +++ b/lib/search/quick.test.ts @@ -47,12 +47,16 @@ describe("quickSearchRequestSchema", () => { describe("quickSearchRequestSchema for games (spec 0029, AC-17)", () => { it("accepts game only while the games library is on", () => { expect( - quickSearchRequestSchema(true).safeParse({ type: "game", q: "zelda" }) - .success, + quickSearchRequestSchema({ gamesEnabled: true }).safeParse({ + type: "game", + q: "zelda", + }).success, ).toBe(true); expect( - quickSearchRequestSchema(false).safeParse({ type: "game", q: "zelda" }) - .success, + quickSearchRequestSchema({ gamesEnabled: false }).safeParse({ + type: "game", + q: "zelda", + }).success, ).toBe(false); }); }); diff --git a/lib/search/quick.ts b/lib/search/quick.ts index 659e1bd8..ec1b589c 100644 --- a/lib/search/quick.ts +++ b/lib/search/quick.ts @@ -15,10 +15,14 @@ const query = z.string().trim().min(QUICK_MIN_CHARS).max(MAX_QUERY_LENGTH); * Validates the two query parameters the Route Handler accepts. `game` is * one of the types only while the games library is on (spec 0029, AC-17). * - * @param gamesEnabled `getGamesLibrary() === "on"`, read by the caller only - * when the raw `type` is `game`. + * @param options.gamesEnabled `gameSearchEnabled(rawType)`, which reads the + * flag only for a raw `type=game`. */ -export function quickSearchRequestSchema(gamesEnabled = false) { +export function quickSearchRequestSchema({ + gamesEnabled = false, +}: { + gamesEnabled?: boolean; +} = {}) { return z.object({ type: z .enum(["tv", "movie", "game"]) From e0ac2e5d5b2cc61d29be27333451a21bd0328fb7 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 11:03:07 +0100 Subject: [PATCH 18/22] test(images): check the quick search cover size against the IGDB ladder Spec 0029, AC-16 asks the loader test to prove the row's 42px sizes resolves to a real IGDB_COVER_LADDER rung at 1x, 2x and 3x. Co-Authored-By: Claude Opus 5.5 --- lib/images/igdb-loader.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/images/igdb-loader.test.ts b/lib/images/igdb-loader.test.ts index 95208d07..39d6bcb0 100644 --- a/lib/images/igdb-loader.test.ts +++ b/lib/images/igdb-loader.test.ts @@ -5,7 +5,7 @@ import { IGDB_IMAGE_BASE } from "@/lib/igdb/constants"; import { igdbImageLoader } from "./igdb-loader"; /** - * covers: spec 0025, AC-12; spec 0028, AC-6 + * covers: spec 0025, AC-12; spec 0028, AC-6; spec 0029, AC-16 * * 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. @@ -26,6 +26,21 @@ describe("igdbImageLoader", () => { expect(url.replace("#same-size", "")).toBe(at(size)); }); + // spec 0029, AC-16: the quick search row's `sizes="42px"` cover. With + // Next's default image widths the browser picks 48 at 1x, 96 at 2x and 128 + // at 3x, and each lands on a real cover rung well under the 2x ceiling. + it.each([ + [48, "t_cover_small"], + [96, "t_cover_small_2x"], + [128, "t_cover_small_2x"], + ])( + "serves the 42px quick search cover at width %i from %s", + (width, size) => { + const url = igdbImageLoader({ src: at("t_cover_big_2x"), width }); + expect(url).toBe(at(size)); + }, + ); + it("marks the source's own size so Next sees the width honoured", () => { expect(igdbImageLoader({ src: at("t_cover_big_2x"), width: 1000 })).toBe( same("t_cover_big_2x"), From bc09fb7fc0b8fa1a9796ee347dae9ff4e15fdcbc Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 11:03:50 +0100 Subject: [PATCH 19/22] test(search): cover the text and list pauses firing in either order Spec 0029, AC-9: a typed query and a games list toggle made together make one navigation whichever pause fires first. Co-Authored-By: Claude Opus 5.5 --- components/search/filter-bar.test.tsx | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/components/search/filter-bar.test.tsx b/components/search/filter-bar.test.tsx index e3e6cfb1..3b362fc1 100644 --- a/components/search/filter-bar.test.tsx +++ b/components/search/filter-bar.test.tsx @@ -300,6 +300,43 @@ describe("FilterBar for games (spec 0029)", () => { expect(replace).toHaveBeenCalledTimes(1); }); + // The text pause and the list pause run on separate timers. Whichever + // fires first carries both changes, and the other finds the URL already + // applied, so the pair always makes one read. + it.each([ + ["the text pause fires first", "text"], + ["the list pause fires first", "list"], + ] as const)("makes one navigation when %s (AC-9)", async (_, first) => { + const user = userEvent.setup(); + render(gameBar()); + const title = screen.getByRole("searchbox", { name: "Title" }); + + async function toggle() { + await user.click(screen.getByRole("button", { name: /^Platforms/ })); + await user.click( + await screen.findByRole("checkbox", { name: "Nintendo Switch" }), + ); + await user.keyboard("{Escape}"); + } + + if (first === "text") { + await user.type(title, "zelda"); + await toggle(); + } else { + await toggle(); + await user.type(title, "zelda"); + } + + await waitFor(() => + expect(replace).toHaveBeenCalledWith( + "/search?type=game&q=zelda&platform=130", + { scroll: false }, + ), + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(replace).toHaveBeenCalledTimes(1); + }); + it("stops at 20 genres, disabling the rest (AC-10)", async () => { const user = userEvent.setup(); const twenty = Array.from({ length: 20 }, (_, index) => index + 1); From dd3df23859e315decaffccb8d50d19be5e1ad847 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 11:04:21 +0100 Subject: [PATCH 20/22] docs(igdb): name every pure importer of the IGDB constants Co-Authored-By: Claude Opus 5.5 --- lib/igdb/AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/igdb/AGENTS.md b/lib/igdb/AGENTS.md index b621f477..ca1537ca 100644 --- a/lib/igdb/AGENTS.md +++ b/lib/igdb/AGENTS.md @@ -30,8 +30,12 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it. (Trending and Popular: the `popularity_primitives` ranks, then the batch read, spec 0028) and `reference.ts`. - Pure code outside this folder (`lib/catalog/game-lists.ts`, - `lib/catalog-list-read.ts`) may import `constants.ts` and `types.ts` - directly, since `index.ts` is server only; a page or component never does. + `lib/catalog/media-type.ts`, `lib/catalog-list-read.ts`, `lib/images/` + and `lib/search/`) may import `constants.ts` and `types.ts` directly, + since `index.ts` is server only; a page or component never does, and + reads search's IGDB limits through `SEARCH_CATALOGS` + (`lib/search/catalogs.ts`). Nothing outside imports `query.ts`; a rule it + shares, like the control character pattern, moves to `constants.ts`. - Pure files (no `server-only`, so tests and the live check read them): `catalog-filter.ts`, `constants.ts`, `errors.ts`, `query.ts`, `types.ts`, `validation.ts`. Every other file starts with `import "server-only";`. From 91a2f2ae9643cd2ff308962b13b12bf757a6950e Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 11:04:22 +0100 Subject: [PATCH 21/22] docs(specs): match spec 0029 AC-15 and AC-16 to the shipped navbar and row AC-15 now says the static /games shells hold the real field, since no fallback shows there; AC-16 says 2:3, the frame every card uses. Co-Authored-By: Claude Opus 5.5 --- docs/specs/0029-game-search-filters/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/0029-game-search-filters/index.md b/docs/specs/0029-game-search-filters/index.md index a036b2d3..7d7c9e04 100644 --- a/docs/specs/0029-game-search-filters/index.md +++ b/docs/specs/0029-game-search-filters/index.md @@ -34,8 +34,8 @@ This adds games to search. `/search?type=game` finds games by title and narrows - **AC-12**: Every game result is a `GameListCard` in the existing `PosterGrid`, linking to `/games/{id}`, with the cover (or the missing art state), the name, the release year as its meta line (left out when unknown) and the `IgdbRatingBadge` when rated. Game cards carry no status and no controls, and read no session, until G8. - **AC-13**: The games copy of the states: `No games match these filters` (`Try fewer genres or platforms, another year or a lower rating.`) or `No games match “{q}”` (`Check the spelling, or try a shorter title.`), each with `Clear filters` to `/search?type=game`; `That filter isn't valid` naming the parameter; and on any IGDB failure `Couldn't reach IGDB` (`IGDB didn't respond. Try again in a moment.`) with a `Try again` `RetryLink` to the same URL. The heading, the navbar and the filter bar stay usable in every state. The loading skeleton keeps the footprint of spec 0010, AC-21. - **AC-14**: On `/search`, while the flag is on, the navbar tabs are SHOWS | MOVIES | GAMES and switch `type` in place through `typedHref("/search", type, { current })`: `q`, `year` and `rating` are kept, `genre`, `platform` and `page` are dropped. A kept `year` the new type cannot take (below 1950 for `game`) is dropped too, so a switch never lands on an invalid panel. GAMES is lit on `/search?type=game`. -- **AC-15**: While the flag is on, the navbar search (the desktop field and the mobile search icon) shows on `/games`, on every `/games/*` path, on the three library pages with `type=game` and on `/search?type=game`, and searches games there, with the placeholder `Search games`. The search reads `libraryType` through `WithLibraryTypedPage`, the field the tabs already use, and `PageMediaType.type` stays as it is for the logo. Its Suspense fallback takes the placeholder from the pathname where the prerendered shell knows it, so it reads `Search games` on `/games` and the five list pages. On `/games/{id}` and on the typed paths the shell cannot know the type, so `Search shows` shows once before the type resolves, the one time flash spec 0027, AC-4 already accepted. Only the pathname is read off the four typed paths, so `/games` and its list and game pages keep their prerendered shells. With the flag off, nothing changes from today. -- **AC-16**: Games quick search keeps every behaviour of spec 0010, AC-2 to AC-6 (debounce, cancellation, combobox keys, mobile overlay, skeleton and error rows). Each row shows the cover in the 3:4 frame the game cards use (or the game missing cover icon), the name, the release year (left out when unknown) and the amber star with the IGDB community rating, its screen reader label `IGDB community rating`. The row takes its label from `CATALOG_SOURCES[type]` and uses the same amber colour token as `IgdbRatingBadge`. The cover's `sizes` resolves to a real `IGDB_COVER_LADDER` rung, checked by the loader test. The footer reads `{n} games` from IGDB's count, or just `See all` when IGDB sends no count; See all opens `/search?type=game&q={query}`. No match reads `No games match “{query}”`, and a failure reads `Couldn't reach IGDB` with `Try again`. +- **AC-15**: While the flag is on, the navbar search (the desktop field and the mobile search icon) shows on `/games`, on every `/games/*` path, on the three library pages with `type=game` and on `/search?type=game`, and searches games there, with the placeholder `Search games`. The search reads `libraryType` through `WithLibraryTypedPage`, the field the tabs already use, and `PageMediaType.type` stays as it is for the logo. `/games` and the five list pages are static, so their prerendered shell holds the real field with `Search games` and no fallback is ever shown there. On `/games/{id}` and on the typed paths the shell cannot know the type, so `Search shows` shows once before the type resolves, the one time flash spec 0027, AC-4 already accepted. Only the pathname is read off the four typed paths, so `/games` and its list and game pages keep their prerendered shells. With the flag off, nothing changes from today. +- **AC-16**: Games quick search keeps every behaviour of spec 0010, AC-2 to AC-6 (debounce, cancellation, combobox keys, mobile overlay, skeleton and error rows). Each row shows the cover in the 2:3 frame the game cards use (or the game missing cover icon), the name, the release year (left out when unknown) and the amber star with the IGDB community rating, its screen reader label `IGDB community rating`. The row takes its label from `CATALOG_SOURCES[type]` and uses the same amber colour token as `IgdbRatingBadge`. The cover's `sizes` resolves to a real `IGDB_COVER_LADDER` rung, checked by the loader test. The footer reads `{n} games` from IGDB's count, or just `See all` when IGDB sends no count; See all opens `/search?type=game&q={query}`. No match reads `No games match “{query}”`, and a failure reads `Couldn't reach IGDB` with `Try again`. - **AC-17**: `GET /api/search` accepts `type=game` only while the flag is on (otherwise 400 `{ error: "invalid" }`), reads only the cached `searchGames` page 1 at the default 20 and returns its first 5 games. The response field `tmdbRating` is renamed `rating` for every type, and `totalResults` is `number | null`. An `IgdbError` answers 502 `{ error: "upstream", kind }`, and an `IgdbValidationError` (a class of its own, not an `IgdbError`) answers 400 `{ error: "invalid" }`, never a 500. `getGamesLibrary()` is read only when the `type` parameter is `game`. The cache headers, the `no-store` errors and the rule that it reads no cookie, header or session stay as spec 0010, AC-20 sets them. - **AC-18**: On `/search?type=game` the logo links to `/games` and remembers `game`, as on a games tab (spec 0028, AC-16): `libraryTypeForLocation` treats `/search` like a library page for `game`. - **AC-19**: The IGDB module changes, each validated before the cache and part of the cache key: genres match all of the ids (`cond.allOf`, writing `genres = [ids]`) while platforms keep any of; `minRatingCount` moves into `GameFilters`, so `searchGames` accepts it; `searchGames` accepts `pageSize` 20 or 24 like `discoverGames`; and `discoverGames` accepts `requireSortField` (boolean, default `true`), which with `false` leaves out the `{sort field} != null` condition. G6's lists pass none of these and are unchanged. In `validation.ts`, `minRatingCount` moves into the shared `filters()` validator and its condition into `sharedConditions`, and the discover only copies in `validateDiscoverOptions` and `fetchDiscoverGames` are removed, so the condition is written once; `requireSortField` joins `ValidDiscoverOptions`; `pageSize` joins `ValidSearchOptions` with the same 20 or 24 check and default 20, and `fetchSearchGames` reads it instead of `IGDB_PAGE_SIZE`. The fixture suite covers each, and `pnpm igdb:live` gains two checks: a two genre `[]` filter returns only games with both genres, and a `rating_count_desc` discover with `requireSortField: false` puts every unrated game after every rated one and counts them. @@ -118,7 +118,7 @@ Reasoning and options: see [rationale.md](rationale.md). | browse | heading line | `Popular {noun} on {CATALOG_SOURCES.game}` | | card | name, cover, year, rating, link | `GameSummary.name`, `coverUrl`, `releaseYear`, `communityRating`; `gameHref(id)` | | error and empty copy | source name, noun | `CATALOG_SOURCES[type]`, `mediaNoun(type)` (gains `game`/`games`) | -| navbar search | type and placeholder | `PageMediaType.libraryType` through `WithLibraryTypedPage`: `game` on `/games*` by pathname, and on the typed paths by `type` through `libraryTypeForLocation`; the fallback reads the pathname only (AC-15) | +| navbar search | type and placeholder | `PageMediaType.libraryType` through `WithLibraryTypedPage`: `game` on `/games*` by pathname, and on the typed paths by `type` through `libraryTypeForLocation`; the static `/games*` shells render the real field, and the fallback shows only where the pathname or type is unknown (AC-15) | | tabs on `/search` | each tab's href | `typedHref("/search", type, { current })` | | logo on `/search?type=game` | target and remembered type | `libraryTypeForLocation` (AC-18) | | quick search | rows, count | `/api/search` body: `searchGames({ query, page: 1 })` cut to 5; `coverUrl` passed as built (the IGDB loader picks the rung from `sizes`); `releaseYear`; `communityRating` as `rating`; `gameHref(id)` | @@ -170,7 +170,7 @@ Reasoning and options: see [rationale.md](rationale.md). - Type narrowing: no TMDB read accepts `LibraryType` (a type test), `mediaNoun("game")` is `games`, and `resultsKey` keeps two `platform` values, verifies **AC-1**, **AC-2**. - Edge inputs: `?type=game&q=%01` shows `That filter isn't valid` naming `q`, and `/api/search?type=game&q=%01%01` answers 400, never 500; with an invalid flag value, `/search?type=movie&q=dune` still works, verifies **AC-1**, **AC-2**, **AC-17**. - Picker cap: after 20 platforms every unchecked box is disabled with `Up to 20`; three quick toggles make one navigation, verifies **AC-9**, **AC-10**. -- Navbar: with the flag on, `/games/top-rated` shows `Search games` and stays prerendered; with the flag off, `/search?type=game` lights SHOWS and the logo goes to `/shows`; `/watchlist?type=game` searches games; the fallback on `/games` reads `Search games`, verifies **AC-15**, **AC-20**. +- Navbar: with the flag on, `/games/top-rated` shows `Search games` and stays prerendered; with the flag off, `/search?type=game` lights SHOWS and the logo goes to `/shows`; `/watchlist?type=game` searches games; the shell on `/games` reads `Search games`, verifies **AC-15**, **AC-20**. - Auth/permission: a signed in request to `/api/search?type=game&q=zelda` carries no `Set-Cookie` and the same body as a signed out one; the request scope test for `/search?type=game` finds no session read, verifies **AC-17**, **AC-20**. ## Build plan From 60dafc787d2be3a3cd03053895b157604fdeb7f1 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 26 Oct 2026 11:04:22 +0100 Subject: [PATCH 22/22] refactor(search): drop the redundant tv default from the navbar fallback Co-Authored-By: Claude Opus 5.5 --- components/search/navbar-search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/search/navbar-search.tsx b/components/search/navbar-search.tsx index 9ea0df28..213c138d 100644 --- a/components/search/navbar-search.tsx +++ b/components/search/navbar-search.tsx @@ -34,7 +34,7 @@ type Layout = "desktop" | "mobile"; function NavbarSearch({ layout }: { layout: Layout }) { return ( } + fallback={() => } > {({ pathname, libraryType }) => (