From 06497c580b707ef4b5a82ab803dbbe2c038d2e42 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 13 Oct 2026 17:20:22 +0200 Subject: [PATCH 01/14] docs(spec): amend spec 0022 for 24 title pages and full grid rows Co-Authored-By: Claude Opus 5.5 --- .../specs/0022-card-size-title-pages/index.md | 13 +-- .../0022-card-size-title-pages/rationale.md | 13 +++ prompts/full-grid-rows.md | 84 +++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 prompts/full-grid-rows.md diff --git a/docs/specs/0022-card-size-title-pages/index.md b/docs/specs/0022-card-size-title-pages/index.md index 7efd455..cf6bdea 100644 --- a/docs/specs/0022-card-size-title-pages/index.md +++ b/docs/specs/0022-card-size-title-pages/index.md @@ -3,6 +3,7 @@ **Date**: 2026-10-12 **Status**: Accepted **Amended**: 2026-10-12. After the branch review, the spec records what shipped: Cast is a carousel row of home row cards (AC-17), a network shows its name with its logo beside it (AC-16), the error line names no cause (AC-18), `/privacy` also explains the trailer player (AC-19), and the extras reads throw like every other read (interface table). The owner kept each as built; see [rationale.md](rationale.md#amendment-2026-10-12-recording-what-shipped). +**Amended**: 2026-10-13. Full grid rows: every paged grid shows 24 titles, and the `catalog` grid steps 2, 3, 4 and 6 columns (no 5), so a full page fills every row at every width (AC-1, AC-2, AC-4, AC-6, new AC-21 and AC-22). This reverses the ragged last row accepted below; see [rationale.md](rationale.md#amendment-2026-10-13-full-grid-rows) and [prompts/full-grid-rows.md](../../../prompts/full-grid-rows.md). ## Summary @@ -19,13 +20,15 @@ This spec covers two related changes. First, poster cards on every grid page (Se *Card size strand* -- **AC-1**: The default (`catalog`) `PosterGrid` has 2 columns below `sm` (640px), 3 from `sm`, 4 from `md` (768px), 5 from `lg` (1024px) and 6 from `xl` (1280px). The 1200px cap, centring and gaps (`gap-x-4 gap-y-6`) are unchanged. -- **AC-2**: The `catalog` grid applies to the See all list pages (`/shows/{slug}`, `/movies/{slug}`), `/search`, Watchlist, Upcoming and Watched (both types), every one of their skeletons, and `/showcase`. At 1280px and wider a card is between 180px and 190px wide. From 1024px to 1279px cards are deliberately larger (about 186px to 228px). +- **AC-1**: The default (`catalog`) `PosterGrid` has 2 columns below `sm` (640px), 3 from `sm`, 4 from `md` (768px) and 6 from `xl` (1280px); `lg` (1024px) keeps 4 (amended 2026-10-13, was 5). The 1200px cap, centring and gaps (`gap-x-4 gap-y-6`) are unchanged. +- **AC-2**: The `catalog` grid applies to the See all list pages (`/shows/{slug}`, `/movies/{slug}`), `/search`, Watchlist, Upcoming and Watched (both types), every one of their skeletons, and `/showcase`. At 1280px and wider a card is between 180px and 190px wide. From 1024px to 1279px cards are deliberately larger (4 columns, about 236px to 288px; amended 2026-10-13). - **AC-3**: The season grid on `/shows/{id}` and its skeleton use the `wide` preset, which is exactly today's layout: 2 columns below `md`, 4 from it, with today's `sizes`. -- **AC-4**: Each preset's `sizes` string has one `floor(100 / columns)vw` entry per fluid column step, and one fixed px entry, `ceil((1200 - (columns - 1) × 16) / columns)`, for every column count the grid shows while capped at 1200px. For `catalog` that is `(min-width: 1280px) 187px, (min-width: 1232px) 228px, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw`. For `wide` it is today's `(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw`. `poster-grid.test.tsx` checks every key of `POSTER_GRID_SIZES` with this rule, so a preset with no sizes fails the type check, and a column change that misses its sizes fails the suite. +- **AC-4**: Each preset's `sizes` string has one `floor(100 / columns)vw` entry per fluid column step, and one fixed px entry, `ceil((1200 - (columns - 1) × 16) / columns)`, for every column count the grid shows while capped at 1200px. For `catalog` that is `(min-width: 1280px) 187px, (min-width: 1232px) 288px, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw` (amended 2026-10-13). For `wide` it is today's `(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw`. `poster-grid.test.tsx` checks every key of `POSTER_GRID_SIZES` with this rule, so a preset with no sizes fails the type check, and a column change that misses its sizes fails the suite. - **AC-5**: Eager posters are one full row at the preset's widest count: 6 for `catalog` and 4 for `wide`, read from one exported `POSTER_GRID_EAGER` map. The four local `EAGER_POSTERS` copies are removed. -- **AC-6**: `LibrarySkeleton` shows one row at the current column count at every width (6 cards, visibility `["", "", "hidden sm:block", "hidden md:block", "hidden lg:block", "hidden xl:block"]`), so a one row library does not shift when it arrives. The list page and search skeletons keep their 20 cards. +- **AC-6**: `LibrarySkeleton` shows one row at the current column count at every width (6 cards, visibility `["", "", "hidden sm:block", "hidden md:block", "hidden xl:block", "hidden xl:block"]`, amended 2026-10-13), so a one row library does not shift when it arrives. The list page and search skeletons show 24 cards (amended 2026-10-13, was 20). - **AC-7**: At 375px, 640px, 768px, 1024px and 1280px no grid page scrolls sideways, the missing poster tile keeps a real poster's footprint, and every card control keeps its 44px tap target. `/showcase` shows 6 grid cards. +- **AC-21** (added 2026-10-13): Every paged `catalog` grid shows 24 titles a page: the See all list pages, `/search` in browse, search and discover modes, and every library tab (`LIBRARY_PAGE_SIZE = 24`). A TMDB backed page *p* shows TMDB results `(p - 1) × 24 + 1` to `p × 24`, read from the two TMDB pages that hold them in parallel through the existing cached reads (a page past TMDB's page 500 is never requested). Its last page is `ceil(min(total_results, min(total_pages, 500) × 20) / 24)`, so a 10,000 result list ends at page 417 with 16 titles. If either TMDB read fails, the page shows its existing error panel with Retry. A title TMDB returns on both pages appears once. `generateMetadata` reads the same 24 title page, so canonicals and `noindex` follow the new page count. Old `?page=N` links are not redirected. +- **AC-22** (added 2026-10-13): A full page fills every row at 2, 3, 4 and 6 columns. A short last row is accepted only on the last page of a list, a filtered search page (query plus genre or rating, which stays cursor based with spec 0010's 20 match scan target), a library page after a card is removed in place, a page where TMDB repeated a title across its two pages, and the season grid. *Title page strand: data* @@ -138,7 +141,7 @@ Reasoning and options: see [rationale.md](rationale.md). - The library pages pay nothing for the new data. **Negative / tradeoffs**: -- A 20 title page ends with a short last row at 3, 5 and 6 columns, the problem the 2 and 4 column grid was built to remove. +- ~~A 20 title page ends with a short last row at 3, 5 and 6 columns.~~ Fixed by the 2026-10-13 amendment (AC-21, AC-22), at the cost of two TMDB reads per catalog or search page instead of one, and page numbers that point at different titles than before. - Smaller desktop posters make captions and controls tighter, and six eager posters means a phone preloads three rows instead of two. - Each title page view can cost one more TMDB request per hour per title, and a cold page waits on it for two of its sections. - Embedding YouTube adds a processor and a legal update. Region from an IP header is approximate (VPNs and travel show another country), and local development always shows the `US` fallback. diff --git a/docs/specs/0022-card-size-title-pages/rationale.md b/docs/specs/0022-card-size-title-pages/rationale.md index 4381bea..c5f01de 100644 --- a/docs/specs/0022-card-size-title-pages/rationale.md +++ b/docs/specs/0022-card-size-title-pages/rationale.md @@ -69,3 +69,16 @@ A review of the branch against this spec found five places where the build went - **An error line with no cause.** "TMDB didn't respond" was untrue for a 404 or a bad response, which the extras read also turns into the error line, so the line now says only that the section couldn't load from TMDB. - **The trailer paragraph in `/privacy`.** Saying when the YouTube player loads and whose policy then applies belongs beside the cookie text, in addition to the one country sentence AC-19 asked for. - **The read signatures.** The extras reads follow the module's pair pattern: the cached twin returns a `TmdbResult` and the exported wrapper throws. The interface table described only the inner half. + +## Amendment 2026-10-13: full grid rows + +The owner asked for every row of a full page to be filled at every width, reversing the ragged last row accepted in the premise note above. 20 does not divide by 3 or 6, so at 6 columns a page ended 4 cards short. + +Options weighed in the 2026-10-13 grilling session: + +- **Columns that divide 20 (2, 4, 5)**: no data change, but drops 3 and 6 columns and grows desktop cards to about 228px. Recommended, not chosen. +- **24 a page with 2, 3, 4 and 6 columns (chosen)**: keeps six columns on wide screens and the 187px card. The premise note said a page size change could not work on TMDB pages fixed at 20; it can, by reading the two TMDB pages that hold each 24 title window. The cost is a second cached TMDB read per page and page numbers that move. +- **60 a page**: fills every column count but costs three TMDB reads and very long pages. +- **Hiding the extra cards**: titles would vanish between pages. Rejected. + +Filtered search keeps its cursor and 20 match target: its pages were never a fixed size, and making them exactly 24 would mean rewriting spec 0010's scan and cursor for a cosmetic gain. Old page links are not redirected because the lists are re-ranked by TMDB daily, so a page number never named stable content. A failed half of a page shows the whole page's error, not a half page with a notice, so no new state is added. Page 417 under TMDB's 10,000 result cap shows its 16 titles rather than hiding them. diff --git a/prompts/full-grid-rows.md b/prompts/full-grid-rows.md new file mode 100644 index 0000000..f7dcc63 --- /dev/null +++ b/prompts/full-grid-rows.md @@ -0,0 +1,84 @@ +# Full grid rows + +## Goal + +On every full page, every row of every title grid is filled at every width. Today a page holds 20 titles and the `catalog` grid steps 2/3/4/5/6 columns; 20 does not divide by 3 or 6, so at 6 columns (1280px and wider) a page ends 4 cards short. Move every paged grid to 24 titles and the columns to 2/3/4/6. Amends spec 0022 (AC-1, AC-2, AC-4, AC-6, new AC-21 and AC-22); decided in the 2026-10-13 grilling session. + +## Inspected + +- `components/poster-grid.tsx` and `poster-grid.test.tsx` (the presets, `sizes` rule, eager count) +- `components/catalog/catalog-list-page.tsx` (`PAGE_SIZE = 20` skeleton, `lastReachablePage`), `lib/catalog-list-read.ts` (`readCatalogList`, shared with the home rows and `generateMetadata`), `lib/landing-metadata.ts` +- `components/search/search-results.tsx` (`PAGE_SIZE = 20` skeleton, modes), `lib/search/read.ts` (`readSearchPage`, `readDiscoverPage`), `lib/search/mode.ts`, `lib/search/scan.ts`, `lib/search/constants.ts` (`TMDB_PAGE_SIZE`, `SCAN_TARGET`), `lib/search/count.ts` +- `lib/catalog/pages.ts` (`TMDB_MAX_PAGE = 500`, `parsePageParam`, `lastReachablePage`) +- `lib/tracking/library-lists.ts` (`LIBRARY_PAGE_SIZE = 20`, `libraryLastPage`, in memory slice and database range) and its test +- `components/library/library-panels.tsx` (`LibrarySkeleton` visibility list), `library-grid.tsx`, `show-library-tab.tsx`, `movie-library-tab.tsx` +- `docs/specs/0022-card-size-title-pages/` (amended in this change), and the specs that state 20 a page: 0006, 0008 (AC-11), 0009, 0020 (AC-16), 0021 + +## Skills + +- `next-dev-loop` to check the grids and pagination in the running app. +- `supabase` is not needed: `LIBRARY_PAGE_SIZE` only changes the slice and the range, with no schema or RLS change. + +## Decisions (from the grilling, owner approved) + +1. **24 a page, columns 2 → 3 (`sm`) → 4 (`md`, `lg`) → 6 (`xl`).** 24 divides by every step. The 1200px cap, gaps and eager count (6) stay. +2. **`sizes`** by the AC-4 rule: `(min-width: 1280px) 187px, (min-width: 1232px) 288px, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw`. +3. **All `catalog` grids**: See all list pages, `/search` (browse, search, discover), every library tab, their skeletons, `/showcase`. The season grid (`wide`) is unchanged. +4. **TMDB window.** Page *p* shows TMDB results `(p-1)×24+1` to `p×24`, which always span exactly two TMDB pages, read in parallel through the existing cached reads, so neighbours share one cache entry. A TMDB page past 500 is never requested. +5. **Last page** `ceil(min(total_results, min(total_pages, 500) × 20) / 24)`: page 417 of a 10,000 result list shows 16 titles. +6. **One failed half fails the page**: the existing error panel with Retry. +7. **A title on both TMDB pages appears once** (deduplicated by id inside the window, which can leave that page one short; React keys need it). +8. **Filtered search stays as is**: cursor based, `SCAN_TARGET = 20`, variable card count. Named exception. +9. **Short rows accepted** on a last page, filtered search, a library page after an in place removal, a deduplicated page, the season grid. +10. **No redirects** for old `?page=N` links; past the new last page they show the existing "That page doesn't exist" panel. +11. **The home rows keep TMDB page 1** (`readCatalogList` is unchanged); only the See all pages use the window. + +## Design + +- **`lib/catalog/grid-page.ts`** (new, pure, no `server-only`): `GRID_PAGE_SIZE = 24`; `TMDB_PAGE_SIZE = 20` moves here from `lib/search/constants.ts` (which re-exports it, so `scan.ts` is untouched); `gridPageWindow(page)` → `{ tmdbPages: number[], skip }` with pages above `TMDB_MAX_PAGE` dropped; `lastGridPage(totalResults, totalPages)`; `combineGridPage(page, tmdbPages: Paged[])` → `Paged` (concatenate, deduplicate by id, skip, take 24, `totalPages` = `lastGridPage`, `totalResults` from the first read). Unit tested. +- **`readGridPage(page, readTmdbPage)`** (in the same module, generic over the read function): `Promise.all` over the window, then `combineGridPage`. A rejection propagates as the `TmdbError` it already is. +- **Catalog**: `readCatalogGridPage(list, page)` and a tagged twin in `lib/catalog-list-read.ts`, used by `CatalogListPage` and `catalogListMetadata`. `readCatalogList` stays for the home rows. +- **Search**: `search` and `browse`/`discover` modes read through `readGridPage` over `readSearchPage` and `readDiscoverPage`. `FilteredResults` is untouched. +- **Pages**: callers use the combined `totalPages` (already capped), so `lastReachablePage` is no longer applied on top of it in those two places. `parsePageParam` keeps its 1–500 range. +- **Grid**: the `catalog` classes become `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6`, with the new `sizes`; the JSDoc drops "short last row accepted". `COLUMNS_AT` in the test follows. +- **Skeletons**: both 20 card skeletons use `GRID_PAGE_SIZE`; `LibrarySkeleton` visibility becomes `["", "", "hidden sm:block", "hidden md:block", "hidden xl:block", "hidden xl:block"]`. +- **Library**: `LIBRARY_PAGE_SIZE = 24`. `fetchMoviesByIds` already batches 8 at a time, so 24 is three batches. + +## Expected files + +- New: `lib/catalog/grid-page.ts`, `lib/catalog/grid-page.test.ts` +- `components/poster-grid.tsx`, `components/poster-grid.test.tsx`, `components/poster-card.tsx` (sizes comment only, if it names columns) +- `lib/catalog-list-read.ts`, `components/catalog/catalog-list-page.tsx`, `lib/landing-metadata.ts` (+ tests) +- `lib/search/read.ts`, `lib/search/constants.ts`, `components/search/search-results.tsx` (+ tests) +- `lib/tracking/library-lists.ts`, `lib/tracking/library-lists.test.ts`, `components/library/library-panels.tsx` (+ skeleton test) +- Docs: spec 0022 (done with this plan), one line amendment notes in specs 0006, 0008, 0009, 0020, 0021 where they say 20; `components/AGENTS.md` grid description (via `/sync` after the build) + +## Requirements and acceptance criteria + +- At 375, 640, 768, 1024 and 1280px+ a full page of `/movies/popular`, `/shows/top-rated`, `/search?type=movie` (browse), `/search?q=star` and a library tab with 24+ titles has no empty slot in its last row. +- Page 2 of a list starts with TMDB result 25 and has no title from page 1 (unless TMDB itself re-ranked between reads). +- The pagination count matches `lastGridPage`; `?page=` past it shows "That page doesn't exist"; page 417 of a capped list renders. +- Filtered search (`/search?q=star&genre=…`) behaves exactly as before. +- A failed TMDB read on either half shows the error panel with Retry. +- The home rows still show TMDB page 1 (12 cards), unchanged. +- The season grid is unchanged. +- No horizontal scroll, 44px tap targets kept, eager first row of 6. + +## Security + +Public catalog data only. No new request state enters a cached scope: the window function takes only the page number, and each TMDB read keeps its existing cache key. No auth, RLS or schema change; the library change only resizes an owner scoped slice and range. + +## Automated checks + +`pnpm typecheck`, `pnpm lint`, `pnpm test` (new `grid-page.test.ts`: window at pages 1, 2, 5, 417; skip values 0/4/8/12/16; TMDB page 501 dropped; dedupe; last page maths incl. empty list and the 10,000 cap; the updated `poster-grid.test.tsx` sizes invariant; `LIBRARY_PAGE_SIZE` is 24), `pnpm build` (route table unchanged). + +## Manual test + +1. `pnpm dev:docker`, open `/movies/popular` at 1440px: 4 full rows of 6. Resize to 1100px (6 rows of 4), 700px (8 rows of 3), 375px (12 rows of 2): no gaps. +2. Note the last card on page 1, open page 2: its first card is the next title, and the pagination last page matches the count. +3. Open `/movies/popular?page=417`: up to 16 cards; `?page=418` shows "That page doesn't exist". +4. `/search?type=tv` and `/search?type=movie&q=star`: 24 cards, full rows; pages 2 and 3 work. +5. `/search?type=movie&q=star&rating=7`: same behaviour as before (variable count, "More results" cursor). +6. Signed in with 25+ planned movies, `/watchlist?type=movie`: 24 cards on page 1, the rest on page 2. Remove one card: the row shortens in place (accepted). +7. `/shows` and `/movies` landings: rows unchanged. A show page: season grid unchanged. +8. View source of `/movies/popular?page=2`: the canonical is `?page=2`; `?page=500` is `noindex`. From 3ae975b9158c0ec4194e7b11e37f797faa27988c Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 13 Oct 2026 17:20:22 +0200 Subject: [PATCH 02/14] feat(catalog): add the 24 title grid page window over TMDB pages Co-Authored-By: Claude Opus 5.5 --- lib/catalog/grid-page.test.ts | 143 ++++++++++++++++++++++++++++++++++ lib/catalog/grid-page.ts | 104 +++++++++++++++++++++++++ lib/search/constants.ts | 2 +- 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 lib/catalog/grid-page.test.ts create mode 100644 lib/catalog/grid-page.ts diff --git a/lib/catalog/grid-page.test.ts b/lib/catalog/grid-page.test.ts new file mode 100644 index 0000000..3ac1b43 --- /dev/null +++ b/lib/catalog/grid-page.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Paged } from "@/lib/tmdb/types"; + +import { + combineGridPage, + GRID_PAGE_SIZE, + gridPageWindow, + lastGridPage, + readGridPage, +} from "./grid-page"; + +/** + * covers: spec 0022 AC-21, AC-22 + * + * The window arithmetic is the whole risk: an off by one skips or repeats a + * title between pages and nothing looks broken. So every test builds TMDB + * pages of real 1 based result positions and checks positions, not counts. + */ + +type Item = { id: number }; + +/** TMDB page `n` of a list of `total` results, ids being 1 based positions. */ +function tmdbPage(n: number, total = 10_000): Paged { + const start = (n - 1) * 20 + 1; + const end = Math.min(n * 20, total); + return { + page: n, + results: Array.from( + { length: Math.max(end - start + 1, 0) }, + (_, index) => ({ id: start + index }), + ), + totalPages: Math.ceil(total / 20), + totalResults: total, + }; +} + +const ids = (page: Paged) => page.results.map((item) => item.id); + +describe("gridPageWindow", () => { + it.each([ + [1, [1, 2], 0], + [2, [2, 3], 4], + [3, [3, 4], 8], + [4, [4, 5], 12], + [5, [5, 6], 16], + [6, [7, 8], 0], + ])("page %i reads TMDB pages %j skipping %i", (page, tmdbPages, skip) => { + expect(gridPageWindow(page)).toEqual({ tmdbPages, skip }); + }); + + it("never asks for a TMDB page past 500", () => { + expect(gridPageWindow(417)).toEqual({ tmdbPages: [500], skip: 4 }); + }); +}); + +describe("lastGridPage", () => { + it.each([ + [0, 0, 0], + [7, 1, 1], + [24, 2, 1], + [25, 2, 2], + [47, 3, 2], + [10_000, 500, 417], + // TMDB reports more results than its 500 pages serve. + [20_001, 500, 417], + ])( + "%i results on %i TMDB pages end at grid page %i", + (results, pages, last) => { + expect(lastGridPage(results, pages)).toBe(last); + }, + ); +}); + +describe("combineGridPage", () => { + it("joins consecutive pages without a gap or a repeat", () => { + const seen: number[] = []; + for (let page = 1; page <= 10; page++) { + const { tmdbPages } = gridPageWindow(page); + seen.push( + ...ids( + combineGridPage( + page, + tmdbPages.map((n) => tmdbPage(n)), + ), + ), + ); + } + expect(seen).toEqual(Array.from({ length: 240 }, (_, index) => index + 1)); + }); + + it("fills a full page and reports the grid's page count", () => { + const page = combineGridPage(2, [tmdbPage(2), tmdbPage(3)]); + expect(page.results).toHaveLength(GRID_PAGE_SIZE); + expect(page.totalPages).toBe(417); + expect(page.totalResults).toBe(10_000); + expect(page.page).toBe(2); + }); + + it("shows the 16 titles of the last page under the cap", () => { + expect(ids(combineGridPage(417, [tmdbPage(500)]))).toEqual( + Array.from({ length: 16 }, (_, index) => 9985 + index), + ); + }); + + it("keeps a short last page short", () => { + const page = combineGridPage(2, [tmdbPage(2, 30), tmdbPage(3, 30)]); + expect(ids(page)).toEqual([25, 26, 27, 28, 29, 30]); + expect(page.totalPages).toBe(2); + }); + + it("keeps a title TMDB returned on both pages once", () => { + const second = tmdbPage(2); + second.results[0] = { id: 3 }; + const page = combineGridPage(1, [tmdbPage(1), second]); + expect(ids(page).filter((id) => id === 3)).toHaveLength(1); + expect(page.results).toHaveLength(GRID_PAGE_SIZE); + }); + + it("is empty with no results", () => { + const page = combineGridPage(1, [tmdbPage(1, 0), tmdbPage(2, 0)]); + expect(page.results).toEqual([]); + expect(page.totalPages).toBe(0); + }); +}); + +describe("readGridPage", () => { + it("reads both TMDB pages", async () => { + const read = vi.fn(async (n: number) => tmdbPage(n)); + const page = await readGridPage(3, read); + expect(read.mock.calls.map(([n]) => n)).toEqual([3, 4]); + expect(ids(page)[0]).toBe(49); + }); + + it("fails the whole page when either half fails", async () => { + const failure = new Error("TMDB down"); + const read = vi.fn(async (n: number) => { + if (n === 2) throw failure; + return tmdbPage(n); + }); + await expect(readGridPage(1, read)).rejects.toBe(failure); + }); +}); diff --git a/lib/catalog/grid-page.ts b/lib/catalog/grid-page.ts new file mode 100644 index 0000000..f9f2acf --- /dev/null +++ b/lib/catalog/grid-page.ts @@ -0,0 +1,104 @@ +import type { Paged } from "@/lib/tmdb/types"; + +import { lastReachablePage, TMDB_MAX_PAGE } from "./pages"; + +/** + * Titles on one page of every paged `catalog` grid (spec 0022, AC-21). 24 + * divides by each of the grid's 2, 3, 4 and 6 columns, so a full page fills + * every row at every width (AC-22). + */ +export const GRID_PAGE_SIZE = 24; + +/** TMDB's fixed result page size, which every window is cut from. */ +export const TMDB_PAGE_SIZE = 20; + +/** + * The TMDB pages that hold grid page `page`, and how many results of the + * first one come before it (spec 0022, AC-21). + * + * A 24 title window over 20 result pages always spans exactly two of them. + * A page past TMDB's 500 is dropped rather than requested, because TMDB + * refuses it; that only happens on the last grid page of a capped list. + */ +export function gridPageWindow(page: number): { + tmdbPages: number[]; + skip: number; +} { + const first = (page - 1) * GRID_PAGE_SIZE; + const last = page * GRID_PAGE_SIZE - 1; + const from = Math.floor(first / TMDB_PAGE_SIZE) + 1; + const to = Math.floor(last / TMDB_PAGE_SIZE) + 1; + const tmdbPages: number[] = []; + for ( + let tmdbPage = from; + tmdbPage <= Math.min(to, TMDB_MAX_PAGE); + tmdbPage++ + ) { + tmdbPages.push(tmdbPage); + } + return { tmdbPages, skip: first % TMDB_PAGE_SIZE }; +} + +/** + * The last grid page a list reaches (spec 0022, AC-21): only results on the + * TMDB pages it serves count, so a 10,000 result list ends at page 417 with + * its 16 titles rather than hiding them. + */ +export function lastGridPage(totalResults: number, totalPages: number): number { + const reachable = Math.min( + totalResults, + lastReachablePage(totalPages) * TMDB_PAGE_SIZE, + ); + return Math.ceil(reachable / GRID_PAGE_SIZE); +} + +/** + * Cuts grid page `page` out of the TMDB pages `gridPageWindow` named, in + * order (spec 0022, AC-21). A title TMDB returns on both pages, which happens + * when a list re-ranks between two cached reads, is kept once: a grid keys + * its cards by id. `totalPages` becomes the grid's own page count, already + * capped, and `totalResults` stays TMDB's count from the first read. + */ +export function combineGridPage( + page: number, + reads: Paged[], +): Paged { + const { skip } = gridPageWindow(page); + const seen = new Set(); + const results: T[] = []; + for (const read of reads) { + for (const result of read.results) { + if (seen.has(result.id)) continue; + seen.add(result.id); + results.push(result); + } + } + const first = reads[0]; + const totalResults = first?.totalResults ?? 0; + return { + page, + results: results.slice(skip, skip + GRID_PAGE_SIZE), + totalPages: lastGridPage(totalResults, first?.totalPages ?? 0), + totalResults, + }; +} + +/** + * Reads grid page `page` through any cached TMDB page read (spec 0022, + * AC-21): both TMDB pages in parallel, so neighbouring grid pages share one + * cache entry. A failure on either half rejects with the read's own error, + * so the caller shows its usual error panel rather than half a page. + * + * Pure apart from the read it is handed, so the proxy rule for `lib/catalog/` + * holds: no TMDB import here. + */ +export async function readGridPage( + page: number, + readTmdbPage: (tmdbPage: number) => Promise>, +): Promise> { + const { tmdbPages } = gridPageWindow(page); + const reads = await Promise.all( + tmdbPages.map((tmdbPage) => readTmdbPage(tmdbPage)), + ); + return combineGridPage(page, reads); +} diff --git a/lib/search/constants.ts b/lib/search/constants.ts index c8086e9..40319b8 100644 --- a/lib/search/constants.ts +++ b/lib/search/constants.ts @@ -44,7 +44,7 @@ export const SCAN_TARGET = 20; export const SCAN_MAX_PAGES = 5; /** TMDB's result page size, which the partial range is computed from. */ -export const TMDB_PAGE_SIZE = 20; +export { TMDB_PAGE_SIZE } from "@/lib/catalog/grid-page"; /** * TMDB's search endpoints report at most this many results, so a total at the From 20f8c22863943a3e806a5c4a8845727e2a339df6 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 13 Oct 2026 17:20:22 +0200 Subject: [PATCH 03/14] feat(catalog): page See all lists 24 titles at a time Co-Authored-By: Claude Opus 5.5 --- app/movies/popular/page.test.ts | 24 ++++++--- app/shows/top-rated/page.test.ts | 14 ++++-- components/catalog/catalog-list-page.test.tsx | 22 ++++---- components/catalog/catalog-list-page.tsx | 14 +++--- lib/catalog-list-read.test.ts | 32 +++++++++++- lib/catalog-list-read.ts | 50 ++++++++++++++++++- lib/landing-metadata.ts | 9 ++-- 7 files changed, 129 insertions(+), 36 deletions(-) diff --git a/app/movies/popular/page.test.ts b/app/movies/popular/page.test.ts index 639f872..a41e4a3 100644 --- a/app/movies/popular/page.test.ts +++ b/app/movies/popular/page.test.ts @@ -20,6 +20,15 @@ vi.mock("@/lib/tmdb", async () => { }); const { TmdbError } = await import("@/lib/tmdb/errors"); + +/** A TMDB list page with no titles, as the metadata reads only its counts. */ +const tmdbPage = (totalPages: number) => ({ + page: 1, + results: [], + totalPages, + totalResults: totalPages * 20, +}); + const { generateMetadata } = await import("./page"); const ORIGIN = "https://bestats.example"; @@ -41,7 +50,7 @@ afterEach(() => { describe("the popular movies list's metadata (spec 0021, AC-15)", () => { it("declares /movies/popular as the canonical of page 1, with the site card", async () => { - discoverMovies.mockResolvedValue({ totalPages: 50 }); + discoverMovies.mockResolvedValue(tmdbPage(50)); const metadata = await metadataFor(); @@ -58,7 +67,7 @@ describe("the popular movies list's metadata (spec 0021, AC-15)", () => { }); it("gives page N its own canonical", async () => { - discoverMovies.mockResolvedValue({ totalPages: 50 }); + discoverMovies.mockResolvedValue(tmdbPage(50)); const metadata = await metadataFor("2"); @@ -69,20 +78,21 @@ describe("the popular movies list's metadata (spec 0021, AC-15)", () => { }); it("indexes the last page itself", async () => { - discoverMovies.mockResolvedValue({ totalPages: 7 }); + // 7 TMDB pages hold 140 titles, 6 pages of 24 (spec 0022, AC-21). + discoverMovies.mockResolvedValue(tmdbPage(7)); - const metadata = await metadataFor("7"); + const metadata = await metadataFor("6"); expect(metadata.robots).toBeUndefined(); expect(metadata.alternates?.canonical).toBe( - `${ORIGIN}/movies/popular?page=7`, + `${ORIGIN}/movies/popular?page=6`, ); }); it("is noindex with no canonical for a page past the last one", async () => { - discoverMovies.mockResolvedValue({ totalPages: 7 }); + discoverMovies.mockResolvedValue(tmdbPage(7)); - const metadata = await metadataFor("8"); + const metadata = await metadataFor("7"); expect(metadata).toEqual({ title: TITLE, diff --git a/app/shows/top-rated/page.test.ts b/app/shows/top-rated/page.test.ts index 3ac4433..f25a04e 100644 --- a/app/shows/top-rated/page.test.ts +++ b/app/shows/top-rated/page.test.ts @@ -27,6 +27,14 @@ function metadataFor(page?: string) { } as Parameters[0]); } +/** A TMDB list page with no titles, as the metadata reads only its counts. */ +const tmdbPage = (totalPages: number) => ({ + page: 1, + results: [], + totalPages, + totalResults: totalPages * 20, +}); + beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN)); afterEach(() => { @@ -36,7 +44,7 @@ afterEach(() => { describe("the top rated shows list's metadata", () => { it("reads the top_rated list and names the page by its h1 and note", async () => { - getTvList.mockResolvedValue({ totalPages: 100 }); + getTvList.mockResolvedValue(tmdbPage(100)); const metadata = await metadataFor(); @@ -49,7 +57,7 @@ describe("the top rated shows list's metadata", () => { }); it("gives page 2 its own canonical", async () => { - getTvList.mockResolvedValue({ totalPages: 100 }); + getTvList.mockResolvedValue(tmdbPage(100)); const metadata = await metadataFor("2"); @@ -59,7 +67,7 @@ describe("the top rated shows list's metadata", () => { }); it("is noindex past the last page", async () => { - getTvList.mockResolvedValue({ totalPages: 100 }); + getTvList.mockResolvedValue(tmdbPage(100)); const metadata = await metadataFor("999"); diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx index 58a2bed..ac44248 100644 --- a/components/catalog/catalog-list-page.test.tsx +++ b/components/catalog/catalog-list-page.test.tsx @@ -21,7 +21,7 @@ vi.mock("@/lib/tmdb", async () => ({ TmdbError: (await import("@/lib/tmdb/errors")).TmdbError, })); vi.mock("@/lib/catalog-list-read", () => ({ - readTaggedCatalogList: (...args: unknown[]) => read(...args), + readTaggedCatalogGridPage: (...args: unknown[]) => read(...args), })); vi.mock("@/components/catalog/list-cards", () => ({ ShowListCard: (props: { @@ -162,17 +162,17 @@ describe("the list page header (AC-9)", () => { describe("the list page grid (AC-4, AC-9)", () => { it("reads the page from the URL and shows one card per title", async () => { - read.mockResolvedValue(tvPage(20, 30)); + read.mockResolvedValue(tvPage(24, 30)); await renderGrid(TOP_RATED, { page: "2" }); expect(read).toHaveBeenCalledWith(TOP_RATED, 2); const grid = screen.getByRole("list", { name: "Top rated shows, page 2" }); - expect(within(grid).getAllByRole("listitem")).toHaveLength(20); + expect(within(grid).getAllByRole("listitem")).toHaveLength(24); }); it("reads page 1 when the URL has no page", async () => { - read.mockResolvedValue(tvPage(20, 30)); + read.mockResolvedValue(tvPage(24, 30)); await renderGrid(); @@ -232,7 +232,7 @@ describe("the list page grid (AC-4, AC-9)", () => { }); it("pages with Page N of M, page 1 as the bare path", async () => { - read.mockResolvedValue(tvPage(20, 30)); + read.mockResolvedValue(tvPage(24, 30)); await renderGrid(TOP_RATED, { page: "2" }); @@ -248,12 +248,12 @@ describe("the list page grid (AC-4, AC-9)", () => { ); }); - it("caps the last page at TMDB's 500", async () => { - read.mockResolvedValue(tvPage(20, 900)); + it("ends at the grid's own last page under TMDB's cap", async () => { + read.mockResolvedValue(tvPage(16, 417)); - await renderGrid(TOP_RATED, { page: "500" }); + await renderGrid(TOP_RATED, { page: "417" }); - expect(screen.getByText("Page 500 of 500")).toBeInTheDocument(); + expect(screen.getByText("Page 417 of 417")).toBeInTheDocument(); expect( screen.queryByRole("link", { name: "Next" }), ).not.toBeInTheDocument(); @@ -338,13 +338,13 @@ describe("the list page states (AC-10)", () => { expect(screen.queryByRole("list")).not.toBeInTheDocument(); }); - it("holds the grid's place with 20 card skeletons and the pagination skeleton", () => { + it("holds the grid's place with 24 card skeletons and the pagination skeleton", () => { const fallback = gridBoundary().props.fallback as Element; const { container } = render(fallback); const grid = container.querySelector('[aria-hidden="true"]'); - expect(grid?.querySelectorAll("li")).toHaveLength(20); + expect(grid?.querySelectorAll("li")).toHaveLength(24); expect(screen.queryByRole("list")).not.toBeInTheDocument(); }); }); diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx index e15992e..9ee7700 100644 --- a/components/catalog/catalog-list-page.tsx +++ b/components/catalog/catalog-list-page.tsx @@ -9,18 +9,16 @@ import { RetryLink } from "@/components/retry-link"; import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; import { StatePanel } from "@/components/state-panel"; import { ButtonLink } from "@/components/ui/button"; +import { GRID_PAGE_SIZE } from "@/lib/catalog/grid-page"; import { type CatalogList, listPageHref, listPath } from "@/lib/catalog/lists"; import { CATALOG_PATHS } from "@/lib/catalog/media-type"; -import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages"; +import { parsePageParam } from "@/lib/catalog/pages"; import { - readTaggedCatalogList, + readTaggedCatalogGridPage, type TaggedCatalogPage, } from "@/lib/catalog-list-read"; import { TmdbError } from "@/lib/tmdb"; -/** TMDB's list page size, which the skeleton mirrors. */ -const PAGE_SIZE = 20; - const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const; type SearchParams = Promise>; @@ -81,7 +79,7 @@ async function ListGrid({ let result: TaggedCatalogPage; try { - result = await readTaggedCatalogList(list, page); + result = await readTaggedCatalogGridPage(list, page); } catch (error) { if (!(error instanceof TmdbError)) throw error; return ( @@ -108,7 +106,7 @@ async function ListGrid({ ); } - const lastPage = lastReachablePage(result.paged.totalPages); + const lastPage = result.paged.totalPages; if (page > lastPage) return ; return ( @@ -148,7 +146,7 @@ function ListGridSkeleton() { return (