Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
424b845
docs(games): add spec 0032 and record the IGDB terms check
kris1027 Oct 26, 2026
126dfc5
fix(igdb): cache the genre and platform lists for hours, inside Twitc…
kris1027 Oct 26, 2026
0619329
feat(seo): index a found game page with its canonical, share card and…
kris1027 Oct 26, 2026
a25a898
feat(seo): list the games landing, its list pages and 200 popular gam…
kris1027 Oct 26, 2026
30018f6
feat(seo): give /games and the five games list pages canonicals and s…
kris1027 Oct 26, 2026
90d9360
feat(legal): credit IGDB with its logo in the footer and on /terms
kris1027 Oct 26, 2026
58af908
feat(games): remove the games library flag, so games are always on
kris1027 Oct 26, 2026
0eb95bc
docs(deploy): list the IGDB credentials for Production and Preview, d…
kris1027 Oct 26, 2026
3c428ff
docs(games): tick the G10 milestones that landed
kris1027 Oct 26, 2026
5f95fdb
docs(games): add the spec 0032 verify steps
kris1027 Oct 26, 2026
9954412
docs(games): amend spec 0032 AC-2 to allow the default site card on n…
kris1027 Oct 26, 2026
f8cb400
test(games): keep the visitor region out of the games pages
kris1027 Oct 27, 2026
e57a8ef
docs(games): tick Test it for G10
kris1027 Oct 27, 2026
d619933
docs(games): tick the spec 0032 verify steps that passed
kris1027 Oct 27, 2026
bff5a10
docs(games): tick Document it for G10
kris1027 Oct 27, 2026
d74f6e4
fix(legal): date the policy pages to the PR's final commit
kris1027 Oct 27, 2026
cb2f1d6
refactor(seo): dedupe sitemap ids from a flat list and rewrap its com…
kris1027 Oct 27, 2026
93c2aec
docs(seo): record the games metadata, JSON-LD, sitemap and IGDB credit
kris1027 Oct 27, 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
8 changes: 0 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,6 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# it on only together with enable_confirmations = true and an SMTP provider.
NEXT_PUBLIC_AUTH_EMAIL_DELIVERY=off

# Optional. on or off; unset or empty means off. on adds a GAMES tab to
# /watchlist, /upcoming and /watched and accepts ?type=game there (spec 0027).
# Production leaves it unset until people have a way to find games; set it on
# in .env.local and in Vercel Preview. Public on purpose: it only decides
# whether the tab renders, never who may read what. Inlined at build time, so a
# change needs a redeploy.
NEXT_PUBLIC_GAMES_LIBRARY=on

