diff --git a/AGENTS.md b/AGENTS.md index 680c73f4..32b11eaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,8 +319,8 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke - Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, and TMDB's notice only in `TMDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if either is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` is TMDB's official file, hash checked; never edit it. - Production runs from [docs/deploy.md](docs/deploy.md), the runbook of spec 0018: push a migration with `supabase db push` before merging the PR that adds it, and auth settings reach the cloud only through `supabase config push` (never the dashboard). Run both pushes yourself; from an agent shell the CLI applies them without stopping at the prompt. `vercel.json` pins functions to `fra1`, next to the Supabase region `eu-central-1`. The `/api/search` rate limit is a Vercel Firewall rule, recorded only in `docs/deploy.md`. - `NEXT_PUBLIC_AUTH_EMAIL_DELIVERY` (`on` or `off`, unset meaning `off`) is read only through `getAuthEmailDelivery()` in `lib/env.ts`, separate from `getPublicEnv()` so previews with no Supabase variables still build. Production is `off`: no auth email is sent or promised. Being a `NEXT_PUBLIC_` value, a change takes a redeploy. -- `NEXT_PUBLIC_GAMES_LIBRARY` (`on` or `off`, unset meaning `off`) gates the games tabs (spec 0027). It is read only in `lib/env.ts`, through `getGamesLibrary()` (throws on an invalid value; only `LibrarySection` calls it) and `gamesLibraryEnabled()` (an invalid value reads as off; the navbar uses it). Production leaves it unset, Preview and `.env.local` set `on` (`docs/deploy.md`). -- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and the `typedHref` overloads accept it only on `/watchlist`, `/upcoming` and `/watched`, so `/search` cannot receive it until G7 widens the type on purpose. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit. +- `NEXT_PUBLIC_GAMES_LIBRARY` (`on` or `off`, unset meaning `off`) gates the games tabs (spec 0027). It is read only in `lib/env.ts`, through `getGamesLibrary()` (throws on an invalid value; `LibrarySection` calls it, and `/search` and `/api/search` call it only for a raw `type=game`, so a bad value never breaks a show or movie search, spec 0029) and `gamesLibraryEnabled()` (an invalid value reads as off; the navbar uses it). Production leaves it unset, Preview and `.env.local` set `on` (`docs/deploy.md`). +- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and `typedHref` accept it on `/watchlist`, `/upcoming`, `/watched` and, since G7, `/search` (spec 0029); every TMDB read still takes `MediaType`, so `game` is narrowed away before one, and the title pages and the proxy never receive it. G6 widened `CATALOG_PATHS`, `CATALOG_SOURCES`, the list registry and the proxy's list slug rule to `game` (spec 0028). A games tab reads its stored status, not a classification: one counted `user_game_state` page of 24 plus one `getGamesByIds` batch for those ids (`lib/tracking/game-library.ts`), with no 500 title limit. ## Code conventions diff --git a/app/api/search/route.test.ts b/app/api/search/route.test.ts index 3d5fa1ae..ab77ddf2 100644 --- a/app/api/search/route.test.ts +++ b/app/api/search/route.test.ts @@ -1,16 +1,26 @@ import { NextRequest } from "next/server"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { IgdbError, IgdbValidationError } from "@/lib/igdb/errors"; import { TmdbError } from "@/lib/tmdb/errors"; -/** covers: spec 0010, AC-20 */ +/** covers: spec 0010, AC-20; spec 0029, AC-17 */ const readSearchPage = vi.hoisted(() => vi.fn()); +const searchGames = vi.hoisted(() => vi.fn()); +const games = vi.hoisted(() => ({ library: "on" as "on" | "off" })); vi.mock("@/lib/search/read", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readSearchPage }; }); +vi.mock("@/lib/igdb", async () => { + const errors = await import("@/lib/igdb/errors"); + return { ...errors, searchGames }; +}); +vi.mock("@/lib/env", () => ({ + getGamesLibrary: vi.fn(() => games.library), +})); const { GET } = await import("./route"); @@ -35,6 +45,8 @@ const PAGE = { afterEach(() => { readSearchPage.mockReset(); + searchGames.mockReset(); + games.library = "on"; }); describe("GET /api/search", () => { @@ -56,7 +68,7 @@ describe("GET /api/search", () => { title: "Dune 1", year: 2024, posterUrl: null, - tmdbRating: 7.2, + rating: 7.2, href: "/shows/1", }); }); @@ -128,3 +140,65 @@ describe("GET /api/search", () => { expect(await signedIn.json()).toEqual(await signedOut.json()); }); }); + +describe("GET /api/search for games (spec 0029, AC-17)", () => { + const GAMES = { + page: 1, + hasNextPage: true, + totalResults: null, + results: Array.from({ length: 8 }, (_, index) => ({ + id: index + 1, + name: `Zelda ${index + 1}`, + coverUrl: null, + releaseYear: index === 0 ? null : 1998, + communityRating: 9.1, + })), + }; + + it("answers five games from search page 1 with IGDB's count or null", async () => { + searchGames.mockResolvedValue(GAMES); + + const response = await GET(request("type=game&q=zelda")); + + expect(response.status).toBe(200); + expect(searchGames).toHaveBeenCalledWith({ query: "zelda", page: 1 }); + expect(readSearchPage).not.toHaveBeenCalled(); + const body = await response.json(); + expect(body.totalResults).toBeNull(); + expect(body.results).toHaveLength(5); + expect(body.results[0]).toEqual({ + id: 1, + title: "Zelda 1", + year: null, + posterUrl: null, + rating: 9.1, + href: "/games/1", + }); + }); + + it("answers 400 with the games library off, without asking IGDB", async () => { + games.library = "off"; + const response = await GET(request("type=game&q=zelda")); + expect(response.status).toBe(400); + expect(searchGames).not.toHaveBeenCalled(); + }); + + it("answers 400, never 500, for a query IGDB's cleaning refuses", async () => { + searchGames.mockRejectedValue( + new IgdbValidationError("query", "The search query is empty."), + ); + const response = await GET(request("type=game&q=%01%01")); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid" }); + }); + + it("answers 502 with the kind when IGDB fails", async () => { + searchGames.mockRejectedValue(new IgdbError("timeout", "games", "slow")); + const response = await GET(request("type=game&q=zelda")); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: "upstream", + kind: "timeout", + }); + }); +}); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 1b2eb413..dab9c08c 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,5 +1,8 @@ import { type NextRequest, NextResponse } from "next/server"; +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"; @@ -22,31 +25,47 @@ const SUCCESS_CACHE = "public, s-maxage=300, stale-while-revalidate=600"; * input is validated before TMDB is asked, and the read goes through the * cached module read, so a popular query costs one TMDB request per cache * lifetime. Errors are never cached. + * + * `type=game` is accepted only while the games library is on, and the flag is + * read only for that raw value; it reads the cached IGDB search page 1 at the + * default 20 and returns its first five games (spec 0029, AC-17). A query + * IGDB's cleaning refuses answers 400, never 500. */ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; - const parsed = quickSearchRequestSchema.safeParse({ - type: searchParams.get("type"), - q: searchParams.get("q"), - }); - if (!parsed.success) { - return NextResponse.json( - { error: "invalid" }, - { status: 400, headers: { "Cache-Control": "no-store" } }, - ); - } + const rawType = searchParams.get("type"); + const parsed = quickSearchRequestSchema({ + gamesEnabled: gameSearchEnabled(rawType), + }).safeParse({ type: rawType, q: searchParams.get("q") }); + if (!parsed.success) return invalid(); const { type, q } = parsed.data; try { - const page = await readSearchPage(type, q, { page: 1 }); - return NextResponse.json(toQuickSearchResponse(type, page), { + const body = + type === "game" + ? toGameQuickSearchResponse(await searchGames({ query: q, page: 1 })) + : toQuickSearchResponse( + type, + await readSearchPage(type, q, { page: 1 }), + ); + return NextResponse.json(body, { headers: { "Cache-Control": SUCCESS_CACHE }, }); } catch (error) { - if (!(error instanceof TmdbError)) throw error; + if (error instanceof IgdbValidationError) return invalid(); + if (!(error instanceof TmdbError || error instanceof IgdbError)) { + throw error; + } return NextResponse.json( { error: "upstream", kind: error.kind }, { status: 502, headers: { "Cache-Control": "no-store" } }, ); } } + +function invalid() { + return NextResponse.json( + { error: "invalid" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/movies/request-scope.test.ts b/app/movies/request-scope.test.ts index 9c8d875d..ca772cfe 100644 --- a/app/movies/request-scope.test.ts +++ b/app/movies/request-scope.test.ts @@ -134,9 +134,9 @@ describe("the show routes read no request scoped value (spec 0009, AC-18; spec 0 }); /** - * covers: spec 0010, AC-20, AC-22 + * covers: spec 0010, AC-20, AC-22; spec 0029, AC-20 * - * Search is public. The page, the Route Handler and every search piece reach + * Search is public, games included. The page, the Route Handler and every search piece reach * for no request API and no Supabase client, and declare no cache scope of * their own; the only session read is each movie card's bookmark, inside its * own Suspense boundary, exactly as on `/movies`. @@ -154,6 +154,24 @@ describe("search reads no request scoped value (spec 0010, AC-22)", () => { expect(files).toContain(join("app/api/search/route.ts")); expect(files).toContain(join("components/search/search-results.tsx")); expect(files).toContain(join("lib/search/scan.ts")); + // spec 0029, AC-20: the games branch is held to the same rule. + expect(files).toContain(join("lib/search/game-read.ts")); + }); + + it("reads IGDB only through the module's entry point (spec 0029, AC-20)", () => { + for (const path of files) { + const source = readFileSync(path, "utf8"); + const deep = [...source.matchAll(/from "@\/lib\/igdb\/([\w-]+)"/g)].map( + (match) => match[1], + ); + // Only the pure helpers outside the module may be read directly. + for (const file of deep) { + expect( + ["constants", "query", "errors"], + `${path} imports ${file}`, + ).toContain(file); + } + } }); it.each(files)("%s reaches for none of them", (path) => { diff --git a/app/showcase/game-search-demo.tsx b/app/showcase/game-search-demo.tsx new file mode 100644 index 00000000..3c3f6559 --- /dev/null +++ b/app/showcase/game-search-demo.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; + +import { PlatformList, PlatformPicker } from "@/components/search/filter-bar"; +import type { CatalogRef } from "@/lib/search/params"; + +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" }, + { id: 169, name: "Xbox Series X|S" }, +]; + +/** The open popover's look, so the list reads as it does in the bar. */ +const PANEL = + "glass glass-rim glass-plate-float glass-shadow w-[min(26rem,calc(100vw-2rem))] rounded-panel p-4 text-sm text-foreground"; + +/** One open platform list, its filter already typed. */ +function OpenList({ initial }: { initial: string }) { + const [filter, setFilter] = useState(initial); + const [selected, setSelected] = useState([6]); + return ( +
+ + setSelected((ids) => + ids.includes(id) + ? ids.filter((value) => value !== id) + : [...ids, id], + ) + } + /> +
+ ); +} + +/** + * The games platform picker's states (spec 0029, AC-21), for `/showcase`: open + * with a filter typed (PC stays ticked though the filter hides it), with a + * filter that matches nothing, and disabled when IGDB's list failed. A client + * module because the list takes handlers. + */ +export function GameSearchDemo() { + return ( +
+ + + {}} /> +
+ ); +} diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index ac81bc58..65b763e8 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -49,6 +49,7 @@ import { PersonalScoreBadge, TmdbRatingBadge, } from "@/components/rating-badges"; +import { QuickSearchRow } from "@/components/search/quick-search-row"; import { PosterCardSkeleton, Skeleton } from "@/components/skeleton"; import { StatePanel } from "@/components/state-panel"; import { @@ -74,6 +75,7 @@ import { TMDB_HOME_URL } from "@/lib/legal/operator"; import { CardRoundButtonDemo } from "./card-round-button-demo"; import { GameLibraryCardDemo } from "./game-library-card-demo"; +import { GameSearchDemo } from "./game-search-demo"; export const metadata: Metadata = { title: "Design system", @@ -614,6 +616,42 @@ export default function ShowcasePage() { +
+
+ +
+ {[ + { + id: 1, + title: "A game with a cover", + year: 2017, + posterUrl: LIGHT_POSTER, + rating: 9.1, + href: "/games/1", + }, + { + id: 2, + title: "A game with no cover or year", + year: null, + posterUrl: null, + rating: null, + href: "/games/2", + }, + ].map((result) => ( +
+ +
+ ))} +
+
+
+
`, not `next/link`, so the client router cache cannot replay the failure the visitor is retrying past (spec 0006, AC-10). - **Focus** is a 2px `#F6F7F8` outline at 2px offset on `:focus-visible` only, driven by `--ring` in the base layer. Do not restate it per component. - **Backdrop blur** comes with the plate utilities (`--blur-control`, `--blur-panel`, `--blur-overlay`, no saturation), so never add `backdrop-blur-*` to a frosted surface yourself. `--blur-glass` (16px) remains for the dialog backdrops and the poster badge slots. Only the outermost large surface frosts: a highlighted row or panel inside a panel takes no plate class of its own. Small controls inside a panel keep theirs; blur cost follows area, and a 22 episode season page held 120fps while scrolling over the animated background. 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/layout/media-type-tabs.test.tsx b/components/layout/media-type-tabs.test.tsx index b178ac19..6ca256e8 100644 --- a/components/layout/media-type-tabs.test.tsx +++ b/components/layout/media-type-tabs.test.tsx @@ -178,7 +178,7 @@ describe("MediaTypeTabs", () => { ); // spec 0028, AC-14: GAMES on every page, leading to /games. - it.each(["/search", "/shows", "/movies/550"])( + it.each(["/shows", "/movies/550"])( "adds GAMES to /games on %s, unlit", (path) => { pathname.value = path; @@ -191,6 +191,33 @@ describe("MediaTypeTabs", () => { }, ); + // spec 0029, AC-14: on /search, GAMES switches the type in place. + it("switches /search to games in place, keeping what games can take", () => { + pathname.value = "/search"; + search.value = "type=movie&q=dune&year=1940&genre=878"; + render(); + + expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute( + "href", + "/search?type=game&q=dune", + ); + }); + + it("lights GAMES on /search?type=game and keeps the query on a switch back", () => { + pathname.value = "/search"; + search.value = "type=game&q=zelda&platform=130"; + render(); + + expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "MOVIES" })).toHaveAttribute( + "href", + "/search?type=movie&q=zelda", + ); + }); + it.each(["/games", "/games/top-rated", "/games/1942"])( "lights GAMES on %s, with the catalogs one click away", (path) => { @@ -279,7 +306,7 @@ describe("MediaTypeTabs", () => { ); }); - it("gives the /search fallback a GAMES link to /games (spec 0028, AC-14)", async () => { + it("gives the /search fallback a GAMES link that stays on /search (spec 0029, AC-14)", async () => { vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); suspendSearch.value = true; pathname.value = "/search"; @@ -290,7 +317,7 @@ describe("MediaTypeTabs", () => { expect(screen.getAllByRole("link")).toHaveLength(3); expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute( "href", - "/games", + "/search?type=game", ); }); }); diff --git a/components/layout/media-type-tabs.tsx b/components/layout/media-type-tabs.tsx index 030d4b50..02145d47 100644 --- a/components/layout/media-type-tabs.tsx +++ b/components/layout/media-type-tabs.tsx @@ -5,7 +5,6 @@ import Link from "next/link"; import { CATALOG_PATHS, - isLibraryPath, type LibraryType, type MediaType, type TypedPath, @@ -27,8 +26,8 @@ import { WithLibraryTypedPage } from "./page-media-type"; * cannot. * * On `/watchlist`, `/upcoming`, `/watched` and `/search` the tabs switch the - * page's own `type` parameter in place (GAMES leads from `/search` to - * `/games`); everywhere else they lead to `/shows`, `/movies` and `/games`. + * page's own `type` parameter in place, GAMES included (spec 0029, AC-14); + * everywhere else they lead to `/shows`, `/movies` and `/games`. * `WithLibraryTypedPage` reads the page's type, and on those four pages the * current query, which `/search` keeps across a switch. * @@ -71,9 +70,9 @@ function MediaTypeTabs({ className }: { className?: string }) { } /** - * Each tab's link on a typed page: the same page with its own `type`, and, - * while the games library is on, GAMES switching a library page's `type` in - * place or leading from `/search` to `/games` (spec 0028, AC-14). + * Each tab's link on a typed page: the same page with its own `type`, GAMES + * only while the games library is on (spec 0028, AC-14; spec 0029, AC-14). + * On `/search` each keeps the query, year and rating `typedHref` allows. */ function typedHrefs(pathname: TypedPath, current?: URLSearchParams): TabHrefs { const hrefs = { @@ -81,12 +80,7 @@ function typedHrefs(pathname: TypedPath, current?: URLSearchParams): TabHrefs { movie: typedHref(pathname, "movie", { current }), }; if (!gamesLibraryEnabled()) return hrefs; - return { - ...hrefs, - game: isLibraryPath(pathname) - ? typedHref(pathname, "game") - : CATALOG_PATHS.game, - }; + return { ...hrefs, game: typedHref(pathname, "game", { current }) }; } /** diff --git a/components/layout/navbar.test.tsx b/components/layout/navbar.test.tsx index a0b05829..c89368d0 100644 --- a/components/layout/navbar.test.tsx +++ b/components/layout/navbar.test.tsx @@ -161,10 +161,9 @@ describe("Navbar", () => { }, ); - // covers: spec 0027, AC-3, AC-4. A games tab has the GAMES tab lit, no - // search (there is no game search until G7), and a logo that never - // remembers `game`. - it("lights GAMES and shows no search on a games tab", () => { + // covers: spec 0027, AC-3; spec 0029, AC-15. A games tab has the GAMES tab + // lit and searches games. + it("lights GAMES and searches games on a games tab (spec 0029, AC-15)", () => { vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); window.localStorage.removeItem("bestats:last-media-type"); pathname.current = "/watched"; @@ -180,10 +179,10 @@ describe("Navbar", () => { "aria-current", "page", ); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Search" }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveAttribute( + "placeholder", + "Search games", + ); // spec 0028, AC-16: the logo follows a games tab and remembers it. expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute( "href", @@ -199,7 +198,7 @@ describe("Navbar", () => { // spec 0028, AC-14 to AC-16 it.each(["/games", "/games/coming-soon"])( - "lights GAMES on %s, with no search and the logo on /games", + "lights GAMES on %s, searching games, with the logo on /games", (path) => { vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); pathname.current = path; @@ -215,10 +214,10 @@ describe("Navbar", () => { "aria-current", "page", ); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Search" }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveAttribute( + "placeholder", + "Search games", + ); expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute( "href", "/games", @@ -231,6 +230,58 @@ describe("Navbar", () => { }, ); + // spec 0029, AC-15, AC-18 + it("searches games on /search?type=game, with the logo on /games", () => { + vi.stubEnv("NEXT_PUBLIC_GAMES_LIBRARY", "on"); + pathname.current = "/search"; + search.current = "type=game&q=zelda"; + render( + } + desktopAccountSlot={} + />, + ); + expect(screen.getByRole("combobox")).toHaveAttribute( + "placeholder", + "Search games", + ); + expect(screen.getByRole("link", { name: "GAMES" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute( + "href", + "/games", + ); + vi.unstubAllEnvs(); + window.localStorage.removeItem("bestats:last-media-type"); + pathname.current = "/shows"; + search.current = "type=movie"; + }); + + it("lights SHOWS and searches shows on /search?type=game with the flag off", () => { + pathname.current = "/search"; + search.current = "type=game"; + render( + } + desktopAccountSlot={} + />, + ); + expect(screen.getByRole("combobox")).toHaveAttribute( + "placeholder", + "Search shows", + ); + expect(screen.queryByRole("link", { name: "GAMES" })).toBeNull(); + expect(screen.getByRole("link", { name: "BeStats" })).toHaveAttribute( + "href", + "/shows", + ); + window.localStorage.removeItem("bestats:last-media-type"); + pathname.current = "/shows"; + search.current = "type=movie"; + }); + it("takes the type from the type parameter on /search", () => { pathname.current = "/search"; render( diff --git a/components/layout/page-media-type.tsx b/components/layout/page-media-type.tsx index d8de0761..4a83c32e 100644 --- a/components/layout/page-media-type.tsx +++ b/components/layout/page-media-type.tsx @@ -16,11 +16,11 @@ import { gamesLibraryEnabled } from "@/lib/env"; * page's media type, null on a page about neither catalog, and on a typed * page its current query. * - * `type` is the catalog, for the search; `libraryType` is what the tabs, the - * Library links and the logo follow, which on a games tab of a library page - * and on `/games` and below it is `game` while `type` is null (spec 0027, - * AC-4; spec 0028, AC-14 to AC-16): there is no game search until G7, so the - * search renders nothing there. + * `type` is the TMDB catalog, null on a games page; `libraryType` is what + * the tabs, the Library links, the search and the logo follow, which on a + * games tab of a library page or `/search`, and on `/games` and below it, is + * `game` while `type` is null (spec 0027, AC-4; spec 0028, AC-14 to AC-16; + * spec 0029, AC-15). */ type PageMediaType = | { @@ -37,9 +37,9 @@ type PageMediaType = }; /** - * The media type of the page being viewed, for `BrandLink`, through - * `WithTypedPage` for `NavbarSearch`, and through `WithLibraryTypedPage` for - * `MediaTypeTabs` and `LibraryNav` (feature 22; spec 0027). It holds the one rule they share: only on + * The media type of the page being viewed, for `BrandLink`, and through + * `WithLibraryTypedPage` for `MediaTypeTabs`, `LibraryNav` and + * `NavbarSearch` (feature 22; spec 0027; spec 0029). It holds the one rule they share: only on * a typed page is `useSearchParams` read, and only inside its own Suspense * boundary, because reading it anywhere else would pull `/shows`, `/movies` * and the title pages out of their prerendered shells (`AGENTS.md`, Commands @@ -78,38 +78,15 @@ function WithPageMediaType({ }); } -/** A page that has a media type, as `WithTypedPage` hands it on. */ -type TypedPageMediaType = PageMediaType & { type: MediaType }; - -/** - * `WithPageMediaType` for a control that exists only on a page with a type: - * the search renders nothing on a page about neither catalog - * (prompts/hide-tabs-without-type.md), nor on a games tab (spec 0027, AC-4). - * The logo keeps `WithPageMediaType`, because it still renders there. - */ -function WithTypedPage({ - children, - fallback, -}: { - children: (page: TypedPageMediaType) => ReactNode; - fallback: (pathname: TypedPath) => ReactNode; -}) { - return ( - - {(page) => - page.type === null ? null : children({ ...page, type: page.type }) - } - - ); -} - /** A page that has a library type, as `WithLibraryTypedPage` hands it on. */ type LibraryTypedPageMediaType = PageMediaType & { libraryType: LibraryType }; /** - * `WithTypedPage` for the tabs and the Library links, which also exist on a - * games tab and light `game` there (spec 0027, AC-3, AC-4). They render - * nothing on a page about neither catalog. + * `WithPageMediaType` for the tabs, the Library links and the search, which + * also exist on a games page and follow `game` there (spec 0027, AC-3, AC-4; + * spec 0029, AC-15). They render nothing on a page about neither catalog + * (prompts/hide-tabs-without-type.md); the logo keeps `WithPageMediaType`, + * because it still renders there. */ function WithLibraryTypedPage({ children, @@ -156,9 +133,4 @@ function TypedPageMediaType({ }); } -export { - type PageMediaType, - WithLibraryTypedPage, - WithPageMediaType, - WithTypedPage, -}; +export { type PageMediaType, WithLibraryTypedPage, WithPageMediaType }; diff --git a/components/search/filter-bar.test.tsx b/components/search/filter-bar.test.tsx index 814ae740..3b362fc1 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,163 @@ 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); + }); + + // 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); + 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..5c14b87a 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 { SEARCH_CATALOGS } from "@/lib/search/catalogs"; 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) => (