From 93c27b630ccb3a7899d3c78b2f8da7532b39116f Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 27 Oct 2026 10:02:16 +0100 Subject: [PATCH 1/5] fix(seo): cap the sitemap's complete list at hours, inside IGDB's 24 hour cache rule The list now holds IGDB game ids, so a days lifetime outlived the Twitch Developer Services Agreement's 24 hour caching clause that spec 0032 AC-11 applied to every other IGDB read. Co-Authored-By: Claude Opus 5.5 --- app/sitemap.test.ts | 4 ++-- app/sitemap.ts | 9 +++++---- lib/seo/AGENTS.md | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index 0622d57..96915ae 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -214,12 +214,12 @@ describe("the sitemap on an indexable deployment", () => { } }); - it("keeps a complete list for days (AC-8)", async () => { + it("keeps a complete list for hours, inside the 24 hour IGDB cap (AC-8, spec 0032 AC-11)", async () => { happyReads(); await sitemap(); - expect(cacheLife.mock.calls).toEqual([["days"]]); + expect(cacheLife.mock.calls).toEqual([["hours"]]); }); it("drops only the pages whose read failed, and keeps it for minutes (AC-8)", async () => { diff --git a/app/sitemap.ts b/app/sitemap.ts index ecc2c72..3fe59c4 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -54,9 +54,10 @@ const LIST_PATHS = [ * list instead of failing the file (spec 0032, AC-7). `getPopularGames` * rebuilds its error outside its own cached scope, so `instanceof` holds * here. Any other rejection, a validation error included, is a bug and is - * rethrown. A complete list lives for days; a partial one for minutes, so the - * full list returns soon after TMDB or IGDB recovers (`lib/seo/AGENTS.md`, the - * sitemap cache rule). Only plain id arrays leave this scope (`AGENTS.md`, the + * rethrown. A complete list lives for hours, not days, because it holds IGDB + * ids and Twitch's terms cap a cached copy at 24 hours (spec 0032, AC-11); a + * partial one lives for minutes, so the full list returns soon after TMDB or + * IGDB recovers (`lib/seo/AGENTS.md`, the sitemap cache rule). Only plain id arrays leave this scope (`AGENTS.md`, the * cached scope rule). */ async function popularTitleIds(): Promise<{ @@ -100,7 +101,7 @@ async function popularTitleIds(): Promise<{ if (failed) { cacheLife("minutes"); } else { - cacheLife("days"); + cacheLife("hours"); } return { diff --git a/lib/seo/AGENTS.md b/lib/seo/AGENTS.md index fcd8caa..243ab74 100644 --- a/lib/seo/AGENTS.md +++ b/lib/seo/AGENTS.md @@ -22,7 +22,7 @@ How BeStats presents itself to search engines and link previews. Governed by [sp - Leave out any field whose TMDB or IGDB value is null, empty or zero. Never emit a rating of any kind in metadata or JSON-LD. - Serialize JSON-LD only through `jsonLdScript()`. It escapes `<`, U+2028 and U+2029, so TMDB or IGDB text cannot close the script tag. Render it only in a page's found branch. - `/robots.txt`, `/sitemap.xml` and `/opengraph-image` are prerendered, so `VERCEL_ENV` and `NEXT_PUBLIC_SITE_URL` are read when `pnpm build` runs, not per request. To check the indexable branch locally, build with `VERCEL_ENV=production` and a site URL set. -- The sitemap reads discover pages with the exact calls `/movies/popular` and `/shows/popular` make (page 1 is also each landing's Popular row), and IGDB's Popular list with the exact read `/games/popular` makes, so they share cache entries and every listed title is linked from a public page. A `TmdbError` drops only its page and an `IgdbError` only the game ids; any other error is rethrown. Its `cacheLife` is chosen once, after all reads settle: `days` when all succeed, `minutes` when any fail. +- The sitemap reads discover pages with the exact calls `/movies/popular` and `/shows/popular` make (page 1 is also each landing's Popular row), and IGDB's Popular list with the exact read `/games/popular` makes, so they share cache entries and every listed title is linked from a public page. A `TmdbError` drops only its page and an `IgdbError` only the game ids; any other error is rethrown. Its `cacheLife` is chosen once, after all reads settle: `hours` when all succeed (it holds IGDB ids, and Twitch caps a cached copy at 24 hours), `minutes` when any fail. - `app/opengraph-image.tsx` holds no colour literal (`design-tokens-boundary.test.ts`). It reads tokens from `globals.css` inside a cached scope, and a missing token fails the build. _Drafted by /sync from the introducing change, worth a quick human pass._ From 669dc521c83b4395136cc396830cbdfe21627bff Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 27 Oct 2026 10:02:49 +0100 Subject: [PATCH 2/5] docs(seo): say share images can come from IGDB as well as TMDB The game page passes IGDB art to catalogMetadata and shareMetadata, so the comments on their image fields no longer name TMDB alone. Co-Authored-By: Claude Opus 5.5 --- lib/seo/metadata.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/seo/metadata.ts b/lib/seo/metadata.ts index f9c0db0..77839af 100644 --- a/lib/seo/metadata.ts +++ b/lib/seo/metadata.ts @@ -7,7 +7,8 @@ export const SITE_NAME = "BeStats"; /** * The path Next.js serves the root `app/opengraph-image.tsx` at, the share - * card a page falls back to when it has no TMDB artwork (spec 0016, AC-10). + * card a page falls back to when it has no TMDB or IGDB artwork (spec 0016, + * AC-10). */ export const SITE_CARD_PATH = "/opengraph-image"; @@ -19,7 +20,7 @@ export type CatalogMetadataInput = { description: string | undefined; /** The page path, never with a query string except a list page's `?page=N`. */ path: string; - /** An absolute TMDB image URL, or null to use the site card. */ + /** An absolute TMDB or IGDB image URL, or null to use the site card. */ image: string | null; ogType: OgType; }; @@ -28,7 +29,7 @@ type OgType = "website" | "video.movie" | "video.tv_show"; /** * The absolute site card URL, or null without a site URL, so a page with no - * TMDB image and no site URL simply carries no `og:image` (spec 0016, AC-11; + * catalog image and no site URL simply carries no `og:image` (spec 0016, AC-11; * `lib/seo/AGENTS.md`, Rules). */ export function siteCardUrl(): string | null { @@ -58,7 +59,7 @@ export function shareMetadata({ /** The absolute canonical; omitted on pages that have none. */ url?: string; type?: OgType; - /** An absolute TMDB image URL, or null for the site card. */ + /** An absolute TMDB or IGDB image URL, or null for the site card. */ image?: string | null; }): Pick { const shareImage = image ?? siteCardUrl(); From fda428ae6348e2fe39c19740abf6d2dd35e1d896 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 27 Oct 2026 10:05:07 +0100 Subject: [PATCH 3/5] docs(games): record the G10 review and its last verify checks Co-Authored-By: Claude Opus 5.5 --- docs/reviews/2026-10-27-feat-games-launch.md | 36 ++++++++++++++++++++ docs/scope/games.md | 12 +++---- docs/specs/0032-games-launch/verify.md | 4 +-- 3 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 docs/reviews/2026-10-27-feat-games-launch.md diff --git a/docs/reviews/2026-10-27-feat-games-launch.md b/docs/reviews/2026-10-27-feat-games-launch.md new file mode 100644 index 0000000..7191ce2 --- /dev/null +++ b/docs/reviews/2026-10-27-feat-games-launch.md @@ -0,0 +1,36 @@ +# Review, feat/games-launch (PR #61, merged), 2026-10-27 + +**Reviewed by**: claude-sonnet-5-5 (author model not recorded) +**Scope**: 66 files, branch vs main (merge base 82102a1, head f2827c0) plus two uncommitted doc ticks (docs/scope/games.md, docs/specs/0032-games-launch/verify.md) +**Verdict**: Approve with nits + +## Summary +The change opens games to search engines (canonical, share card and `VideoGame` JSON-LD on the game page, `/games` and the five list pages, game ids in the sitemap with failure isolation), adds the hash-checked IGDB logo and the `IGDB_ATTRIBUTION` credit to the footer and `/terms`, shortens the genre and platform caches to the 24 hour limit, and removes the `NEXT_PUBLIC_GAMES_LIBRARY` flag everywhere. It follows spec 0032 closely and respects the repo's rules: metadata and sitemap read no request state, the not found and failed branches stay `noindex`, the credential and legal-fact boundaries are enforced by tests, and `lib/seo/` stays free of IGDB imports. No blockers or majors. One real inconsistency with the 24 hour caching rule the PR itself adopts (the sitemap's game ids), plus small documentation drift. + +## Minor +### 🟡 Sitemap keeps IGDB game ids for days, past the 24 hour cap the PR applies to genres, `app/sitemap.ts:101` +**Problem**: `popularTitleIds` is one cached scope that ends with `cacheLife("days")` when nothing failed. It now holds `gameIds` copied from IGDB's Popular list. The same PR moved the genre and platform reads from `max` to `hours` because the Twitch Developer Services Agreement caps a cached copy of IGDB data at 24 hours (AC-11), and the inner `getPopularGames` read is `hours`. +**Why it matters**: The outer scope outlives the inner one, so a derived copy of IGDB data (ids, and which games are visible/popular) can live well past 24 hours. The data is small and low sensitivity, so the practical risk is low, but the rule is applied unevenly and a hidden game could stay listed for days. +**Suggested fix**: Either record in the rationale why a list of bare ids is not "cached data" under the clause, or cap the outer scope at a lifetime of 24 hours or less when game ids are present (for example `hours` when the list is complete). Note a prerendered sitemap is also frozen until the next deploy, so say so in `lib/seo/AGENTS.md`. + +### 🟡 Share-image doc comments still say "TMDB image", `lib/seo/metadata.ts:22`, `:61` +**Problem**: `catalogMetadata`'s `image` is now also an IGDB art URL (`app/games/[id]/page.tsx` passes `artUrl ?? screenshotUrls[0] ?? coverUrl`), but the param docs and the "no TMDB artwork" wording say TMDB only. +**Why it matters**: A future reader may add a host allowlist or validation based on the stale comment, or conclude a non-TMDB URL is a bug. +**Suggested fix**: Reword the docs to "an absolute catalog image URL (TMDB or IGDB)". + +## Nits +- ⚪ `lib/seo/metadata.ts:114`, the "failed TMDB read" wording in the list-page doc is TMDB-only; games lists now follow the same rule through `IgdbError`. +- ⚪ `components/layout/site-footer.tsx:78-87`, the logo has `alt="IGDB"` and the link also shows the credit text, so a screen reader hears "IGDB Game data and images from IGDB.com". The spec asks for this (AC-10), so it is only worth a look if you want `alt=""` for the decorative logo inside a link that already has visible text. +- ⚪ `lib/search/params.ts` (`parseSearchParams`), now returns a wide `ParseResult` and is wrapped by two narrowing helpers; its JSDoc could say that callers should prefer the narrowed helpers. +- ⚪ `components/search/navbar-search.tsx:19-20`, the comment now wraps awkwardly after the flag text was removed ("games under `/games`, and the" on its own line). +- ⚪ Working tree: `docs/scope/games.md` still leaves "Review it" unticked, which is correct until this review is accepted; tick it afterwards. + +## Strengths +- The failure model of the sitemap is right: `Promise.allSettled`, only `IgdbError` drops the game ids, anything else (including `IgdbValidationError`) is rethrown, and `cacheLife` shortens to `minutes` on a partial result. Errors are rebuilt outside the cached scope so `instanceof` holds. +- Indexing is conservative: only a found, visible game gets a canonical and JSON-LD; not found, hidden and failed branches stay `noindex`, and the canonical is built from the id so a query string never reaches it. +- `catalogListMetadata` handles games' unknown last page explicitly (`hasResults` plus nullable `lastPage`) without changing the TMDB rule. +- Guard rails are encoded as tests: the logo's SHA-256, the one-home rule for `IGDB_ATTRIBUTION`, and a repo-wide test that fails if the removed flag name reappears outside `docs/`. +- Flag removal is complete and simplifies types (`quickSearchRequestSchema` is a plain constant, `TabHrefs` is `Record`, no more optional GAMES tab branch). + +## Test coverage +Configured (vitest). The new logic is covered: `gameJsonLd` (including omission of empty fields) in `lib/seo/json-ld.test.ts`; sitemap order, dedupe, 200 cap and IGDB failure isolation in `app/sitemap.test.ts` and `lib/seo/sitemap.test.ts`; game page metadata for found, hidden, failed and invalid ids in `app/games/[id]/page.test.ts`; list metadata results rule in `lib/landing-metadata.test.ts`; footer credit and logo hash; `/terms` content; request-scope tests for the new code. The flag removal updated every affected test and added the leftover-name guard. Not covered: the 24 hour behavior of the sitemap's game ids (no test asserts the outer `cacheLife` for a complete games list), and nothing checks that the share image URL is a real IGDB host, which is fine given the optimizer-independent `og:image`. I did not run the suite; I judged coverage from the diff. diff --git a/docs/scope/games.md b/docs/scope/games.md index ad973fc..bbc44c5 100644 --- a/docs/scope/games.md +++ b/docs/scope/games.md @@ -33,7 +33,7 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md | G7 | Game search and filters | Slice G4 | done | | G8 | Game cards everywhere | Slice G4 | done | | G9 | Richer game page | Slice G5 | done | -| G10 | Games launch | Slice G6 | in-progress | +| G10 | Games launch | Slice G6 | done | ## Foundations @@ -178,19 +178,19 @@ spec [0031](../specs/0031-richer-game-page/index.md) · code in `components/game ## Slice G6: Launch -### G10. Games launch · in-progress · GA +### G10. Games launch · done · GA Turn games on for search engines and production, with the credits IGDB asks for. **Done when:** game pages and list pages are in the sitemap with `VideoGame` JSON-LD and share cards; an `IGDB_ATTRIBUTION` sits next to `TMDB_ATTRIBUTION` in the footer and on `/terms` with `LEGAL_LAST_UPDATED` bumped; IGDB's current terms are checked again and the result recorded; the `IGDB_*` variables are set in Vercel and the change is deployed through [docs/deploy.md](../deploy.md). - [x] Design it (spec): `/architect games launch` -- [ ] Build it: `/develop games launch` +- [x] Build it: `/develop games launch` - [x] Terms check in a browser, recorded in the spec's rationale, plus the logo search, AC-10, AC-11 - [x] Thin thread: an indexable game page (canonical, share card, `VideoGame` JSON-LD) and its ids in the sitemap with failure isolation, AC-1 to AC-3, AC-6, AC-7, AC-14 - [x] `/games` and the five list pages indexed and in the sitemap, AC-4 to AC-6 - [x] IGDB credit in the footer and on `/terms`, `LEGAL_LAST_UPDATED` bumped, AC-8 to AC-10 - - [ ] Flag removed, Preview credentials and the deploy docs, then the production launch checks, AC-12, AC-13, AC-15 -- [ ] Verify it: `/check verify games launch` + - [x] Flag removed, Preview credentials and the deploy docs, then the production launch checks, AC-12, AC-13, AC-15 +- [x] Verify it: `/check verify games launch` - [x] Test it: `/test games launch` -- [ ] Review it (fresh model): `/check review games launch` +- [x] Review it (fresh model): `/check review games launch` - [x] Document it: `/document games launch` spec [0032](../specs/0032-games-launch/index.md) · code in `app/games/`, `app/sitemap.ts`, `lib/seo/`, `lib/legal/`, `components/layout/site-footer.tsx`, `app/terms/` diff --git a/docs/specs/0032-games-launch/verify.md b/docs/specs/0032-games-launch/verify.md index bb4e291..b1773f5 100644 --- a/docs/specs/0032-games-launch/verify.md +++ b/docs/specs/0032-games-launch/verify.md @@ -14,9 +14,9 @@ Build locally with `VERCEL_ENV=production NEXT_PUBLIC_SITE_URL=https://bestats.e - [x] Open `/sitemap.xml` → `/movies`, `/shows`, `/games`, five movie, five show, five games list pages, `/privacy`, `/terms`, movies, shows, then up to 200 `/games/{id}`; all absolute → AC-6 (Value sourcing: game ids, games list paths) - [x] Look at the footer on `/shows` at 1440px and 390px → TMDB logo and notice, then the IGDB logo and "Game data and images from IGDB.com" as one link to igdb.com in a new tab, logo no taller than TMDB's → AC-8, AC-10 - [x] Open `/terms` → section "Catalog data, TMDB and IGDB" ends with the three IGDB paragraphs in order; Last updated 26 October 2026 → AC-9 -- [ ] With no flag set anywhere: GAMES tab on `/shows`, `/watchlist?type=game` (signed in) shows Want to play, `/search?type=game&q=zelda` returns games, `GET /api/search?type=game&q=zelda` answers 200 → AC-12 +- [x] With no flag set anywhere: GAMES tab on `/shows`, `/watchlist?type=game` (signed in) shows Want to play, `/search?type=game&q=zelda` returns games, `GET /api/search?type=game&q=zelda` answers 200 → AC-12 - [x] Open a Preview deployment with `vercel curl /games/1942 --deployment ` → the game renders (credentials present in Preview) → AC-13 -- [ ] After merge, on production: `/sitemap.xml` lists games; `/games/1942` has canonical, `og:image`, `VideoGame` JSON-LD and no `noindex`; `/games` and `/games/popular` have canonicals; GAMES tab shows; validator.schema.org on `https://bestats-sable.vercel.app/games/1942` finds a `VideoGame` with zero errors → AC-15 +- [x] After merge, on production: `/sitemap.xml` lists games; `/games/1942` has canonical, `og:image`, `VideoGame` JSON-LD and no `noindex`; `/games` and `/games/popular` have canonicals; GAMES tab shows; validator.schema.org on `https://bestats-sable.vercel.app/games/1942` finds a `VideoGame` with zero errors → AC-15 ## Commands - [x] `pnpm test` → all green, including `app/sitemap.test.ts`, `lib/seo/sitemap.test.ts`, `lib/seo/json-ld.test.ts`, `lib/landing-metadata.test.ts`, `components/layout/igdb-logo.test.ts`, `security-boundary.test.ts` → AC-1 to AC-12, AC-14 From 53035005c8a95f2298461a93130652dcd2b27fe7 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 27 Oct 2026 10:05:07 +0100 Subject: [PATCH 4/5] docs(specs): amend 0032 AC-7 to cache a complete sitemap for hours and accept 0032 The sitemap holds IGDB ids, so a complete list lives hours to stay inside the 24 hour caching clause; 0016 notes the change to its AC-8. G10 is done, so 0032 moves to Accepted. Co-Authored-By: Claude Opus 5.5 --- docs/specs/0016-seo-metadata-and-sitemap/index.md | 1 + docs/specs/0032-games-launch/index.md | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/specs/0016-seo-metadata-and-sitemap/index.md b/docs/specs/0016-seo-metadata-and-sitemap/index.md index 30a046c..02663ca 100644 --- a/docs/specs/0016-seo-metadata-and-sitemap/index.md +++ b/docs/specs/0016-seo-metadata-and-sitemap/index.md @@ -2,6 +2,7 @@ **Date**: 2026-10-03 **Status**: Accepted +**Amended**: 2026-10-27. Since games joined the sitemap, a complete list lives `hours`, not `days` (AC-8), because it holds IGDB ids; see [spec 0032, AC-7](../0032-games-launch/index.md). A partial list still lives `minutes`. Scope feature: [17. SEO metadata and sitemap](../../scope/scope.md) · Beta tier diff --git a/docs/specs/0032-games-launch/index.md b/docs/specs/0032-games-launch/index.md index e14adb0..ab76107 100644 --- a/docs/specs/0032-games-launch/index.md +++ b/docs/specs/0032-games-launch/index.md @@ -1,10 +1,11 @@ # 0032. Games launch **Date**: 2026-10-26 -**Status**: In Progress +**Status**: Accepted **Amended**: 2026-10-26. AC-2 no longer says "no Open Graph or X fields". These pages keep the root layout's default site card (its `og:title`, `og:image` and X fields), as `/movies/{id}` does; what they never carry is anything game specific. Found by `/check verify`. +**Amended**: 2026-10-27. AC-7: a complete sitemap now lives `hours`, not `days`, so its IGDB ids stay inside the 24 hour caching clause (AC-11). A partial one still lives `minutes`. Found by `/check review` ([review](../../reviews/2026-10-27-feat-games-launch.md)). -Scope feature: [G10. Games launch](../../scope/games.md#g10-games-launch--in-progress--ga) · GA tier +Scope feature: [G10. Games launch](../../scope/games.md#g10-games-launch--done--ga) · GA tier ## Summary @@ -25,7 +26,7 @@ This spec opens games to everyone: search engines, link previews and production - **AC-4**: `/games` has the canonical `absoluteUrl("/games")` and the site card, and no `noindex`. `gamesMetadata({ title, description })` keeps its signature, now returns `landingMetadata({ title, description, path: "/games" })`, and is used only by `app/games/page.tsx`. Its title and description stay as they are. `gameListMetadata` and its tests are deleted. - **AC-5**: Each of the five games list pages (`trending`, `popular`, `recently-released`, `coming-soon`, `top-rated`) replaces its static `metadata` with `generateMetadata` calling `catalogListMetadata(list, searchParams)`, widened to take any `CatalogList` and reading the same `readTaggedCatalogGridPage(list, page)` call the page body makes (one shared cache entry, no extra IGDB request). `landingPageMetadata`'s `discover` callback changes from `{ totalPages }` to `{ hasResults: boolean; lastPage: number | null }`. Page N is indexed with the canonical `listPageHref(list, N)` when `hasResults` is true and either `lastPage` is null or N is at most `lastPage`. For a TMDB list `lastPage` is always its `totalPages` and `hasResults` is `page <= totalPages`, so TMDB lists behave exactly as today (their tests stay green unchanged). A games discover list with no IGDB count is therefore indexed on any page that shows games, up to IGDB's page 50 (past it the read answers empty). An invalid `?page`, an empty page, a page past the last, or an `IgdbError` or `TmdbError` is `noindex` with no canonical. Any other error, `IgdbValidationError` included, is rethrown, the existing TMDB rule. `app/catalog-lists.test.ts` expects `catalogListMetadata(` on the game list pages too. - **AC-6**: On an indexable deployment, `sitemap.xml` lists, in this order: `/movies`, `/shows`, `/games`; the movie list pages, the show list pages, then the five games list pages, each at page 1 in registry row order; `/privacy`, `/terms`; then every movie, every show, then every game. `sitemapEntries` gains a fifth argument, `gameIds: number[]`. The games are the first `GAME_SITEMAP_LIMIT` (200, exported from `lib/seo/sitemap.ts`) ids of `getPopularGames(POPULAR_GAME_LISTS.popular)`, the exact call `/games/popular` makes, deduplicated in rank order. That read returns visible games only, so no hidden game is listed. It joins the same `Promise.allSettled` as the 20 TMDB reads inside the existing cached scope, so it adds no wait. Every entry is absolute. A deployment that is not indexable still answers an empty `urlset` and asks neither TMDB nor IGDB anything. `app/sitemap.test.ts` replaces "lists no games URL before G10" with the new order, and `lib/seo/sitemap.test.ts` covers the new argument. -- **AC-7**: When the Popular read throws an `IgdbError` (any kind, including `unauthorized` for missing credentials) while the sitemap is built, only the game ids drop: the landings, every list page (games included), the legal pages, the movies and the shows stay, and the file's `cacheLife` is `minutes` instead of `days`, the same rule a TMDB failure follows. `getPopularGames` rethrows a rebuilt `IgdbError` outside its own cached scope, so `instanceof` holds inside the sitemap's scope. A rejection that is neither an `IgdbError` nor a `TmdbError`, an `IgdbValidationError` included, is rethrown and fails the build on purpose. +- **AC-7**: When the Popular read throws an `IgdbError` (any kind, including `unauthorized` for missing credentials) while the sitemap is built, only the game ids drop: the landings, every list page (games included), the legal pages, the movies and the shows stay, and the file's `cacheLife` is `minutes`, the same rule a TMDB failure follows. When every read succeeds the file lives `hours`, not the `days` spec 0016 set: it now holds IGDB ids, and the 24 hour caching clause AC-11 applies to IGDB data covers this copy too. `getPopularGames` rethrows a rebuilt `IgdbError` outside its own cached scope, so `instanceof` holds inside the sitemap's scope. A rejection that is neither an `IgdbError` nor a `TmdbError`, an `IgdbValidationError` included, is rethrown and fails the build on purpose. - **AC-8**: `IGDB_ATTRIBUTION` is `"Game data and images from IGDB.com"` (or the wording AC-11 records, if IGDB asks for different text). It is defined once, in `lib/legal/operator.ts` next to `IGDB_HOME_URL`. The footer shows it on every route beside TMDB's logo and notice, linking to `IGDB_HOME_URL` in a new tab (`noopener noreferrer`), with the footer's 44px mobile and 36px desktop tap areas. `lib/legal/boundary.test.ts` fails if the literal appears in any other file. - **AC-9**: `/terms` gains IGDB in its catalog section. The section title becomes `Catalog data, TMDB and IGDB`, and after the TMDB paragraphs it adds, in this order: "Game details and images come from IGDB. We can't guarantee that they are accurate or complete."; a paragraph rendering `IGDB_ATTRIBUTION`; "IGDB community ratings belong to IGDB. Your own ratings are yours." `LEGAL_LAST_UPDATED` is set to the date of the PR's final commit (the merge day can't be known in advance). `/privacy` keeps its existing IGDB image paragraph unchanged, since no processor changes. - **AC-10**: When the terms check (AC-11) finds an official IGDB logo file, it is saved byte for byte as `public/igdb-logo.svg` (or `.png` if that is the only official format), hash checked by a test like `tmdb-logo.test.ts`, and shown in the footer as the link, sized no taller than TMDB's logo, with `alt="IGDB"` and the attribution text beside it. With no official file, the footer shows the text credit alone as the link, and the result is recorded. @@ -100,7 +101,7 @@ Turn games on in one release that reuses `catalogMetadata`, `jsonLdScript`, the - Not found, hidden and failed games stay noindex with no JSON-LD, verifies **AC-2** - `gameJsonLd` drops empty genres, platforms, companies and a null date, and never emits `aggregateRating`, verifies **AC-3** - List metadata: page 1 with games indexed; page 3 of a list with no count but results indexed; an empty page and an `IgdbError` noindex, verifies **AC-5** -- Sitemap order with games, 200 game cap and dedupe; an `IgdbError` keeps everything but game ids and caches for minutes; a non indexable build is empty, verifies **AC-6**, **AC-7** +- Sitemap order with games, 200 game cap and dedupe; a complete list caches for hours; an `IgdbError` keeps everything but game ids and caches for minutes; a non indexable build is empty, verifies **AC-6**, **AC-7** - Footer and `/terms` render the IGDB credit; the boundary test fails on a pasted literal, verifies **AC-8**, **AC-9** - With no flag set, the GAMES tab shows and `?type=game` works on every library page and search; the no flag name test passes, verifies **AC-12** - Request scope tests cover the new metadata and JSON-LD, verifies **AC-14** From 659e00e7c89a049c93aaff3b0923b1dfd40f08fe Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 27 Oct 2026 10:08:44 +0100 Subject: [PATCH 5/5] docs(specs): say hours in the body of 0016 to match its amendment Co-Authored-By: Claude Opus 5.5 --- docs/specs/0016-seo-metadata-and-sitemap/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specs/0016-seo-metadata-and-sitemap/index.md b/docs/specs/0016-seo-metadata-and-sitemap/index.md index 02663ca..13c44ca 100644 --- a/docs/specs/0016-seo-metadata-and-sitemap/index.md +++ b/docs/specs/0016-seo-metadata-and-sitemap/index.md @@ -2,7 +2,7 @@ **Date**: 2026-10-03 **Status**: Accepted -**Amended**: 2026-10-27. Since games joined the sitemap, a complete list lives `hours`, not `days` (AC-8), because it holds IGDB ids; see [spec 0032, AC-7](../0032-games-launch/index.md). A partial list still lives `minutes`. +**Amended**: 2026-10-27. Since games joined the sitemap, a complete list lives `hours`, not `days` (AC-8), because it holds IGDB ids; see [spec 0032, AC-7](../0032-games-launch/index.md). A partial list still lives `minutes`. AC-8, the response table, the value sourcing table and build plan step 6 now say `hours` too. Scope feature: [17. SEO metadata and sitemap](../../scope/scope.md) · Beta tier @@ -34,7 +34,7 @@ This finishes how BeStats presents itself to search engines and to link previews - **AC-6**: On an indexable deployment, `/sitemap.xml` is a valid sitemap of absolute URLs, in this order: `/movies`, `/shows`, then `/movies/{id}` for every movie on `discoverMovies({ page })` pages 1 to 10, then `/shows/{id}` for every show on `discoverTvShows({ page })` pages 1 to 10, each in page order then result order. These are the same calls, with the same default options and popularity order, that the `/movies` and `/shows` landings make, so every listed title is linked from a public page. The sitemap relies on the discover module's existing adult exclusion and does no filtering of its own. Ids are deduplicated within each media type (first occurrence wins), so the file holds at most 402 URLs. An empty page, or a page past TMDB's `totalPages`, simply adds no ids. Entries carry no `lastModified`, `changeFrequency` or `priority`. - **AC-7**: The sitemap never lists `/search`, a season page, a `?page=` variant, `/showcase`, any private path (`/watchlist`, `/watched`, `/upcoming`, `/account`), any auth path, or any `/api/` or `/auth/` URL. -- **AC-8**: The 20 TMDB page reads run together through `Promise.allSettled` inside one `use cache` function. A read rejected with a `TmdbError` drops only that page's ids; the file still answers 200 with the landings and every id that did load. Any other rejection is rethrown, as `MoviesPage` does, because it is a bug rather than an outage. After all 20 settle, one `if`/`else` calls `cacheLife` exactly once: `"days"` when all 20 succeeded, `"minutes"` when any failed, so a full list returns soon after TMDB recovers. The sitemap's own entry is what lives a day. The inner discover entries keep their own `minutes` lifetime, so the sitemap reuses a landing's TMDB response only when both run within about an hour. +- **AC-8**: The 20 TMDB page reads run together through `Promise.allSettled` inside one `use cache` function. A read rejected with a `TmdbError` drops only that page's ids; the file still answers 200 with the landings and every id that did load. Any other rejection is rethrown, as `MoviesPage` does, because it is a bug rather than an outage. After all 20 settle, one `if`/`else` calls `cacheLife` exactly once: `"hours"` when all 20 succeeded, `"minutes"` when any failed, so a full list returns soon after TMDB recovers. The sitemap's own entry is what lives for hours (it was `"days"` until games joined the sitemap; see the amendment above). The inner discover entries keep their own `minutes` lifetime, so the sitemap reuses a landing's TMDB response only when both run within about an hour. - **AC-9**: On a deployment that is not indexable, `/sitemap.xml` returns `[]`, which renders as a valid, empty `urlset` (confirmed in the built output), and makes no TMDB request. *Shared page metadata* @@ -111,7 +111,7 @@ Reasoning and options: see [rationale.md](rationale.md). | Route | Output | Cache | Errors | |---|---|---|---| | `/robots.txt` | text, AC-4 or AC-5 | prerendered at build | none | -| `/sitemap.xml` | XML `urlset`, AC-6 to AC-9 | `days`, or `minutes` after any failed read | never non 200; failures shrink the list | +| `/sitemap.xml` | XML `urlset`, AC-6 to AC-9 | `hours`, or `minutes` after any failed read | never non 200; failures shrink the list | | `/opengraph-image` | 1200 by 630 PNG | prerendered at build | none | **Value sourcing**: @@ -121,7 +121,7 @@ Reasoning and options: see [rationale.md](rationale.md). | any URL field | the site origin | `NEXT_PUBLIC_SITE_URL` through `siteUrl()` | | robots, sitemap | indexable or not | `VERCEL_ENV` (set by Vercel per deployment) plus `siteUrl()` | | sitemap | movie ids, show ids | `discoverMovies({ page })`, `discoverTvShows({ page })` for pages 1 to 10, default options, the landings' calls | -| sitemap | cache lifetime | derived: all 20 reads ok → `days`, else `minutes` | +| sitemap | cache lifetime | derived: all 20 reads ok → `hours`, else `minutes` | | movie metadata | title, description | unchanged, `loadMovie` (spec 0006, AC-12) | | movie metadata | share image | `movie.backdropUrl` → `movie.posterUrl` → site card | | show metadata | share image | `show.backdropUrl` → `show.posterUrl` → site card | @@ -164,7 +164,7 @@ Tracer Bullet: the first slice proves the pipe from environment to crawler outpu 3. Add `lib/seo/metadata.ts` (`catalogMetadata()`), `app/opengraph-image.tsx` reading the three tokens from `globals.css`, and the root layout's `metadataBase`, default `openGraph` and `twitter`, satisfies **AC-3**, **AC-10**, **AC-11**, **AC-19**, **AC-20** 4. Wire the movie page: `catalogMetadata()` in the found branch, `noindex` in the failed branch, and `lib/seo/json-ld.ts` (`movieJsonLd()`, `jsonLdScript()`) rendered in the found body, with unit tests for omission and escaping, satisfies **AC-12**, **AC-15**, **AC-16**, **AC-21**, **AC-23** 5. Wire the show and season pages the same way, with `showJsonLd()` on the show page only, satisfies **AC-13**, **AC-14**, **AC-15**, **AC-22** -6. Thicken the sitemap: the 20 discover reads with independent failure, dedupe, ordering and the `days` or `minutes` lifetime inside its `use cache` function, with unit tests on a mocked `lib/tmdb`, satisfies **AC-6**, **AC-7**, **AC-8** +6. Thicken the sitemap: the 20 discover reads with independent failure, dedupe, ordering and the `hours` or `minutes` lifetime inside its `use cache` function, with unit tests on a mocked `lib/tmdb`, satisfies **AC-6**, **AC-7**, **AC-8** 7. Convert `/movies` and `/shows` to `generateMetadata` with descriptions and per page canonicals, and add the `/search` description and site card, satisfies **AC-17**, **AC-18** 8. Proof: `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build` (route table shows the prerendered shells and the three metadata routes), then in the running app check the heads of every public page type, the not found and failed branches, a private page and an auth page, signed out and signed in, and with `NEXT_PUBLIC_SITE_URL` unset, satisfies **AC-11**, **AC-24**, **AC-25**