# ---------------------------------------------------------------------------
# Deployment (set by the host, not by you)
# ---------------------------------------------------------------------------
Expand Down
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ Games are a third media type, planned in [docs/scope/games.md](docs/scope/games.
- **Ratings:** a personal score from 1 to 10, only on Playing or Finished. Moving back to Want to play clears it, with Undo restoring status, score and timestamps; Playing to Finished and back keeps it. IGDB's community rating shows out of 10 and is always labelled as the community rating.
- **Timestamps:** added, started (the first time a game becomes Playing) and finished (every time it becomes Finished). Want to play sorts by added, Playing by started, Finished by finished.
- **Not tracked:** platform played on, hours played, 100% completion.
- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. Since G6 the GAMES tab shows on every page while `NEXT_PUBLIC_GAMES_LIBRARY` is `on` (spec 0028), and `/games` is the games landing with five paged list pages (`/games/{slug}`); game pages reach the sitemap only in G10.
- **Navigation:** SHOWS | MOVIES | GAMES with `?type=game`; for games `/watchlist` is Want to play, `/upcoming` Playing, `/watched` Finished. The GAMES tab shows on every page (spec 0028; the flag that gated it was removed in G10, spec 0032), and `/games` is the games landing with five paged list pages (`/games/{slug}`). Since G10 game pages, `/games` and its list pages are indexable and in the sitemap (spec 0032).

## Build approach

Expand Down Expand Up @@ -316,11 +316,10 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke
- Images render through `next/image` and the one `loaderFile`, `lib/images/loader.ts`, which sends a TMDB URL to `lib/tmdb/image-loader.ts` and an IGDB cover to `lib/images/igdb-loader.ts` (spec 0025). The optimizer is closed: no `remotePatterns` and an empty `localPatterns`, so `/_next/image` fetches only the static import folders Next always appends (`/_next/static/media/**`), which hold no images (prompts/close-image-optimizer.md). Never add a remote pattern, and never import an image file into a component (that would open the optimizer to it); give a new host its own loader.
- The visitor's country (Where to watch, the age rating chip) comes only from `getVisitorRegion()` in `lib/region.ts`, which reads Vercel's `x-vercel-ip-country` header with a `US` fallback (spec 0022). Call it only inside a streamed Suspense boundary, never in a page shell, a cached scope or an SEO surface, and never store, log or send it to TMDB.
- The navbar is transparent (no background, border or blur) and not sticky, so it scrolls away with the page; every control in it is 40px tall at every width, with the `hit-area` utilities keeping a 44px tap target. The footer is borderless. See [components/AGENTS.md](components/AGENTS.md).
- 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.
- Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, TMDB's notice only in `TMDB_ATTRIBUTION` and IGDB's credit only in `IGDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if any 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` and `public/igdb-logo.svg` are TMDB's and IGDB's official files, each hash checked by its own test; never edit either.
- 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; `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.
- `game` is a `LibraryType` (`MediaType | "game"`), never a `MediaType`: `parseLibraryTypeParam` and `typedHref` always accept it on `/watchlist`, `/upcoming`, `/watched` and, since G7, `/search` (spec 0029; no flag since spec 0032); 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
14 changes: 1 addition & 13 deletions app/api/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ 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; spec 0029, AC-17 */
/** covers: spec 0010, AC-20; spec 0029, AC-17; spec 0032, AC-12 */

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")>();
Expand All @@ -18,9 +17,6 @@ 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 @@ -46,7 +42,6 @@ const PAGE = {
afterEach(() => {
readSearchPage.mockReset();
searchGames.mockReset();
games.library = "on";
});

describe("GET /api/search", () => {
Expand Down Expand Up @@ -176,13 +171,6 @@ describe("GET /api/search for games (spec 0029, AC-17)", () => {
});
});

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."),
Expand Down
14 changes: 6 additions & 8 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ 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 @@ -26,17 +25,16 @@ const SUCCESS_CACHE = "public, s-maxage=300, stale-while-revalidate=600";
* 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
* `type=game` 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 rawType = searchParams.get("type");
const parsed = quickSearchRequestSchema({
gamesEnabled: gameSearchEnabled(rawType),
}).safeParse({ type: rawType, q: searchParams.get("q") });
const parsed = quickSearchRequestSchema.safeParse({
type: searchParams.get("type"),
q: searchParams.get("q"),
});
if (!parsed.success) return invalid();

const { type, q } = parsed.data;
Expand Down
6 changes: 2 additions & 4 deletions app/catalog-lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ describe.each([
const source = readFileSync(join(dir, slug, "page.tsx"), "utf8");
expect(source).toContain(`catalogList("${mediaType}", "${slug}")`);
expect(source).toContain("<CatalogListPage");
// spec 0028, AC-13: a games list is `noindex` and never reads `?page`.
expect(source).toContain(
mediaType === "game" ? "gameListMetadata(LIST)" : "catalogListMetadata(",
);
// spec 0032, AC-5: games lists share the TMDB lists' metadata flow.
expect(source).toContain("catalogListMetadata(");
});
});
132 changes: 110 additions & 22 deletions app/games/[id]/page.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { createElement, Fragment, type ReactElement, Suspense } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { JsonLd } from "@/components/catalog/json-ld";
import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton";
import { GameFacts } from "@/components/game/game-facts";
import { GameHero } from "@/components/game/game-hero";
import { GameMoreLikeThisSection } from "@/components/game/game-more-like-this";
import { GameOverview } from "@/components/game/game-overview";
import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";
import { gameJsonLd } from "@/lib/seo/json-ld";

/**
* covers: spec 0025, AC-1, AC-6, AC-7, AC-8, AC-9, AC-10; spec 0031, AC-1,
* AC-11
* AC-11; spec 0032, AC-1, AC-2, AC-3
*
* Until G10 every game page outcome is `noindex`, with no canonical or share
* card. `loadGame` is replaced, so the metadata and the streamed body branch
* A found game is indexable with a canonical, a share card and `VideoGame`
* JSON-LD; every other outcome stays `noindex` with none of them. `loadGame`
* is replaced, so the metadata and the streamed body branch
* on the same fake result, and `notFound` throws a marker the tests catch.
*/
const loadGame = vi.fn();
Expand All @@ -34,10 +37,20 @@ vi.mock("next/navigation", () => ({

const { default: GamePage, generateMetadata } = await import("./page");

const ORIGIN = "https://bestats.example";

const ART = "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg";
const SHOT = "https://images.igdb.com/igdb/image/upload/t_1080p/sc1.jpg";
const COVER =
"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co1wyy.jpg";

const GAME = {
name: "The Witcher 3: Wild Hunt",
releaseYear: 2015,
summary: "RPG and sequel to The Witcher 2 (2011).",
artUrl: ART,
screenshotUrls: [SHOT],
coverUrl: COVER,
};

function metadataFor(id: string) {
Expand All @@ -61,20 +74,74 @@ async function bodyFor(id: string) {
return render(detail.props);
}

afterEach(() => loadGame.mockReset());
beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN));

describe("a found game's metadata (AC-10)", () => {
it("is the name and year, the summary, and noindex with nothing else", async () => {
afterEach(() => {
loadGame.mockReset();
vi.unstubAllEnvs();
});

describe("a found game's metadata (spec 0025, AC-10; spec 0032, AC-1)", () => {
it("is indexable, with the name and year, the summary, its canonical and its art", async () => {
loadGame.mockResolvedValue({ kind: "found", game: GAME });

expect(await metadataFor("1942")).toEqual({
title: "The Witcher 3: Wild Hunt (2015)",
description: "RPG and sequel to The Witcher 2 (2011).",
robots: { index: false },
const metadata = await metadataFor("1942");

expect(metadata.title).toBe("The Witcher 3: Wild Hunt (2015)");
expect(metadata.description).toBe(
"RPG and sequel to The Witcher 2 (2011).",
);
expect(metadata.robots).toBeUndefined();
expect(metadata.alternates).toEqual({ canonical: `${ORIGIN}/games/1942` });
expect(metadata.openGraph).toMatchObject({
url: `${ORIGIN}/games/1942`,
type: "website",
siteName: "BeStats",
images: [ART],
});
expect(metadata.twitter).toMatchObject({
card: "summary_large_image",
images: [ART],
});
expect(loadGame).toHaveBeenCalledWith(1942);
});

it.each([
["the first screenshot without art", { artUrl: null }, SHOT],
[
"the cover without art or screenshots",
{ artUrl: null, screenshotUrls: [] },
COVER,
],
[
"the site card with no image at all",
{ artUrl: null, screenshotUrls: [], coverUrl: null },
`${ORIGIN}/opengraph-image`,
],
])("shares %s", async (_, override, image) => {
loadGame.mockResolvedValue({
kind: "found",
game: { ...GAME, ...override },
});

expect((await metadataFor("1942")).openGraph?.images).toEqual([image]);
});

it("builds the canonical from the id alone, never relative without a site URL", async () => {
vi.stubEnv("NEXT_PUBLIC_SITE_URL", undefined);
loadGame.mockResolvedValue({
kind: "found",
game: { ...GAME, artUrl: null, screenshotUrls: [], coverUrl: null },
});

const metadata = await metadataFor("1942");

expect(metadata.alternates).toBeUndefined();
expect(metadata.openGraph?.url).toBeUndefined();
expect(metadata.openGraph?.images).toBeUndefined();
expect(metadata.robots).toBeUndefined();
});

it("leaves the year and description out when IGDB has none", async () => {
loadGame.mockResolvedValue({
kind: "found",
Expand All @@ -99,18 +166,21 @@ describe("a found game's metadata (AC-10)", () => {
});
});

describe("a game page with nothing to show (AC-7, AC-8)", () => {
describe("a game page with nothing to show (AC-7, AC-8; spec 0032, AC-2)", () => {
it.each([
["not found", { kind: "not_found" }, "Game not found"],
["a failed IGDB read", { kind: "failed" }, "Game"],
])("is noindex for %s", async (_, result, title) => {
loadGame.mockResolvedValue(result);

expect(await metadataFor("1942")).toEqual({
title,
robots: { index: false },
});
});
])(
"is noindex with no canonical or share card for %s",
async (_, result, title) => {
loadGame.mockResolvedValue(result);

expect(await metadataFor("1942")).toEqual({
title,
robots: { index: false },
});
},
);

it("is noindex for a malformed id without asking IGDB (AC-6)", async () => {
expect(await metadataFor("abc")).toEqual({
Expand Down Expand Up @@ -160,8 +230,25 @@ describe("the streamed body", () => {
platformReleases: [],
};

/** The article's children: the hero first, then the sections in order. */
const sectionsOf = (article: Element) => article.props.children as Element[];
/**
* The article's children after its JSON-LD: the hero first, then the
* sections in order.
*/
const sectionsOf = (article: Element) =>
(article.props.children as Element[]).slice(1);

it("opens with the game's VideoGame JSON-LD, only in the found branch (spec 0032, AC-3)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });

const [script] = (await bodyFor("1942")).props.children as Element[];

expect(script.type).toBe(JsonLd);
expect(script.props.data).toEqual(gameJsonLd(FOUND));
expect(script.props.data).toMatchObject({
"@type": "VideoGame",
url: `${ORIGIN}/games/1942`,
});
});

it("hands the found game's facts to the hero and nothing it does not show (AC-1)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });
Expand Down Expand Up @@ -253,6 +340,7 @@ describe("the streamed body", () => {

const html = renderToStaticMarkup(await bodyFor("1942"));

expect(html).not.toContain("application/ld+json");
expect(html).toContain("Couldn&#x27;t reach IGDB");
expect(html).toContain("IGDB didn&#x27;t respond. Try again in a moment.");
expect(html).toMatch(/<a href="\/games\/1942"[^>]*>Try again<\/a>/);
Expand Down
Loading
Loading