Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f674af6
docs(specs): add spec 0029 for game search and filters
kris1027 Oct 25, 2026
89e5ec7
feat(igdb): match genres as all of and add the game search options
kris1027 Oct 25, 2026
551a6db
refactor(catalog): share the games page slice and last page with search
kris1027 Oct 25, 2026
99d8734
feat(search): search games on /search with genre, platform, year and …
kris1027 Oct 25, 2026
a0422c5
feat(search): search games from the navbar on every games page
kris1027 Oct 25, 2026
5698bf4
feat(showcase): add the game search platform picker and quick search …
kris1027 Oct 25, 2026
37d630a
docs(scope): tick the game search and filters build
kris1027 Oct 25, 2026
91579b0
docs(specs): add the verify steps for spec 0029
kris1027 Oct 25, 2026
a9330db
docs(scope): tick verify for the game search and filters
kris1027 Oct 26, 2026
394cd58
test(search): cover the games results, quick search and count line
kris1027 Oct 26, 2026
df3e5a4
docs(scope): tick test for the game search and filters
kris1027 Oct 26, 2026
611439a
docs(scope): mark the game search and filters done
kris1027 Oct 26, 2026
abf99c1
docs(agents): sync the game search conventions
kris1027 Oct 26, 2026
4837158
refactor(search): check games queries without importing the IGDB quer…
kris1027 Oct 26, 2026
afa44ee
refactor(search): gather per catalog search rules in one map
kris1027 Oct 26, 2026
f31af3e
refactor(search): read the games search flag in one place
kris1027 Oct 26, 2026
b9d9b39
refactor(search): narrow parsed search params instead of casting
kris1027 Oct 26, 2026
e0ac2e5
test(images): check the quick search cover size against the IGDB ladder
kris1027 Oct 26, 2026
bc09fb7
test(search): cover the text and list pauses firing in either order
kris1027 Oct 26, 2026
dd3df23
docs(igdb): name every pure importer of the IGDB constants
kris1027 Oct 26, 2026
91a2f2a
docs(specs): match spec 0029 AC-15 and AC-16 to the shipped navbar an…
kris1027 Oct 26, 2026
60dafc7
refactor(search): drop the redundant tv default from the navbar fallback
kris1027 Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 76 additions & 2 deletions app/api/search/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/search/read")>();
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");

Expand All @@ -35,6 +45,8 @@ const PAGE = {

afterEach(() => {
readSearchPage.mockReset();
searchGames.mockReset();
games.library = "on";
});

describe("GET /api/search", () => {
Expand All @@ -56,7 +68,7 @@ describe("GET /api/search", () => {
title: "Dune 1",
year: 2024,
posterUrl: null,
tmdbRating: 7.2,
rating: 7.2,
href: "/shows/1",
});
});
Expand Down Expand Up @@ -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",
});
});
});
45 changes: 32 additions & 13 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" } },
);
}
22 changes: 20 additions & 2 deletions app/movies/request-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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) => {
Expand Down
57 changes: 57 additions & 0 deletions app/showcase/game-search-demo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={PANEL}>
<PlatformList
platforms={PLATFORMS}
selected={selected}
filter={filter}
onFilter={setFilter}
onToggle={(id) =>
setSelected((ids) =>
ids.includes(id)
? ids.filter((value) => value !== id)
: [...ids, id],
)
}
/>
</div>
);
}

/**
* 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 (
<div className="flex flex-wrap items-start gap-6">
<OpenList initial="switch" />
<OpenList initial="zzz" />
<PlatformPicker platforms={null} selected={[]} onToggle={() => {}} />
</div>
);
}
38 changes: 38 additions & 0 deletions app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -614,6 +616,42 @@ export default function ShowcasePage() {
</div>
</Section>

<Section
title="Game search"
note="/search?type=game (spec 0029): the platform picker open with a filter typed, a ticked platform the filter hides staying ticked, the No platforms match state, and the picker disabled when IGDB's platform list failed. Below, games quick search rows with a cover and without one, rated by IGDB's community rating."
>
<div className="flex flex-col gap-8">
<GameSearchDemo />
<div className="glass glass-rim glass-plate-float glass-shadow flex w-full max-w-[28rem] flex-col gap-1 rounded-panel p-2">
{[
{
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) => (
<div
key={result.id}
className="flex items-center gap-4 px-3 py-2"
>
<QuickSearchRow result={result} type="game" />
</div>
))}
</div>
</div>
</Section>

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