From 6cfb344e8e860e109c2d4ba26e75213e06b77fd0 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:03:56 +0200
Subject: [PATCH 01/25] docs(prompts): plan the four column grid, cyan tabs,
footer icon key and skeletons
Co-Authored-By: Claude Opus 5.5
---
prompts/grid-tabs-footer-legend.md | 90 ++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 prompts/grid-tabs-footer-legend.md
diff --git a/prompts/grid-tabs-footer-legend.md b/prompts/grid-tabs-footer-legend.md
new file mode 100644
index 0000000..e1ae323
--- /dev/null
+++ b/prompts/grid-tabs-footer-legend.md
@@ -0,0 +1,90 @@
+# Four column grid, cyan selected tabs, footer icon key, honest skeletons
+
+## Goal
+
+Four UI changes, settled with the user in a grilling session on 2026-10-11:
+
+1. Stop the layout jumping when a page finishes loading: every skeleton matches the footprint of what replaces it.
+2. Regrid the poster grids to at most 4 columns, so the 20 cards a page holds fill 5 complete rows.
+3. Light the selected navbar tab in the personal score cyan, as a glass fill.
+4. Replace the per tab badge legend with one fixed icon key, centred at the top of the site footer on every route.
+
+Branch `feat/grid-tabs-footer-legend`. No spec: this plan records the decisions.
+
+## Inspected code
+
+- Grid: `components/poster-grid.tsx` (2/3/4/5/6 columns), `components/poster-card.tsx` (`POSTER_SIZES`), `app/shows/page.tsx` and `app/movies/page.tsx` (`PAGE_SIZE = 20`, `EAGER_POSTERS = 6`, skeletons), `components/library/library-grid.tsx` (`EAGER_POSTERS`), `components/show/season-grid.tsx` (also `PosterGrid`), `lib/tracking/library-lists.ts` (`LIBRARY_PAGE_SIZE = 20`)
+- Skeletons: `components/library/library-panels.tsx` (`LibrarySkeleton`, always 20 cards), `components/search/search-results.tsx` (`SearchResultsSkeleton`), `components/skeleton.tsx`, `components/movie/movie-detail-skeleton.tsx`, `components/show/show-detail-skeleton.tsx`, `components/show/season-skeleton.tsx`, `components/catalog/detail-skeletons.tsx`
+- Tabs: `components/layout/media-type-tabs.tsx`, `components/layout/library-nav.tsx` (bar and sheet variants), `glass-selected` in `app/globals.css`, `--color-score-personal: #50c6e5`
+- Legend: `components/library/badge-legend.tsx`, its callers `show-library-tab.tsx` and `movie-library-tab.tsx`, `components/library/library-section.test.tsx` (legend cases), `components/tracking/tracking-icons.tsx` (no `"use client"`, so usable from the server footer), `components/AGENTS.md` (badge legend line), `app/showcase/page.tsx`
+- Footer: `components/layout/site-footer.tsx`, `site-footer.test.tsx`, `app/layout.tsx`, `app/layout-purity.test.ts` (the footer must stay a request free Server Component)
+
+## Skills used
+
+`next-dev-loop` to measure skeletons against loaded pages and verify the result in the running app. No database change, so no Supabase skill.
+
+## Decisions
+
+1. **Page size stays 20 everywhere.** `/shows`, `/movies` and `/search` keep one TMDB page per BeStats page; the library pages keep `LIBRARY_PAGE_SIZE = 20`. No TMDB re-paging.
+2. **Grid columns: 2 below `md`, 4 from `md` (768px).** 20 fills every row at both counts (10 × 2, 5 × 4); 3 columns would leave a partial row, so there is no 3 column step. Applies to every `PosterGrid`, including the seasons on `/shows/{id}`.
+3. **Grid width capped at 1200px and centred** on every page (`mx-auto max-w-[1200px]` on `PosterGrid`; changed from left aligned at the owner's request after review), so a 4 column poster stays near 285px wide and a 5 row page stays a sensible length. Pagination and other content beside the grid keep their current width.
+4. **Image sizes follow the grid.** `POSTER_SIZES` becomes `(min-width: 1200px) 300px, (min-width: 768px) 25vw, 50vw`; every `EAGER_POSTERS` becomes 4 (one full row at the widest grid).
+5. **Skeletons.**
+ - `LibrarySkeleton` draws one row (4 cards): the real count is unknown until the read, and one row is the smallest honest guess.
+ - The `/shows`, `/movies` and search skeletons add a placeholder for the pagination row at its real height.
+ - Every other route (detail, season, search filters, auth, account) is measured in the browser: skeleton height against loaded height at 390px and 1440px. Any mismatch over a few pixels is fixed in its skeleton; the plan's report lists each route measured and what changed.
+6. **Selected tab: cyan glass.** A new `glass-selected-personal` utility in `app/globals.css`, the same layer stack as `glass-selected` (fill, plate, rim on the 1.5px border) with the gradient tinted from `--color-score-personal`, translucent so the glass reads. Text on it is near black (a token, e.g. `text-on-personal`) so it meets WCAG AA 4.5:1; the exact tint is checked with a contrast measurement, not assumed. Used by the selected tab in `MediaTypeTabsView` and in both `LibraryNav` variants. Unselected tabs do not change.
+7. **Footer icon key.**
+ - `BadgeLegend` and its per tab entries are deleted, with their test cases; the two library tabs no longer render it.
+ - A new `IconKey` Server Component (`components/layout/icon-key.tsx`) renders one fixed list, always all entries, in this order: TMDB rating, Your score, Plan, Planned, Next episode, Mark watched, Release date. Same icons and colours as the cards (`StarIcon` in `rating-tmdb` / `score-personal`, `PlanIcon`, `PlannedIcon`, `NextEpisodeIcon`, `WatchedIcon` outline, `CalendarIcon`), `size-4`, `text-text-legend`.
+ - It is the footer's first row, on every route, inside the footer's 1600px container: a hairline (`border-t border-border`) above, centred `flex-wrap` with the current legend gaps, wrapping centred on mobile. The existing attribution and legal row sits below it unchanged.
+ - ``, each `` the icon (`aria-hidden`) and its text.
+ - It reads no request state, so the footer stays in every prerendered shell.
+8. **Docs.** `components/AGENTS.md` badge legend line now points at the footer icon key; the showcase's legend wording is updated to match.
+
+## Expected files
+
+- `components/poster-grid.tsx`, `components/poster-card.tsx`
+- `app/shows/page.tsx`, `app/movies/page.tsx`, `components/library/library-grid.tsx`, `components/library/show-library-tab.tsx`, `components/library/movie-library-tab.tsx` (eager count, legend removal)
+- `components/library/library-panels.tsx`, `components/search/search-results.tsx`, plus any detail or season skeleton the measurement flags
+- `app/globals.css`, `components/layout/media-type-tabs.tsx`, `components/layout/library-nav.tsx`
+- New `components/layout/icon-key.tsx` and `icon-key.test.tsx`; `components/layout/site-footer.tsx` and its test
+- Deleted `components/library/badge-legend.tsx`; `components/library/library-section.test.tsx` legend cases removed
+- `components/AGENTS.md`, `app/showcase/page.tsx`
+- Tests touching the grid, tabs or skeleton counts, updated
+
+## Requirements
+
+- No horizontal scroll at 390px; tap targets keep their current sizes (the tab `hit-area` utilities are unchanged).
+- The selected tab keeps `aria-current="page"`; colour is not the only signal.
+- No colour literal outside `app/globals.css` (`design-tokens-boundary.test.ts`).
+- Footer stays a Server Component with no cookie, header or session read (`layout-purity.test.ts`).
+- `POSTER_SIZES` and the grid columns change together (the comment in `poster-grid.tsx`).
+
+## Security
+
+UI only: no new data, input, route or credential. The footer key is static, so it is safe in shared prerendered shells.
+
+## Acceptance criteria
+
+1. At 1440px, `/shows`, `/movies`, `/search` and a 20 title library page show 5 full rows of 4; at 390px, 2 columns.
+2. The grid is no wider than 1200px and is centred on every page.
+3. Loading each route shows a skeleton whose height matches the loaded page; a library page with 3 titles no longer collapses from 5 rows to 1.
+4. The selected SHOWS | MOVIES tab and the selected Watchlist / Upcoming / Watched link are cyan glass with near black text at 4.5:1 or better; unselected tabs look as before.
+5. No library page renders a legend under its grid.
+6. Every route, including `/sign-in`, `/account`, `/privacy` and a 404, shows the icon key centred at the top of the footer, with all seven entries.
+7. The showcase and `components/AGENTS.md` describe the new key.
+
+## Automated checks
+
+`pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`.
+
+## Manual test steps (`pnpm dev:docker`)
+
+1. Open `/shows` at 1440px: 4 columns, 5 full rows, grid at most 1200px wide and centred, pagination below. Resize to 390px: 2 columns, no horizontal scroll.
+2. Throttle the network (DevTools, Slow 4G) and open `/shows?page=2`: the skeleton grid and pagination placeholder hold the page height; nothing jumps when cards arrive.
+3. Sign in with an account tracking 3 shows, open `/watchlist`: the skeleton is one row, and the page does not shrink when the cards load.
+4. Open `/shows/1399` and a season page with throttling: no visible resize when content arrives; seasons are 4 per row.
+5. Check the navbar on `/shows`, `/movies?page=2`, `/watchlist?type=movie`: the lit tab is cyan glass with dark text; keyboard Tab shows the focus ring on it.
+6. Scroll to the footer on `/shows`, `/sign-in`, `/privacy` and `/nope`: the icon key is the footer's centred first row with all seven entries; at 390px it wraps centred.
+7. Open `/showcase`: the tab and key examples match the shipped UI.
From 6d1bd3346d27230ec5a94fcdfc7f35ac402895bb Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:04:06 +0200
Subject: [PATCH 02/25] feat(ui): regrid posters to at most four columns,
centred
A page holds 20 titles, which fills every row at two and four columns, so
the grid steps from 2 to 4 at md and never goes wider. It is capped at
1200px and centred; POSTER_SIZES and the eager poster counts follow.
Co-Authored-By: Claude Opus 5.5
---
app/movies/page.tsx | 6 +--
app/shows/page.tsx | 6 +--
components/AGENTS.md | 2 +-
components/library/library-grid.tsx | 2 +-
components/library/show-library-tab.tsx | 2 +-
components/poster-card.tsx | 7 ++-
components/poster-grid.test.tsx | 64 +++++++++++++++---------
components/poster-grid.tsx | 12 +++--
components/search/search-results.tsx | 2 +-
components/show/show-detail-skeleton.tsx | 15 ++----
10 files changed, 67 insertions(+), 51 deletions(-)
diff --git a/app/movies/page.tsx b/app/movies/page.tsx
index a6c2bc2..c327c8d 100644
--- a/app/movies/page.tsx
+++ b/app/movies/page.tsx
@@ -18,10 +18,10 @@ import { discoverMovies, TmdbError } from "@/lib/tmdb";
const PAGE_SIZE = 20;
/**
- * Cards whose posters load eagerly: one full row at the widest grid (6
- * columns at `xl`), so the first row is never lazy at any width (AC-1).
+ * Cards whose posters load eagerly: one full row at the widest grid (4
+ * columns from `md`), so the first row is never lazy at any width (AC-1).
*/
-const EAGER_POSTERS = 6;
+const EAGER_POSTERS = 4;
/** One URL per page: page 1 is the bare path. */
function pageHref(page: number): string {
diff --git a/app/shows/page.tsx b/app/shows/page.tsx
index 33e0eec..8c69ba3 100644
--- a/app/shows/page.tsx
+++ b/app/shows/page.tsx
@@ -18,10 +18,10 @@ import { discoverTvShows, TmdbError } from "@/lib/tmdb";
const PAGE_SIZE = 20;
/**
- * Cards whose posters load eagerly: one full row at the widest grid (6
- * columns at `xl`), so the first row is never lazy at any width (spec 0006, AC-1; spec 0009, AC-1).
+ * Cards whose posters load eagerly: one full row at the widest grid (4
+ * columns from `md`), so the first row is never lazy at any width (spec 0006, AC-1; spec 0009, AC-1).
*/
-const EAGER_POSTERS = 6;
+const EAGER_POSTERS = 4;
/** One URL per page: page 1 is the bare path. */
function pageHref(page: number): string {
diff --git a/components/AGENTS.md b/components/AGENTS.md
index e482468..c48ba54 100644
--- a/components/AGENTS.md
+++ b/components/AGENTS.md
@@ -33,7 +33,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- **The navbar is transparent and not sticky.** It has no background, border or blur and scrolls away with the page: pinned over scrolling content, a transparent bar is unreadable. Do not make it sticky again without giving it a background. Every control in it is 40px tall at every width: the `bar` and `icon-bar` button sizes, `h-7` tabs inside a pill padded so tab, padding and the 1.5px rim add up to 40px, and an `h-10` search field. The `hit-area` utility (and `hit-area-tab` for a tab) stretches each tap area back to 44px, so the touch rule still holds.
- **A dead end is a bug.** `StatePanel`'s `error` and `signed-out` variants cannot render without an action. The discriminated union makes it a type error and the component throws if the type is bypassed.
- **Touch targets** are 44px on mobile and 36px on desktop, the `touch` and `sm` sizes in `buttonVariants`. A control drawn smaller than 44px on mobile carries `hit-area` instead (root AGENTS.md section 3). One accepted exception: the footer's `kris1027` credit link has a 36px tap area, because 44px would mean taking taps from the legal links above it or widening the gap and moving the line.
-- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `POSTER_SIZES` string exported by `poster-card.tsx` so it stays matched to the grid's column counts (2 / 3 / 4 / 5 / 6 from base to `xl`).
+- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `POSTER_SIZES` string exported by `poster-card.tsx` so it stays matched to the grid's column counts (2 below `md`, 4 from it, in a grid capped at 1200px and centred, so a 20 title page is 5 full rows; prompts/grid-tabs-footer-legend.md).
- A missing poster renders the fallback tile at an identical footprint, so the grid never shifts. Never invent a placeholder image or placeholder metadata.
## Layout
diff --git a/components/library/library-grid.tsx b/components/library/library-grid.tsx
index 0444de1..c0aad35 100644
--- a/components/library/library-grid.tsx
+++ b/components/library/library-grid.tsx
@@ -42,7 +42,7 @@ import {
* Cards whose posters load eagerly: one full row at the widest grid, as on
* `/movies`.
*/
-const EAGER_POSTERS = 6;
+const EAGER_POSTERS = 4;
/**
* The movie grid on `/watchlist`, `/upcoming` and `/watched`, and everything
diff --git a/components/library/show-library-tab.tsx b/components/library/show-library-tab.tsx
index 1c027d7..d13ba60 100644
--- a/components/library/show-library-tab.tsx
+++ b/components/library/show-library-tab.tsx
@@ -23,7 +23,7 @@ import type { LibraryList } from "./types";
import { WatchlistShowCard } from "./watchlist-show-card";
/** Posters that load eagerly: one full row at the widest grid. */
-const EAGER_POSTERS = 6;
+const EAGER_POSTERS = 4;
/**
* A show tab (AC-9, AC-11, AC-12, AC-15 to AC-18): Watchlist with Mark
diff --git a/components/poster-card.tsx b/components/poster-card.tsx
index 02c5203..569a6dd 100644
--- a/components/poster-card.tsx
+++ b/components/poster-card.tsx
@@ -10,11 +10,10 @@ import type * as React from "react";
* Kept next to the card rather than in the grid because `next/image` needs it
* on the image, and kept as one constant because a grid change that misses it
* silently ships the wrong image width to every screen (spec 0004, AC-8).
- * Mirrors the AC-9 column counts: 2 at base, 3 at sm, 4 at md, 5 at lg, 6 at
- * xl.
+ * Mirrors the column counts: 2 at base, 4 from md, and a fixed width once the
+ * grid reaches its 1200px cap.
*/
-const POSTER_SIZES =
- "(min-width: 1280px) 16vw, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw";
+const POSTER_SIZES = "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw";
/**
* The single most repeated element in the UI: a 2:3 poster inside a rounded
diff --git a/components/poster-grid.test.tsx b/components/poster-grid.test.tsx
index d0964db..4267c4a 100644
--- a/components/poster-grid.test.tsx
+++ b/components/poster-grid.test.tsx
@@ -21,24 +21,33 @@ import { PosterGrid } from "@/components/poster-grid";
/** Tailwind's default breakpoints; `globals.css` does not override them. */
const COLUMNS_AT: ReadonlyArray<{ minWidth: number | null; columns: number }> =
[
- { minWidth: 1280, columns: 6 },
- { minWidth: 1024, columns: 5 },
{ minWidth: 768, columns: 4 },
- { minWidth: 640, columns: 3 },
{ minWidth: null, columns: 2 },
];
-/** `(min-width: 1280px) 16vw, ... , 50vw` parsed back into pairs. */
-function parseSizes(sizes: string) {
+/** The grid's `max-w-[1200px]` cap, reached once the viewport passes it plus the 16px gutters. */
+const GRID_CAP = { width: 1200, viewport: 1232, columns: 4, gap: 16 };
+
+type SizesEntry = {
+ minWidth: number | null;
+ vw: number | null;
+ px: number | null;
+};
+
+/** `(min-width: 1232px) 288px, ... , 50vw` parsed back into entries. */
+function parseSizes(sizes: string): SizesEntry[] {
return sizes.split(",").map((entry) => {
const trimmed = entry.trim();
- const match = trimmed.match(/^\(min-width:\s*(\d+)px\)\s*(\d+)vw$/);
- if (match) {
- return { minWidth: Number(match[1]), vw: Number(match[2]) };
- }
- const fallback = trimmed.match(/^(\d+)vw$/);
- if (!fallback) throw new Error(`unparsed sizes entry: ${trimmed}`);
- return { minWidth: null, vw: Number(fallback[1]) };
+ const match = trimmed.match(
+ /^(?:\(min-width:\s*(\d+)px\)\s*)?(\d+)(vw|px)$/,
+ );
+ if (!match) throw new Error(`unparsed sizes entry: ${trimmed}`);
+ const value = Number(match[2]);
+ return {
+ minWidth: match[1] === undefined ? null : Number(match[1]),
+ vw: match[3] === "vw" ? value : null,
+ px: match[3] === "px" ? value : null,
+ };
});
}
@@ -67,16 +76,17 @@ describe("PosterGrid", () => {
expect(items).toEqual(["First", "Second"]);
});
- it("steps 2, 3, 4, 5 then 6 columns across the breakpoints", () => {
+ it("steps from 2 to 4 columns at md and never goes wider", () => {
render( );
- expect(screen.getByRole("list")).toHaveClass(
+ const grid = screen.getByRole("list");
+ expect(grid).toHaveClass(
"grid-cols-2",
- "sm:grid-cols-3",
"md:grid-cols-4",
- "lg:grid-cols-5",
- "xl:grid-cols-6",
+ "max-w-[1200px]",
+ "mx-auto",
);
+ expect(grid.className).not.toMatch(/(?:sm|lg|xl|2xl):grid-cols-/);
});
it("keeps a caller class without dropping the column steps", () => {
@@ -86,17 +96,25 @@ describe("PosterGrid", () => {
});
it("matches POSTER_SIZES breakpoint for breakpoint to the column counts", () => {
- const parsed = parseSizes(POSTER_SIZES);
+ const [capped, ...fluid] = parseSizes(POSTER_SIZES);
- expect(parsed.map((entry) => entry.minWidth)).toEqual(
- COLUMNS_AT.map((entry) => entry.minWidth),
+ // Past the cap a column has a fixed width: the grid's share less the gaps,
+ // rounded up so the browser never picks an image narrower than the slot.
+ expect(capped.minWidth).toBe(GRID_CAP.viewport);
+ expect(capped.px).toBe(
+ Math.ceil(
+ (GRID_CAP.width - GRID_CAP.gap * (GRID_CAP.columns - 1)) /
+ GRID_CAP.columns,
+ ),
);
+ expect(fluid.map((entry) => entry.minWidth)).toEqual(
+ COLUMNS_AT.map((entry) => entry.minWidth),
+ );
for (const [index, { columns }] of COLUMNS_AT.entries()) {
// One column is 100/columns vw, floored: the gaps make a column slightly
- // narrower than its share, so rounding down is the right direction. That
- // is what turns 100/6 = 16.67 into the shipped 16vw.
- expect(parsed[index].vw).toBe(Math.floor(100 / columns));
+ // narrower than its share, so rounding down is the right direction.
+ expect(fluid[index].vw).toBe(Math.floor(100 / columns));
}
});
});
diff --git a/components/poster-grid.tsx b/components/poster-grid.tsx
index 95664b7..aff3e88 100644
--- a/components/poster-grid.tsx
+++ b/components/poster-grid.tsx
@@ -6,8 +6,14 @@ import type * as React from "react";
*
* Column counts are fixed here and mirrored by `POSTER_SIZES` in
* `poster-card.tsx`; changing one without the other ships wrong sized images.
- * Two columns at 390px keeps a poster wide enough to read its caption with no
- * horizontal page scroll at the narrowest width.
+ * Two columns below `md`, four from it: a page holds 20 titles, which fills
+ * every row at both counts (prompts/grid-tabs-footer-legend.md), where three
+ * or five columns would leave a ragged last row. Two columns at 390px keeps a
+ * poster wide enough to read its caption with no horizontal page scroll.
+ *
+ * The 1200px cap keeps a four column poster near 285px wide, so a full page
+ * stays a sensible length on a wide screen; past the cap the grid is centred
+ * on every page, under the centred pagination.
*
* A `` rather than a bare div: a collection of titles is a list, and that
* is what tells a screen reader how many results there are.
@@ -21,7 +27,7 @@ function PosterGrid({
;
const PAGE_SIZE = 20;
/** One full row at the widest grid loads its posters eagerly. */
-const EAGER_POSTERS = 6;
+const EAGER_POSTERS = 4;
/**
* The results half of `/search`, for one URL (spec 0010, AC-11 to AC-19).
diff --git a/components/show/show-detail-skeleton.tsx b/components/show/show-detail-skeleton.tsx
index 03fb181..9d373bf 100644
--- a/components/show/show-detail-skeleton.tsx
+++ b/components/show/show-detail-skeleton.tsx
@@ -23,11 +23,11 @@ function ShowDetailSkeleton() {
- {Array.from({ length: 6 }, (_, index) => (
+ {SEASON_CARD_VISIBILITY.map((visibility, index) => (
@@ -41,16 +41,9 @@ function ShowDetailSkeleton() {
/**
* Cards past the column count at each width are hidden, so the placeholder
- * row never wraps: 2 at base, 3 at `sm`, 4 at `md`, 5 at `lg`, 6 at `xl`.
+ * row never wraps: 2 below `md`, 4 from it.
*/
-const SEASON_CARD_VISIBILITY = [
- "",
- "",
- "hidden sm:block",
- "hidden md:block",
- "hidden lg:block",
- "hidden xl:block",
-];
+const SEASON_CARD_VISIBILITY = ["", "", "hidden md:block", "hidden md:block"];
/** The cast heading and row, also the fallback of the cast's own boundary. */
function CastSectionSkeleton() {
From a573649744179aa3b5511791535079e5c8a60f30 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:04:11 +0200
Subject: [PATCH 03/25] feat(layout): light the selected tab in the personal
score cyan
The selected SHOWS | MOVIES tab and Library link use a new
glass-selected-personal utility: the glass-selected stack tinted from
--color-score-personal, with dark text-on-personal at 6.3:1 or better.
Co-Authored-By: Claude Opus 5.5
---
app/globals.css | 42 ++++++++++++++++++++++++++
app/showcase/page.tsx | 8 +++++
components/AGENTS.md | 4 +--
components/layout/library-nav.test.tsx | 4 +--
components/layout/library-nav.tsx | 2 +-
components/layout/media-type-tabs.tsx | 2 +-
6 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/app/globals.css b/app/globals.css
index 9998bdc..e360590 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -60,6 +60,12 @@
--color-score-personal: #50c6e5;
--color-status-planned: #29cea3;
--color-status-planned-mark: #06251d;
+ /*
+ * Text on the cyan selected tab (`glass-selected-personal`). Near white on
+ * the cyan measures under 3:1, so the label goes dark, as the Planned check
+ * does on its green: 6.3:1 at the gradient's darkest stop.
+ */
+ --color-text-on-personal: #04161c;
/*
* The opaque plates painted under every glass surface. Glass alone over a
@@ -184,6 +190,42 @@
var(--glass-rim, none) border-box;
}
+/*
+ * The selected navbar tab (prompts/grid-tabs-footer-legend.md): the same
+ * layer stack as `glass-selected`, its gradient tinted from the personal
+ * score cyan so the lit tab reads in the colour of the user's own star. The
+ * stops stay translucent, so the plate and rim still show through as glass;
+ * at 80% the darkest stop over the control plate keeps
+ * `text-on-personal` at 6.3:1.
+ */
+@utility glass-selected-personal {
+ border: 1.5px solid transparent;
+ background:
+ linear-gradient(
+ 172deg,
+ color-mix(
+ in srgb,
+ color-mix(in srgb, var(--color-score-personal) 70%, white) 95%,
+ transparent
+ )
+ 0%,
+ color-mix(in srgb, var(--color-score-personal) 80%, transparent) 52%,
+ color-mix(
+ in srgb,
+ color-mix(in srgb, var(--color-score-personal) 85%, white) 90%,
+ transparent
+ )
+ 100%
+ )
+ padding-box,
+ linear-gradient(
+ var(--glass-plate, transparent) 0%,
+ var(--glass-plate, transparent) 100%
+ )
+ padding-box,
+ var(--glass-rim, none) border-box;
+}
+
/*
* A plated surface with the rim but no glass gradient: the missing poster
* fallback and the loading skeletons, which are the surfaces that should read
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index c7b3eec..91205c0 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -161,6 +161,14 @@ export default function ShowcasePage() {
Selected
+
+
+ SELECTED TAB
+
+
+ TAB
+
+
diff --git a/components/AGENTS.md b/components/AGENTS.md
index c48ba54..4d5d621 100644
--- a/components/AGENTS.md
+++ b/components/AGENTS.md
@@ -16,9 +16,9 @@ The rule is enforced by the components that own a glass surface (`GlassPill`, `P
All values live in `app/globals.css`, split across four blocks: `@theme inline` (the shadcn semantic remap and the radius scale), `@theme` (product tokens, which become real utilities such as `text-rating-tmdb` and `rounded-panel`), `:root` (the semantic values themselves), and the `@utility` definitions.
-- **Three colours carry meaning and nothing else may borrow them**: `--color-rating-tmdb` amber is always a TMDB community rating, `--color-score-personal` cyan is always the user's own score, `--color-status-planned` green is always Planned. The focus ring deliberately uses none of them.
+- **Three colours carry meaning and nothing else may borrow them**: `--color-rating-tmdb` amber is always a TMDB community rating, `--color-score-personal` cyan is always the user's own score, `--color-status-planned` green is always Planned. One deliberate exception, chosen by the owner on 2026-10-11: the selected navbar tab (`glass-selected-personal`) is lit in the personal cyan. The focus ring deliberately uses none of them.
- **Four plates**: `glass-plate` for controls and badges, `glass-plate-panel` for panels, `glass-plate-sheet` for the menu sheet and overlays, `glass-plate-score` for the personal score badge.
-- **Surface utilities**: `glass` (plate plus gradient plus rim), `glass-selected` (the brighter active variant), `plate-rim` (plate and rim, no gradient, for surfaces that should read as an absence: the missing poster tile and the skeletons), `rim` (the bare gradient stroke over artwork). Pick the rim colour with `glass-rim` or `glass-rim-score`.
+- **Surface utilities**: `glass` (plate plus gradient plus rim), `glass-selected` (the brighter active variant), `glass-selected-personal` (the selected navbar tab, tinted from the personal score cyan, with `text-on-personal` dark text for contrast), `plate-rim` (plate and rim, no gradient, for surfaces that should read as an absence: the missing poster tile and the skeletons), `rim` (the bare gradient stroke over artwork). Pick the rim colour with `glass-rim` or `glass-rim-score`.
- `rim` masks its own padding box out, so it clips everything the element paints. It belongs on an empty decorative overlay, never on an element with content or a background of its own.
- The rim uses the two layer background technique: plate gradient on `padding-box`, rim gradient on `border-box`, over a transparent 1.5px border. Not `border-image`, which ignores `border-radius`.
- **No component writes a colour literal.** [`design-tokens-boundary.test.ts`](../design-tokens-boundary.test.ts) walks the repository and fails the suite if a token hex, a Geist reference, or a dark variant appears outside `globals.css`, in the shape [`security-boundary.test.ts`](../security-boundary.test.ts) already established for the service role key.
diff --git a/components/layout/library-nav.test.tsx b/components/layout/library-nav.test.tsx
index e4f3974..2ba7e0a 100644
--- a/components/layout/library-nav.test.tsx
+++ b/components/layout/library-nav.test.tsx
@@ -64,10 +64,10 @@ describe("LibraryNav", () => {
const selected = link.getAttribute("href") === `${current}?type=movie`;
if (selected) {
expect(link).toHaveAttribute("aria-current", "page");
- expect(link).toHaveClass("glass-selected");
+ expect(link).toHaveClass("glass-selected-personal");
} else {
expect(link).not.toHaveAttribute("aria-current");
- expect(link).not.toHaveClass("glass-selected");
+ expect(link).not.toHaveClass("glass-selected-personal");
}
}
},
diff --git a/components/layout/library-nav.tsx b/components/layout/library-nav.tsx
index df91e88..cbd9f32 100644
--- a/components/layout/library-nav.tsx
+++ b/components/layout/library-nav.tsx
@@ -87,7 +87,7 @@ function LibraryNavView({
? "hit-area-tab h-7 px-4 text-sm"
: "h-11 px-4 text-base",
selected
- ? "glass-selected glass-rim text-foreground"
+ ? "glass-selected-personal glass-rim text-text-on-personal"
: "text-text-link hover:text-foreground",
)}
>
diff --git a/components/layout/media-type-tabs.tsx b/components/layout/media-type-tabs.tsx
index 31de903..58f1032 100644
--- a/components/layout/media-type-tabs.tsx
+++ b/components/layout/media-type-tabs.tsx
@@ -111,7 +111,7 @@ function MediaTypeTabsView({
className={cn(
"hit-area-tab flex h-7 items-center rounded-full px-4 text-xs font-semibold tracking-[0.07em] transition-[filter,color]",
isSelected
- ? "glass-selected glass-rim text-foreground"
+ ? "glass-selected-personal glass-rim text-text-on-personal"
: "text-text-secondary hover:text-foreground",
)}
>
From aadc21973aa603cf4fc5b04684cd1ca06084425a Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:04:15 +0200
Subject: [PATCH 04/25] feat(layout): move the badge legend to a footer icon
key on every route
The per tab BadgeLegend under the library grids is gone. IconKey, a static
Server Component, is the footer's first row everywhere, centred under a
hairline, and always lists every card mark in a fixed order.
Co-Authored-By: Claude Opus 5.5
---
app/showcase/page.tsx | 8 +-
components/AGENTS.md | 2 +-
components/layout/icon-key.test.tsx | 49 ++++++++++++
components/layout/icon-key.tsx | 66 ++++++++++++++++
components/layout/site-footer.test.tsx | 10 +++
components/layout/site-footer.tsx | 14 +++-
components/library/badge-legend.tsx | 84 ---------------------
components/library/library-section.test.tsx | 52 ++++---------
components/library/movie-library-tab.tsx | 3 -
components/library/show-library-tab.tsx | 3 -
10 files changed, 156 insertions(+), 135 deletions(-)
create mode 100644 components/layout/icon-key.test.tsx
create mode 100644 components/layout/icon-key.tsx
delete mode 100644 components/library/badge-legend.tsx
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index 91205c0..3d53014 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -230,10 +230,10 @@ export default function ShowcasePage() {
A TMDB badge with no value renders nothing; a personal score with no
value renders “Not rated”. Plan, Planned and the watched
marks are the movie tracking marks from spec 0007, drawn from the
- legend’s own paths. Next episode is the Watchlist show
- card’s pill, and the dated and Date TBA pills sit on Upcoming
- cards (spec 0020); below sm a date that carries its year
- drops the calendar glyph.
+ original legend’s paths and listed in the footer’s icon
+ key. Next episode is the Watchlist show card’s pill, and the
+ dated and Date TBA pills sit on Upcoming cards (spec 0020); below{" "}
+ sm a date that carries its year drops the calendar glyph.
diff --git a/components/AGENTS.md b/components/AGENTS.md
index 4d5d621..ed46928 100644
--- a/components/AGENTS.md
+++ b/components/AGENTS.md
@@ -44,7 +44,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006).
- `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo.
- `components/layout/site-footer.tsx` renders under every route from the root layout and must read no request state (`app/layout-purity.test.ts`). It is flat (no glass, no border) and carries no email address. Its credit line links `OPERATOR_HANDLE` to `OPERATOR_GITHUB_URL`, both from `lib/legal/operator.ts`. `components/legal/` holds the reading column and helpers `/privacy` and `/terms` share; use `LEGAL_LINK_CLASS` for any link in legal text (spec 0017).
-- The six badges in the badge legend (Planned, Plan, Stop watching, Watch again, Next episode, Release date) are built by features 8, 12, 14 and 15. A new badge must reuse `GlassPill` rather than re-derive it.
+- The card marks are explained once, by `layout/icon-key.tsx`, the footer's first row on every route; there is no per page legend. A new mark adds its entry there, and a new badge must reuse `GlassPill` rather than re-derive it.
## Checking your work
diff --git a/components/layout/icon-key.test.tsx b/components/layout/icon-key.test.tsx
new file mode 100644
index 0000000..44dd36c
--- /dev/null
+++ b/components/layout/icon-key.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen, within } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { IconKey } from "./icon-key";
+
+/**
+ * covers: prompts/grid-tabs-footer-legend.md, acceptance criterion 6
+ *
+ * The key lists every card mark on every route, so the whole list and its
+ * order are pinned, not a subset.
+ */
+describe("IconKey", () => {
+ it("always lists every mark, in the fixed order", () => {
+ render( );
+
+ const key = screen.getByRole("list", { name: "Icon key" });
+ expect(
+ within(key)
+ .getAllByRole("listitem")
+ .map((item) => item.textContent),
+ ).toEqual([
+ "TMDB rating",
+ "Your score",
+ "Plan",
+ "Planned",
+ "Next episode",
+ "Mark watched",
+ "Release date",
+ ]);
+ });
+
+ it("hides every icon from assistive technology, leaving the label", () => {
+ render( );
+
+ for (const item of screen.getAllByRole("listitem")) {
+ const svg = item.querySelector("svg");
+ expect(svg?.closest('[aria-hidden="true"]')).not.toBeNull();
+ }
+ });
+
+ it("centres the entries and wraps them", () => {
+ render( );
+
+ expect(screen.getByRole("list", { name: "Icon key" })).toHaveClass(
+ "flex-wrap",
+ "justify-center",
+ );
+ });
+});
diff --git a/components/layout/icon-key.tsx b/components/layout/icon-key.tsx
new file mode 100644
index 0000000..c149554
--- /dev/null
+++ b/components/layout/icon-key.tsx
@@ -0,0 +1,66 @@
+import { StarIcon } from "lucide-react";
+import type * as React from "react";
+
+import {
+ CalendarIcon,
+ NextEpisodeIcon,
+ PlanIcon,
+ PlannedIcon,
+ WatchedIcon,
+} from "@/components/tracking/tracking-icons";
+
+/**
+ * Every mark a card can carry, in one fixed order. The icons and meaning
+ * colours are the badges' own, so the key can never drift from what the
+ * cards show.
+ */
+const ENTRIES: ReadonlyArray<{ label: string; icon: React.ReactNode }> = [
+ {
+ label: "TMDB rating",
+ icon: ,
+ },
+ {
+ label: "Your score",
+ icon: (
+
+ ),
+ },
+ { label: "Plan", icon: },
+ { label: "Planned", icon: },
+ { label: "Next episode", icon: },
+ {
+ label: "Mark watched",
+ icon: ,
+ },
+ { label: "Release date", icon: },
+];
+
+/**
+ * The key to the card marks, centred at the top of the footer on every route
+ * (prompts/grid-tabs-footer-legend.md). It replaced the per tab legend under
+ * the library grids: one key that always lists every mark is easier to find
+ * than one that changes with the page, and it costs the grids no height.
+ *
+ * Static, with no request read, so the footer stays in every prerendered
+ * shell (`app/layout-purity.test.ts`). The icons are decorative; each
+ * entry's text names it.
+ */
+export function IconKey() {
+ return (
+
+
+ {ENTRIES.map((entry) => (
+
+
+ {entry.icon}
+
+ {entry.label}
+
+ ))}
+
+
+ );
+}
diff --git a/components/layout/site-footer.test.tsx b/components/layout/site-footer.test.tsx
index 4e1af4e..cf052f0 100644
--- a/components/layout/site-footer.test.tsx
+++ b/components/layout/site-footer.test.tsx
@@ -23,6 +23,16 @@ const { SiteFooter } = await import("./site-footer");
* the contract with `buttonVariants`' sizes, and the look stays a browser step.
*/
describe("SiteFooter", () => {
+ it("opens with the icon key, before TMDB's notice", () => {
+ render( );
+
+ const key = screen.getByRole("list", { name: "Icon key" });
+ const notice = screen.getByText(TMDB_ATTRIBUTION);
+ expect(
+ key.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
it("renders TMDB's current notice from the one constant (AC-3)", () => {
render( );
expect(screen.getByText(TMDB_ATTRIBUTION)).toBeInTheDocument();
diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx
index 46b448b..1c11fc8 100644
--- a/components/layout/site-footer.tsx
+++ b/components/layout/site-footer.tsx
@@ -5,10 +5,12 @@ import type * as React from "react";
import { OPERATOR_GITHUB_URL, OPERATOR_HANDLE } from "@/lib/legal/operator";
import { TMDB_ATTRIBUTION } from "@/lib/tmdb";
+import { IconKey } from "./icon-key";
+
/**
- * The quiet footer under every route: TMDB's logo and the notice its API terms
- * require, then the two legal pages and the credit line (spec 0017, AC-1 to
- * AC-5). The credit names the operator by handle, linked to their GitHub
+ * The quiet footer under every route: the icon key to the card marks
+ * (`IconKey`), then TMDB's logo and the notice its API terms require, then
+ * the two legal pages and the credit line (spec 0017, AC-1 to AC-5). The credit names the operator by handle, linked to their GitHub
* profile, the same way the legal pages do.
*
* A Server Component that reads no cookie, header or session, so it sits in
@@ -16,12 +18,16 @@ import { TMDB_ATTRIBUTION } from "@/lib/tmdb";
* that). It carries no email address on purpose: the contact address lives
* only on `/privacy` and `/terms`, off the pages scrapers crawl most.
*
- * Flat, with no glass, blur or border, because it sits on the plain black
+ * Flat, with no glass, blur or border of its own (the key's hairline only
+ * separates it from the page above), because it sits on the plain black
* page rather than over artwork (`components/AGENTS.md`, backdrop blur).
*/
export function SiteFooter() {
return (
+
+
+
- ),
-};
-const PLANNED = { label: "Planned", icon:
};
-const YOUR_SCORE = {
- label: "Your score",
- icon: (
-
- ),
-};
-const NEXT_EPISODE = {
- label: "Next episode",
- icon:
,
-};
-const MARK_WATCHED = {
- label: "Mark watched",
- icon:
,
-};
-
-const ENTRIES = {
- watchlist: {
- movie: [TMDB_RATING, PLANNED],
- tv: [NEXT_EPISODE, MARK_WATCHED],
- },
- upcoming: {
- movie: [
- { label: "Release date", icon:
},
- PLANNED,
- ],
- tv: [{ label: "Air date", icon:
}],
- },
- watched: { movie: [YOUR_SCORE], tv: [YOUR_SCORE] },
-} as const;
-
-function BadgeLegend({ list, type }: { list: LibraryList; type: MediaType }) {
- return (
-
-
- {ENTRIES[list][type].map((entry) => (
-
- {entry.icon}
- {entry.label}
-
- ))}
-
-
- );
-}
-
-export { BadgeLegend };
diff --git a/components/library/library-section.test.tsx b/components/library/library-section.test.tsx
index 465998e..c360ec6 100644
--- a/components/library/library-section.test.tsx
+++ b/components/library/library-section.test.tsx
@@ -357,42 +357,22 @@ describe("show cards (AC-9 to AC-12)", () => {
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
- it.each([
- ["watchlist", ["Next episode", "Mark watched"]],
- ["upcoming", ["Air date"]],
- ["watched", ["Your score"]],
- ] as const)(
- "keeps the %s shows legend to its own badges",
- async (list, entries) => {
- getShowLibraryTab.mockResolvedValue(
- tab([
- list === "watchlist"
- ? {
- page: "watchlist",
- showId: 1,
- title: TITLE,
- next: { season: 1, episode: 1 },
- }
- : list === "upcoming"
- ? {
- page: "upcoming",
- showId: 1,
- title: TITLE,
- airDate: null,
- next: null,
- }
- : { page: "watched", showId: 1, title: TITLE, label: "finished" },
- ]),
- );
- await renderSection(list, { type: "tv" });
- const legend = screen.getByRole("list", { name: "Badge legend" });
- expect(
- within(legend)
- .getAllByRole("listitem")
- .map((item) => item.textContent),
- ).toEqual(entries);
- },
- );
+ it("leaves the icon key to the footer, with no legend under the grid", async () => {
+ getShowLibraryTab.mockResolvedValue(
+ tab([
+ {
+ page: "watchlist",
+ showId: 1,
+ title: TITLE,
+ next: { season: 1, episode: 1 },
+ },
+ ]),
+ );
+ await renderSection("watchlist", { type: "tv" });
+ expect(
+ screen.queryByRole("list", { name: /legend|icon key/i }),
+ ).not.toBeInTheDocument();
+ });
});
describe("movie cards (AC-13)", () => {
diff --git a/components/library/movie-library-tab.tsx b/components/library/movie-library-tab.tsx
index 91e1fe0..58c7ad2 100644
--- a/components/library/movie-library-tab.tsx
+++ b/components/library/movie-library-tab.tsx
@@ -14,7 +14,6 @@ import {
} from "@/lib/tracking/library-lists";
import { LIBRARY_COPY } from "@/lib/tracking/messages";
-import { BadgeLegend } from "./badge-legend";
import { LibraryGrid } from "./library-grid";
import {
EmptyPanel,
@@ -169,8 +168,6 @@ function MovieGridSection({
href={(target) => pageHref(list, "movie", target)}
/>
) : null}
-
-
);
}
diff --git a/components/library/show-library-tab.tsx b/components/library/show-library-tab.tsx
index d13ba60..831a891 100644
--- a/components/library/show-library-tab.tsx
+++ b/components/library/show-library-tab.tsx
@@ -8,7 +8,6 @@ import {
import { LIBRARY_COPY } from "@/lib/tracking/messages";
import { getShowRatings } from "@/lib/tracking/show-ratings";
-import { BadgeLegend } from "./badge-legend";
import { LIBRARY_HEADING_ID } from "./ids";
import {
EmptyPanel,
@@ -88,8 +87,6 @@ export async function showLibraryTab(query: LibraryTabQuery
) {
href={(target) => pageHref(list, "tv", target)}
/>
) : null}
-
-
);
}
From 3b85b2b9ca097d5b62fb73f78950c81455da28e5 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:04:19 +0200
Subject: [PATCH 05/25] fix(ui): make every skeleton match the footprint of
what replaces it
Library pages draw one row instead of 20 cards, so a short list no longer
collapses on load. Catalog and search skeletons reserve the pagination row
and size captions with h-[1lh]; the detail and season skeletons mirror
their real layouts line for line.
Co-Authored-By: Claude Opus 5.5
---
app/movies/page.tsx | 21 ++--
app/shows/page.tsx | 21 ++--
components/catalog/detail-skeletons.tsx | 117 ++++++++++++++++-----
components/library/library-panels.tsx | 15 ++-
components/movie/movie-detail-skeleton.tsx | 4 +-
components/search/search-results.tsx | 14 ++-
components/show/season-skeleton.tsx | 73 ++++++++++---
components/show/show-detail-skeleton.tsx | 11 +-
components/skeleton.test.tsx | 25 +++++
components/skeleton.tsx | 31 +++++-
10 files changed, 256 insertions(+), 76 deletions(-)
diff --git a/app/movies/page.tsx b/app/movies/page.tsx
index c327c8d..d73d5ec 100644
--- a/app/movies/page.tsx
+++ b/app/movies/page.tsx
@@ -6,7 +6,7 @@ import { PosterCard } from "@/components/poster-card";
import { PosterGrid } from "@/components/poster-grid";
import { TmdbRatingBadge } from "@/components/rating-badges";
import { RetryLink } from "@/components/retry-link";
-import { PosterCardSkeleton } from "@/components/skeleton";
+import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
import { CardBookmark } from "@/components/tracking/card-bookmark";
import { ButtonLink } from "@/components/ui/button";
@@ -160,13 +160,16 @@ function NoSuchPage() {
function PopularMoviesSkeleton() {
return (
-
- {Array.from({ length: PAGE_SIZE }, (_, index) => (
- // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
-
-
-
- ))}
-
+
+
+ {Array.from({ length: PAGE_SIZE }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
+
+
+
+ ))}
+
+
+
);
}
diff --git a/app/shows/page.tsx b/app/shows/page.tsx
index 8c69ba3..17bbb66 100644
--- a/app/shows/page.tsx
+++ b/app/shows/page.tsx
@@ -6,7 +6,7 @@ import { PosterCard } from "@/components/poster-card";
import { PosterGrid } from "@/components/poster-grid";
import { TmdbRatingBadge } from "@/components/rating-badges";
import { RetryLink } from "@/components/retry-link";
-import { PosterCardSkeleton } from "@/components/skeleton";
+import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
import { ShowCardBookmark } from "@/components/tracking/show-card-bookmark";
import { ButtonLink } from "@/components/ui/button";
@@ -161,13 +161,16 @@ function NoSuchPage() {
function PopularShowsSkeleton() {
return (
-
- {Array.from({ length: PAGE_SIZE }, (_, index) => (
- // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
-
-
-
- ))}
-
+
+
+ {Array.from({ length: PAGE_SIZE }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
+
+
+
+ ))}
+
+
+
);
}
diff --git a/components/catalog/detail-skeletons.tsx b/components/catalog/detail-skeletons.tsx
index 883a64e..f24abff 100644
--- a/components/catalog/detail-skeletons.tsx
+++ b/components/catalog/detail-skeletons.tsx
@@ -1,3 +1,5 @@
+import { cn } from "cn";
+
import { Skeleton } from "@/components/skeleton";
/**
@@ -9,53 +11,104 @@ import { Skeleton } from "@/components/skeleton";
* collapses upwards when it arrives, which is a smaller jump than reserving no
* space for the common case.
*/
-function DetailHeroSkeleton() {
+function DetailHeroSkeleton({ metaRows = 1 }: { metaRows?: 1 | 2 }) {
return (
-
-
-
-
-
-
-
-
-
+ {/*
+ * The same grid as `DetailHero`, so the info block lands where the
+ * real one does at every width: under the poster on mobile, beside it
+ * from `sm`. Each line sits in a box one line of the real type tall
+ * (`h-[1lh]`), and each row of pills at the real row's height.
+ */}
+
-
-
-
-
+
+ {Array.from({ length: metaRows }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
+
+
+
+
+
+ ))}
+
+
-
);
}
-/** A section heading and a few paragraph lines, at the overview's width. */
+/**
+ * The section heading every detail page section opens with, one line of its
+ * `text-xl md:text-2xl leading-tight` type tall.
+ */
+function SectionHeadingSkeleton({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The overview: its heading and paragraph lines at the body's
+ * `leading-relaxed` line height. Four lines from `md`, eight below it, the
+ * length a typical TMDB overview wraps to at each width.
+ */
function OverviewSkeleton() {
return (
-
-
-
-
+
+
+ {OVERVIEW_LINES.map((className, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
+
+
+
+ ))}
+
);
}
-/** One row of cast cards at `CastRow`'s card widths, without a heading. */
+const OVERVIEW_LINES = [
+ "flex",
+ "flex",
+ "flex",
+ "flex",
+ "flex md:hidden",
+ "flex md:hidden",
+ "flex md:hidden",
+ "flex md:hidden",
+];
+
+/**
+ * One row of cast cards at `CastRow`'s card widths, without a heading. The
+ * caption reserves two lines each for the name and the character, the most
+ * `CastRow` wraps them to, and `pb-3` matches the row's own padding.
+ */
function CastRowSkeleton() {
return (
-
+
{Array.from({ length: 8 }, (_, index) => (
))}
);
}
-export { CastRowSkeleton, DetailHeroSkeleton, OverviewSkeleton };
+export {
+ CastRowSkeleton,
+ DetailHeroSkeleton,
+ OverviewSkeleton,
+ SectionHeadingSkeleton,
+};
diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx
index 25bafbd..52b0561 100644
--- a/components/library/library-panels.tsx
+++ b/components/library/library-panels.tsx
@@ -13,7 +13,6 @@ import {
} from "@/lib/catalog/media-type";
import {
LIBRARY_CLASSIFY_LIMIT,
- LIBRARY_PAGE_SIZE,
type LibraryTab,
libraryLastPage,
} from "@/lib/tracking/library-lists";
@@ -232,16 +231,24 @@ function LoadFailed({
);
}
-/** The static shell's stand in for the list, at the same footprint. */
+/**
+ * The static shell's stand in for the list: one row of cards, two below `md`
+ * and four from it. The real count is unknown until the read, and a full page
+ * of placeholders made a short library collapse by several rows when it
+ * arrived (prompts/grid-tabs-footer-legend.md); one row is the smallest
+ * honest guess and never wraps.
+ */
export function LibrarySkeleton() {
return (
- {Array.from({ length: LIBRARY_PAGE_SIZE }, (_, index) => (
+ {ONE_ROW.map((visibility, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
-
+
))}
);
}
+
+const ONE_ROW = ["", "", "hidden md:block", "hidden md:block"];
diff --git a/components/movie/movie-detail-skeleton.tsx b/components/movie/movie-detail-skeleton.tsx
index f8e8d1e..c848ebc 100644
--- a/components/movie/movie-detail-skeleton.tsx
+++ b/components/movie/movie-detail-skeleton.tsx
@@ -2,8 +2,8 @@ import {
CastRowSkeleton,
DetailHeroSkeleton,
OverviewSkeleton,
+ SectionHeadingSkeleton,
} from "@/components/catalog/detail-skeletons";
-import { Skeleton } from "@/components/skeleton";
/**
* The movie page while TMDB answers, at the footprint of what replaces it
@@ -23,7 +23,7 @@ function MovieDetailSkeleton() {
-
+
diff --git a/components/search/search-results.tsx b/components/search/search-results.tsx
index 09ca5f3..fc8667e 100644
--- a/components/search/search-results.tsx
+++ b/components/search/search-results.tsx
@@ -5,7 +5,11 @@ import { PosterCard } from "@/components/poster-card";
import { PosterGrid } from "@/components/poster-grid";
import { TmdbRatingBadge } from "@/components/rating-badges";
import { RetryLink } from "@/components/retry-link";
-import { PosterCardSkeleton, Skeleton } from "@/components/skeleton";
+import {
+ PaginationSkeleton,
+ PosterCardSkeleton,
+ Skeleton,
+} from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
import { CardBookmark } from "@/components/tracking/card-bookmark";
import { ShowCardBookmark } from "@/components/tracking/show-card-bookmark";
@@ -356,7 +360,10 @@ function resultsKey(raw: RawParams): string {
);
}
-/** The loading shape of the results: the count line and 20 cards (AC-21). */
+/**
+ * The loading shape of the results: the count line, 20 cards and the
+ * pagination row (AC-21).
+ */
function SearchResultsSkeleton() {
return (
@@ -365,10 +372,11 @@ function SearchResultsSkeleton() {
{Array.from({ length: PAGE_SIZE }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
-
+
))}
+
);
}
diff --git a/components/show/season-skeleton.tsx b/components/show/season-skeleton.tsx
index 0cab8aa..289f250 100644
--- a/components/show/season-skeleton.tsx
+++ b/components/show/season-skeleton.tsx
@@ -1,23 +1,49 @@
+import { cn } from "cn";
+
import { Skeleton } from "@/components/skeleton";
/**
* A season page while TMDB answers (spec 0009, AC-17): the back link, the
* compact header, and a few episode rows at their footprint.
+ *
+ * Each piece copies the layout classes of what replaces it (`SeasonHeader`,
+ * `EpisodeRow`), and each text line sits in a box one line of the real type
+ * tall (`h-[1lh]`), so swapping one for the other moves nothing but the
+ * episode count, which is unknown until TMDB answers
+ * (prompts/grid-tabs-footer-legend.md).
*/
function SeasonSkeleton() {
return (
-
+
Loading season
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {/* The tracking row: the season control and its progress. */}
+
+
+
+
+
+
+
+
+
@@ -30,14 +56,22 @@ function SeasonSkeleton() {
>
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
))}
@@ -46,4 +80,13 @@ function SeasonSkeleton() {
);
}
+/** One line of text in the type its parent or `className` sets. */
+function Line({ className, width }: { className?: string; width: string }) {
+ return (
+
+
+
+ );
+}
+
export { SeasonSkeleton };
diff --git a/components/show/show-detail-skeleton.tsx b/components/show/show-detail-skeleton.tsx
index 9d373bf..b654a5d 100644
--- a/components/show/show-detail-skeleton.tsx
+++ b/components/show/show-detail-skeleton.tsx
@@ -2,9 +2,10 @@ import {
CastRowSkeleton,
DetailHeroSkeleton,
OverviewSkeleton,
+ SectionHeadingSkeleton,
} from "@/components/catalog/detail-skeletons";
import { PosterGrid } from "@/components/poster-grid";
-import { PosterCardSkeleton, Skeleton } from "@/components/skeleton";
+import { PosterCardSkeleton } from "@/components/skeleton";
/**
* The show page while TMDB answers (spec 0009, AC-17): the hero, the
@@ -18,10 +19,10 @@ function ShowDetailSkeleton() {
Loading show details
-
+
-
+
{SEASON_CARD_VISIBILITY.map((visibility, index) => (
-
+
))}
@@ -49,7 +50,7 @@ const SEASON_CARD_VISIBILITY = ["", "", "hidden md:block", "hidden md:block"];
function CastSectionSkeleton() {
return (
-
+
);
diff --git a/components/skeleton.test.tsx b/components/skeleton.test.tsx
index cd30a9e..903aa37 100644
--- a/components/skeleton.test.tsx
+++ b/components/skeleton.test.tsx
@@ -84,6 +84,31 @@ describe("PosterCardSkeleton", () => {
expect(container.querySelectorAll("[data-slot=skeleton]")).toHaveLength(2);
});
+ it("reserves one line of the caption's own type, not a bare bar", () => {
+ const { container } = render(
);
+ const { container: loaded } = render(
+
,
+ );
+
+ const line = container.querySelector(
+ "[data-slot=skeleton]:not(.aspect-2\\/3)",
+ );
+ expect(line?.parentElement).toHaveClass(
+ "h-[1lh]",
+ "text-sm",
+ "leading-snug",
+ );
+ expect(loaded.querySelector("h3")).toHaveClass("text-sm", "leading-snug");
+ });
+
+ it("adds the meta line only when asked, as on a season card", () => {
+ const { container: plain } = render(
);
+ const { container: withMeta } = render(
);
+
+ expect(plain.querySelectorAll("[data-slot=skeleton]")).toHaveLength(2);
+ expect(withMeta.querySelectorAll("[data-slot=skeleton]")).toHaveLength(3);
+ });
+
it("announces nothing at all while it is standing in", () => {
const { container } = render(
);
diff --git a/components/skeleton.tsx b/components/skeleton.tsx
index 7584b65..027a4f3 100644
--- a/components/skeleton.tsx
+++ b/components/skeleton.tsx
@@ -52,13 +52,38 @@ function Skeleton({
* The loading shape of one `PosterCard`, kept beside the card's real layout so
* the two cannot drift apart.
*/
-function PosterCardSkeleton() {
+function PosterCardSkeleton({ meta = false }: { meta?: boolean }) {
return (
-
+ {/*
+ * One line of the caption's own type tall (`h-[1lh]`), not a bare 16px
+ * bar: a 20 card grid of 16px stand ins grows by a row's worth when the
+ * 19px captions arrive. `meta` adds the season card's second line.
+ */}
+
+
+
+ {meta ? (
+
+
+
+ ) : null}
);
}
-export { PosterCardSkeleton, Skeleton };
+/**
+ * The stand in for `PaginationLinks` under a paged grid while it streams: one
+ * pill at the links' height (the 44px `touch` size, 36px from `md`), so the
+ * page does not grow by a row when the links arrive.
+ */
+function PaginationSkeleton() {
+ return (
+
+
+
+ );
+}
+
+export { PaginationSkeleton, PosterCardSkeleton, Skeleton };
From 4360fbc7565445f7f208a2d2fa0d6e54ed9dde3f Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:05:13 +0200
Subject: [PATCH 06/25] feat(layout): pair Mark watched with Watched in the
icon key
The key lists each two state mark as action then done, so the filled
watched check joins its outline the way Planned joins Plan.
Co-Authored-By: Claude Opus 5.5
---
components/layout/icon-key.test.tsx | 3 ++-
components/layout/icon-key.tsx | 6 ++++--
prompts/grid-tabs-footer-legend.md | 6 +++---
3 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/components/layout/icon-key.test.tsx b/components/layout/icon-key.test.tsx
index 44dd36c..b735450 100644
--- a/components/layout/icon-key.test.tsx
+++ b/components/layout/icon-key.test.tsx
@@ -23,8 +23,9 @@ describe("IconKey", () => {
"Your score",
"Plan",
"Planned",
- "Next episode",
"Mark watched",
+ "Watched",
+ "Next episode",
"Release date",
]);
});
diff --git a/components/layout/icon-key.tsx b/components/layout/icon-key.tsx
index c149554..50a6870 100644
--- a/components/layout/icon-key.tsx
+++ b/components/layout/icon-key.tsx
@@ -12,7 +12,8 @@ import {
/**
* Every mark a card can carry, in one fixed order. The icons and meaning
* colours are the badges' own, so the key can never drift from what the
- * cards show.
+ * cards show. Each two state mark is listed as a pair, action then done:
+ * Plan and Planned, Mark watched and Watched.
*/
const ENTRIES: ReadonlyArray<{ label: string; icon: React.ReactNode }> = [
{
@@ -27,11 +28,12 @@ const ENTRIES: ReadonlyArray<{ label: string; icon: React.ReactNode }> = [
},
{ label: "Plan", icon: },
{ label: "Planned", icon: },
- { label: "Next episode", icon: },
{
label: "Mark watched",
icon: ,
},
+ { label: "Watched", icon: },
+ { label: "Next episode", icon: },
{ label: "Release date", icon: },
];
diff --git a/prompts/grid-tabs-footer-legend.md b/prompts/grid-tabs-footer-legend.md
index e1ae323..9ed4fd4 100644
--- a/prompts/grid-tabs-footer-legend.md
+++ b/prompts/grid-tabs-footer-legend.md
@@ -36,7 +36,7 @@ Branch `feat/grid-tabs-footer-legend`. No spec: this plan records the decisions.
6. **Selected tab: cyan glass.** A new `glass-selected-personal` utility in `app/globals.css`, the same layer stack as `glass-selected` (fill, plate, rim on the 1.5px border) with the gradient tinted from `--color-score-personal`, translucent so the glass reads. Text on it is near black (a token, e.g. `text-on-personal`) so it meets WCAG AA 4.5:1; the exact tint is checked with a contrast measurement, not assumed. Used by the selected tab in `MediaTypeTabsView` and in both `LibraryNav` variants. Unselected tabs do not change.
7. **Footer icon key.**
- `BadgeLegend` and its per tab entries are deleted, with their test cases; the two library tabs no longer render it.
- - A new `IconKey` Server Component (`components/layout/icon-key.tsx`) renders one fixed list, always all entries, in this order: TMDB rating, Your score, Plan, Planned, Next episode, Mark watched, Release date. Same icons and colours as the cards (`StarIcon` in `rating-tmdb` / `score-personal`, `PlanIcon`, `PlannedIcon`, `NextEpisodeIcon`, `WatchedIcon` outline, `CalendarIcon`), `size-4`, `text-text-legend`.
+ - A new `IconKey` Server Component (`components/layout/icon-key.tsx`) renders one fixed list, always all entries, in this order: TMDB rating, Your score, Plan, Planned, Mark watched, Watched, Next episode, Release date. Same icons and colours as the cards (`StarIcon` in `rating-tmdb` / `score-personal`, `PlanIcon`, `PlannedIcon`, `NextEpisodeIcon`, `WatchedIcon` outline and filled, `CalendarIcon`), `size-4`, `text-text-legend`.
- It is the footer's first row, on every route, inside the footer's 1600px container: a hairline (`border-t border-border`) above, centred `flex-wrap` with the current legend gaps, wrapping centred on mobile. The existing attribution and legal row sits below it unchanged.
- ``, each `` the icon (`aria-hidden`) and its text.
- It reads no request state, so the footer stays in every prerendered shell.
@@ -72,7 +72,7 @@ UI only: no new data, input, route or credential. The footer key is static, so i
3. Loading each route shows a skeleton whose height matches the loaded page; a library page with 3 titles no longer collapses from 5 rows to 1.
4. The selected SHOWS | MOVIES tab and the selected Watchlist / Upcoming / Watched link are cyan glass with near black text at 4.5:1 or better; unselected tabs look as before.
5. No library page renders a legend under its grid.
-6. Every route, including `/sign-in`, `/account`, `/privacy` and a 404, shows the icon key centred at the top of the footer, with all seven entries.
+6. Every route, including `/sign-in`, `/account`, `/privacy` and a 404, shows the icon key centred at the top of the footer, with all eight entries.
7. The showcase and `components/AGENTS.md` describe the new key.
## Automated checks
@@ -86,5 +86,5 @@ UI only: no new data, input, route or credential. The footer key is static, so i
3. Sign in with an account tracking 3 shows, open `/watchlist`: the skeleton is one row, and the page does not shrink when the cards load.
4. Open `/shows/1399` and a season page with throttling: no visible resize when content arrives; seasons are 4 per row.
5. Check the navbar on `/shows`, `/movies?page=2`, `/watchlist?type=movie`: the lit tab is cyan glass with dark text; keyboard Tab shows the focus ring on it.
-6. Scroll to the footer on `/shows`, `/sign-in`, `/privacy` and `/nope`: the icon key is the footer's centred first row with all seven entries; at 390px it wraps centred.
+6. Scroll to the footer on `/shows`, `/sign-in`, `/privacy` and `/nope`: the icon key is the footer's centred first row with all eight entries; at 390px it wraps centred.
7. Open `/showcase`: the tab and key examples match the shipped UI.
From 2ae3bbb7f2f6e9649dfe247e5a53a5d0e12b2277 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:30:18 +0200
Subject: [PATCH 07/25] docs(prompts): plan real library status on catalog
cards
Co-Authored-By: Claude Opus 5.5
---
prompts/catalog-card-status.md | 142 +++++++++++++++++++++++++++++++++
1 file changed, 142 insertions(+)
create mode 100644 prompts/catalog-card-status.md
diff --git a/prompts/catalog-card-status.md b/prompts/catalog-card-status.md
new file mode 100644
index 0000000..3a70822
--- /dev/null
+++ b/prompts/catalog-card-status.md
@@ -0,0 +1,142 @@
+# Catalog cards show the title's real status
+
+No spec: it reuses the library pages' card states and classifiers, with no new architectural decision (decided in grilling, 2026-10-11). Branch `feat/catalog-card-status`, off `main`.
+
+## Goal
+
+A poster card on `/movies`, `/shows` and `/search` shows the signed-in user's actual state for that title, the same state its library page shows. Today a watched and scored movie still shows the TMDB rating and an empty Plan bookmark there.
+
+## Inspected code
+
+- `app/movies/page.tsx`, `app/shows/page.tsx` and `components/search/search-results.tsx` render `PosterCard` with a static `TmdbRatingBadge`. They put the bookmark in `controls` inside its own `Suspense fallback={null}`. Search also passes `meta={year}`.
+- `components/tracking/card-bookmark.tsx` reads only `getWatchlistedMovieIds` (a `cache()`d read, one query per grid). `CardBookmarkButton` can only plan or unplan.
+- `components/tracking/show-card-bookmark.tsx` reads only `getTrackedShows`.
+- `components/library/library-card.tsx` holds the movie cards:
+ - Watchlist: TMDB badge and the Planned button.
+ - Upcoming: `ReleasePill` (`DatedPill` or `DateTbaPill`), no badge, and the Planned button.
+ - Watched: `PersonalScoreBadge` when scored, and a filled `WatchedIcon` button labelled "Unmark … as watched".
+- `components/library/show-cards.tsx` holds the show cards. `UpcomingShowPill` shows "S2E1 · Oct 20" or Date TBA. `WatchedShowCard` shows `CalculatedRatingBadge` ("Your show rating") and puts `WATCHED_SHOW_LABELS` in `meta`.
+- `lib/catalog/movie-page.ts` provides the pure helpers `classifyMovie` and `upcomingReleaseDate`.
+- `lib/tv/library-page.ts` provides the pure `classifyShow(details, watchedRegularKeys, today)`, which returns `ShowPage`.
+- `lib/tracking/library-lists.ts` has two things to reuse:
+ - The private `readWatchedRegular`, which pages through the watched regular episodes.
+ - `getTvShowsSettled`, a cached TMDB details batch.
+- `lib/tracking/show-ratings.ts`: `getShowRatings(userId, ids)`.
+- `lib/tracking/episode-state.ts`: `requestTodayUtc()`.
+- `app/movies/actions.ts` and `app/shows/actions.ts` already call `refresh()` on success, so after any write the server re-renders a card's badge and pill. No client-side badge state is needed.
+- `components/tracking/use-movie-cleared-undo.ts` provides `showCleared("unwatched", cleared, …)`, the unmark toast and Undo the movie page uses.
+- `lib/search/read.ts`: `SearchResult` has `year` but no `releaseDate`. `MovieSummary.releaseDate` exists.
+
+## Skills
+
+- `next-dev-loop`, to drive the three grids in the running app.
+- `supabase` only for the local stack (`pnpm dev:docker`). There is no schema change, so the Postgres skill is not needed.
+
+## Decisions (settled in grilling, 2026-10-11)
+
+1. **Scope:** the poster grids on `/movies`, `/shows` and `/search` (both media types). Quick search rows, detail pages and the library pages themselves are unchanged.
+2. **Watched movie:** your cyan score replaces the TMDB rating. With no score, the TMDB rating stays.
+3. **The watched movie's button** is the filled watched mark from the Watched page.
+ - It unmarks the movie with `setMovieWatched(id, false)`, which also removes the score.
+ - The toast and Undo are the movie page's (`useMovieClearedUndo`, `"unwatched"`).
+ - After `refresh()`, the card falls back to the empty Plan bookmark and the TMDB rating.
+4. **Planned movie, released:** unchanged (TMDB badge and Planned bookmark), as on Watchlist.
+5. **Planned movie, unreleased or undated:** the Upcoming look, with the release date pill or Date TBA, no badge, and the Planned bookmark. The date comes from the TMDB result already on the card. The test is `classifyMovie` with `requestTodayUtc()`, so the card and `/upcoming` cannot disagree.
+6. **Untouched title:** unchanged.
+7. **Tracked show:** the filled bookmark (Stop tracking) stays on every tracked card. What else the card shows comes from `classifyShow`:
+ - **Watchlist:** nothing extra. The card is the same as today.
+ - **Upcoming:** the "S2E1 · Oct 20" pill or Date TBA bottom left, and no TMDB rating.
+ - **Watched:** `CalculatedRatingBadge` replaces the TMDB rating when a regular season is rated; with no rating the TMDB rating stays. The Finished or Caught up label goes under the name.
+8. **While status loads:** the badge falls back to the TMDB rating, so only your own titles swap. The controls fall back to nothing, as today. The meta line falls back to what it shows today (the year on `/search`, nothing on `/shows`).
+9. **Failure:** if the user-state read fails, the card renders exactly as it does for a visitor, as `CardBookmark` does today. If one tracked show's TMDB details or the ratings read fail, that card stays a plain tracked card (bookmark and TMDB rating) and is never labelled wrongly.
+10. **Search meta (assumption):** a Watched show on `/search` reads "2019 · Finished", appending the label to the year rather than replacing it.
+
+## Design
+
+One request-scoped read per grid and media type, shared by every card through React `cache()` with a string key, the `getWatchlistedMovieIds` pattern. Each card fills three slots, each in its own Suspense boundary: badge, meta (shows only) and controls. All three call the same cached read, so a grid still makes one Supabase round per media type.
+
+- `lib/tracking/movie-state.ts`:
+ - `getWatchlistedMovieIds` is replaced by `getMovieGridStates(idsKey)`, which returns `Map` from one `in()` query.
+ - The pure `movieCardStatus(state | undefined, releaseDate, today)` returns `"none" | "watchlist" | "upcoming" | "watched"` through `classifyMovie`, with `"upcoming"` carrying `upcomingReleaseDate`.
+- `lib/tracking/show-grid-status.ts` (new, server-only) provides `getShowGridStatus(idsKey)`:
+ 1. `getTrackedShows`.
+ 2. For the tracked ids only: the watched regular episodes (`readWatchedRegular`, exported from `library-lists.ts`), `getTvShowsSettled`, then `classifyShow` per show.
+ 3. `getShowRatings` for the shows that land on Watched.
+
+ It returns `Map`. A show whose own reads failed is just tracked.
+- `components/tracking/movie-card-slots.tsx` provides `MovieCardBadge` and `MovieCardControls`. The controls render one of three things:
+ - `CardBookmarkButton`
+ - the release pill plus `CardBookmarkButton`
+ - the new `CardWatchedButton`
+- `components/tracking/card-watched-button.tsx` (new client component): the round filled watched mark, which calls `setMovieWatched(id, false)`. It uses the same optimistic, error toast and `useMovieClearedUndo` handling as `CardBookmarkButton`.
+- `components/tracking/show-card-slots.tsx` provides `ShowCardBadge`, `ShowCardMeta` and `ShowCardControls` (the Upcoming pill plus `ShowCardBookmarkButton`).
+- `ReleasePill` (from `library-card.tsx`) and `UpcomingShowPill` (from `show-cards.tsx`) are exported and reused unchanged, so the pill copy and layout have one owner.
+- `card-bookmark.tsx` and `show-card-bookmark.tsx` are replaced by the slot components, and their callers are updated.
+
+## Expected files
+
+- Changed:
+ - `app/movies/page.tsx`
+ - `app/shows/page.tsx`
+ - `components/search/search-results.tsx`
+ - `lib/search/read.ts` (adds `releaseDate` to `SearchResult`, movies only, null for TV)
+ - `lib/tracking/movie-state.ts`
+ - `lib/tracking/library-lists.ts` (exports `readWatchedRegular`)
+ - `components/library/library-card.tsx` and `components/library/show-cards.tsx` (export the pills)
+- New:
+ - `lib/tracking/show-grid-status.ts`
+ - `components/tracking/movie-card-slots.tsx`
+ - `components/tracking/show-card-slots.tsx`
+ - `components/tracking/card-watched-button.tsx`
+- Removed: `components/tracking/card-bookmark.tsx` and `components/tracking/show-card-bookmark.tsx` (their tests move to the slot files).
+- Tests:
+ - `lib/tracking/movie-state.test.ts` (`movieCardStatus`)
+ - `lib/tracking/show-grid-status.test.ts`
+ - `components/tracking/movie-card-slots.test.tsx`
+ - `components/tracking/show-card-slots.test.tsx`
+ - `components/tracking/card-watched-button.test.tsx`
+ - updates to `search-results` and the page tests
+
+## Requirements
+
+- R1. A watched, scored movie on any grid shows `PersonalScoreBadge` and the filled watched button, and no Plan bookmark.
+- R2. A watched, unscored movie shows the TMDB badge and the filled watched button.
+- R3. Tapping the watched button unmarks the movie. A stored score shows the "score removed" toast with Undo, and Undo restores the mark and the score. Afterwards the card shows the TMDB badge and the empty Plan bookmark.
+- R4. A planned, unreleased or undated movie shows the release pill or Date TBA, no badge, and the pressed Planned bookmark. A planned, released movie looks as it does today.
+- R5. A tracked show on Upcoming shows its pill and no badge. On Watched it shows the calculated rating, or the TMDB rating if unrated, plus the Finished or Caught up label. On Watchlist it looks as it does today. Every tracked show keeps the filled bookmark.
+- R6. A card's status always matches the library page that title is on, because both use the same classifiers and the same `requestTodayUtc()`.
+- R7. Signed-out visitors, and any failed read, get today's catalog card. Nothing breaks the grid.
+- R8. A grid makes one movie-state query or one tracked-show pass per request, whatever the card count. TMDB details are read only for tracked shows on the grid (at most 20, cached).
+
+## Security
+
+- Every new read derives the user from `getOptionalUser()`, filters on `user_id` explicitly and through RLS, and never runs inside `use cache` (`AGENTS.md` section 11).
+- The grids' prerendered shells stay public. All user state streams inside per-card Suspense boundaries, so private state never enters a shared cache.
+- The unmark goes through the existing `setMovieWatched` Server Action, which already authenticates and validates.
+- `releaseDate` on `SearchResult` is public TMDB data.
+
+## Acceptance criteria
+
+- AC-1. R1 to R7 hold on `/movies`, `/shows`, `/search?type=movie` and `/search?type=tv`.
+- AC-2. `/movies` and `/shows` still prerender their shells (`pnpm build` reports them as partially prerendered, as now).
+- AC-3. Unchanged cards show no layout shift: the badge fallback is the TMDB badge.
+
+## Automated checks
+
+`pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`. No `pnpm test:db`: there is no database change.
+
+## Manual test steps (`pnpm dev:docker`, signed in)
+
+1. Mark a released movie watched with score 8. On `/movies` (or `/search?type=movie&q=`), its card shows the cyan 8 and the filled watched mark.
+2. Tap the watched mark. The toast offers Undo, and the card shows the TMDB rating and the empty Plan bookmark. Tap Undo, and the cyan 8 and watched mark return.
+3. Mark another movie watched without a score. Its card shows the TMDB rating and the watched mark.
+4. Plan an unreleased movie (find one in search). Its card switches to the release date pill with no badge, and it matches its `/upcoming?type=movie` card.
+5. On `/shows` or `/search?type=tv`, track three shows:
+ - one with an unwatched aired episode, which looks as it does today;
+ - one caught up with a dated next episode, which shows the S·E date pill and no rating;
+ - one ended show fully watched and rated, which shows your show rating and "Finished".
+
+ Each must match its library page.
+6. Stop tracking the Watched show from its card. The card returns to the TMDB rating with an empty bookmark.
+7. Sign out. All grids look as they did before this change.
+8. Narrow the window to phone width. The pills and badges don't overlap, and the tap targets stay 44px.
From 03f960e22212b4e913aec3d72048383ac7de0b5d Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:30:18 +0200
Subject: [PATCH 08/25] refactor(library): share the release and upcoming show
pills
Move ReleasePill and releaseDateFor into dated-pill.tsx and export
UpcomingShowPill, so the catalog cards can reuse the Upcoming copy.
Co-Authored-By: Claude Opus 5.5
---
components/library/dated-pill.tsx | 39 +++++++++++++++++++++++-
components/library/library-card.tsx | 19 ++----------
components/library/movie-library-tab.tsx | 13 ++------
components/library/show-cards.tsx | 6 +++-
4 files changed, 48 insertions(+), 29 deletions(-)
diff --git a/components/library/dated-pill.tsx b/components/library/dated-pill.tsx
index 9deeb14..4142ac3 100644
--- a/components/library/dated-pill.tsx
+++ b/components/library/dated-pill.tsx
@@ -2,8 +2,11 @@ import { cn } from "cn";
import { GlassPill } from "@/components/glass-pill";
import { CalendarIcon } from "@/components/tracking/tracking-icons";
+import { formatAirDate, formatShortDate } from "@/lib/format";
import { UPCOMING_MESSAGES } from "@/lib/tracking/messages";
+import type { ReleaseDate } from "./types";
+
/** A short date that carries its year, such as `Feb 3, 2027`. */
const ENDS_IN_YEAR = /\d{4}$/;
@@ -69,4 +72,38 @@ function DateTbaPill({ label }: { label: string }) {
);
}
-export { DatedPill, DateTbaPill };
+/**
+ * A movie's Upcoming release date, formatted beside the `today` that decided
+ * it (spec 0020, AC-13), or null for Date TBA. The library Upcoming card and
+ * the catalog cards both format through it (prompts/catalog-card-status.md).
+ *
+ * @param releaseDate A date `upcomingReleaseDate` returned, or null.
+ * @param today `requestTodayUtc()`, for `formatShortDate`'s year rule.
+ */
+function releaseDateFor(
+ releaseDate: string | null,
+ today: string,
+): ReleaseDate {
+ if (releaseDate === null) return null;
+ const fullDate = formatAirDate(releaseDate) ?? releaseDate;
+ return {
+ fullDate,
+ shortDate: formatShortDate(releaseDate, today) ?? fullDate,
+ };
+}
+
+/** The release date on an Upcoming movie card, or Date TBA (AC-13). */
+function ReleasePill({ release }: { release: ReleaseDate }) {
+ if (release === null) {
+ return ;
+ }
+ return (
+
+ );
+}
+
+export { DatedPill, DateTbaPill, ReleasePill, releaseDateFor };
diff --git a/components/library/library-card.tsx b/components/library/library-card.tsx
index 39add18..c7dce44 100644
--- a/components/library/library-card.tsx
+++ b/components/library/library-card.tsx
@@ -7,9 +7,8 @@ import {
} from "@/components/rating-badges";
import { CardRoundButton } from "@/components/tracking/card-round-button";
import { PlannedIcon, WatchedIcon } from "@/components/tracking/tracking-icons";
-import { UPCOMING_MESSAGES } from "@/lib/tracking/messages";
-import { DatedPill, DateTbaPill } from "./dated-pill";
+import { ReleasePill } from "./dated-pill";
import type { LibraryList, LibraryMovieItem } from "./types";
/**
@@ -53,7 +52,7 @@ function LibraryCard({
badge={cardBadge(list, item)}
controls={
<>
- {list === "upcoming" ? : null}
+ {list === "upcoming" ? : null}
>
}
@@ -62,20 +61,6 @@ function LibraryCard({
);
}
-/** The release date on an Upcoming card, or Date TBA (AC-13). */
-function ReleasePill({ item }: { item: LibraryMovieItem }) {
- if (item.release === null) {
- return ;
- }
- return (
-
- );
-}
-
/**
* The badge a card carries: the amber TMDB rating on Watchlist, the cyan
* personal score on Watched when there is one, and none on Upcoming.
diff --git a/components/library/movie-library-tab.tsx b/components/library/movie-library-tab.tsx
index 58c7ad2..ea98c2a 100644
--- a/components/library/movie-library-tab.tsx
+++ b/components/library/movie-library-tab.tsx
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { PaginationLinks } from "@/components/pagination-links";
-import { formatAirDate, formatShortDate } from "@/lib/format";
import {
getLibraryMovieTitles,
getMovieLibraryTab,
@@ -14,6 +13,7 @@ import {
} from "@/lib/tracking/library-lists";
import { LIBRARY_COPY } from "@/lib/tracking/messages";
+import { releaseDateFor } from "./dated-pill";
import { LibraryGrid } from "./library-grid";
import {
EmptyPanel,
@@ -66,15 +66,8 @@ function movieItem(card: MovieTabCard, today: string): LibraryMovieItem {
posterUrl: card.title.posterUrl,
tmdbRating: card.title.tmdbRating,
};
- if (card.page === "watchlist" || card.releaseDate === null) return base;
- const fullDate = formatAirDate(card.releaseDate) ?? card.releaseDate;
- return {
- ...base,
- release: {
- fullDate,
- shortDate: formatShortDate(card.releaseDate, today) ?? fullDate,
- },
- };
+ if (card.page === "watchlist") return base;
+ return { ...base, release: releaseDateFor(card.releaseDate, today) };
}
/**
diff --git a/components/library/show-cards.tsx b/components/library/show-cards.tsx
index b9a2f7d..0308248 100644
--- a/components/library/show-cards.tsx
+++ b/components/library/show-cards.tsx
@@ -47,6 +47,10 @@ function UpcomingShowCard({
);
}
+/**
+ * The next episode and its short date, or Date TBA, on an Upcoming show card
+ * and on a catalog card for a show on Upcoming (prompts/catalog-card-status.md).
+ */
function UpcomingShowPill({
airDate,
next,
@@ -120,4 +124,4 @@ function WatchedShowCard({
);
}
-export { UpcomingShowCard, WatchedShowCard };
+export { UpcomingShowCard, UpcomingShowPill, WatchedShowCard };
From 194a5017199b4b6558b70d2224b8e805655c8714 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:30:18 +0200
Subject: [PATCH 09/25] feat(search): carry the movie release date on search
results
Co-Authored-By: Claude Opus 5.5
---
lib/search/read.test.ts | 2 ++
lib/search/read.ts | 8 ++++++++
lib/search/scan.test.ts | 1 +
3 files changed, 11 insertions(+)
diff --git a/lib/search/read.test.ts b/lib/search/read.test.ts
index 61d8c6b..cf2cea1 100644
--- a/lib/search/read.test.ts
+++ b/lib/search/read.test.ts
@@ -64,6 +64,7 @@ describe("fromMovie and fromShow", () => {
id: 438631,
title: "Dune",
year: 2021,
+ releaseDate: MOVIE.releaseDate,
posterUrl: "https://image.tmdb.org/t/p/w500/dune.jpg",
tmdbRating: 7.8,
tmdbVoteCount: 12000,
@@ -76,6 +77,7 @@ describe("fromMovie and fromShow", () => {
id: 90228,
title: "Dune: Prophecy",
year: 2024,
+ releaseDate: null,
posterUrl: null,
tmdbRating: null,
tmdbVoteCount: 3,
diff --git a/lib/search/read.ts b/lib/search/read.ts
index 90ae536..7665a99 100644
--- a/lib/search/read.ts
+++ b/lib/search/read.ts
@@ -24,6 +24,12 @@ export type SearchResult = {
id: number;
title: string;
year: number | null;
+ /**
+ * A movie's TMDB release date, which places a planned movie on Watchlist
+ * or Upcoming on its card (prompts/catalog-card-status.md). Null for a
+ * show: its card reads its own air dates.
+ */
+ releaseDate: string | null;
posterUrl: string | null;
tmdbRating: number | null;
tmdbVoteCount: number;
@@ -35,6 +41,7 @@ export function fromMovie(movie: MovieSummary): SearchResult {
id: movie.id,
title: movie.title,
year: movie.releaseYear,
+ releaseDate: movie.releaseDate,
posterUrl: movie.posterUrl,
tmdbRating: movie.tmdbRating,
tmdbVoteCount: movie.tmdbVoteCount,
@@ -47,6 +54,7 @@ export function fromShow(show: TvShowSummary): SearchResult {
id: show.id,
title: show.name,
year: show.firstAirYear,
+ releaseDate: null,
posterUrl: show.posterUrl,
tmdbRating: show.tmdbRating,
tmdbVoteCount: show.tmdbVoteCount,
diff --git a/lib/search/scan.test.ts b/lib/search/scan.test.ts
index 26ff321..161b697 100644
--- a/lib/search/scan.test.ts
+++ b/lib/search/scan.test.ts
@@ -17,6 +17,7 @@ function result(
id,
title: `Title ${id}`,
year: 2020,
+ releaseDate: "2020-05-01",
posterUrl: null,
tmdbRating: 8,
tmdbVoteCount: 500,
From 0a270295f40b44e7b807bbe26b9c4acf7688402b Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:30:26 +0200
Subject: [PATCH 10/25] refactor(catalog): place a movie by a watched flag, not
a timestamp
classifyMovie only ever asked whether watched_at was set. Taking a
boolean lets the catalog grid read, which carries no timestamp, place a
movie through the same function as the library pages.
Co-Authored-By: Claude Opus 5.5
---
lib/catalog/movie-page.test.ts | 20 ++++++++++----------
lib/catalog/movie-page.ts | 5 +++--
lib/tracking/library-lists.ts | 2 +-
3 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/lib/catalog/movie-page.test.ts b/lib/catalog/movie-page.test.ts
index fcb1b32..62715b8 100644
--- a/lib/catalog/movie-page.test.ts
+++ b/lib/catalog/movie-page.test.ts
@@ -14,13 +14,13 @@ describe("classifyMovie (AC-13)", () => {
it("puts a released planned movie on Watchlist, release day included", () => {
expect(
classifyMovie(
- { releaseDate: "2026-01-01", inWatchlist: true, watchedAt: null },
+ { releaseDate: "2026-01-01", inWatchlist: true, watched: false },
today,
),
).toBe("watchlist");
expect(
classifyMovie(
- { releaseDate: today, inWatchlist: true, watchedAt: null },
+ { releaseDate: today, inWatchlist: true, watched: false },
today,
),
).toBe("watchlist");
@@ -29,7 +29,7 @@ describe("classifyMovie (AC-13)", () => {
it("puts a planned movie released after today on Upcoming", () => {
expect(
classifyMovie(
- { releaseDate: "2026-10-08", inWatchlist: true, watchedAt: null },
+ { releaseDate: "2026-10-08", inWatchlist: true, watched: false },
today,
),
).toBe("upcoming");
@@ -38,29 +38,28 @@ describe("classifyMovie (AC-13)", () => {
it("puts a planned movie with no date or a malformed one on Upcoming", () => {
expect(
classifyMovie(
- { releaseDate: null, inWatchlist: true, watchedAt: null },
+ { releaseDate: null, inWatchlist: true, watched: false },
today,
),
).toBe("upcoming");
expect(
classifyMovie(
- { releaseDate: "2026-02-30", inWatchlist: true, watchedAt: null },
+ { releaseDate: "2026-02-30", inWatchlist: true, watched: false },
today,
),
).toBe("upcoming");
});
it("puts a watched movie on Watched, planned or not (AC-14)", () => {
- const watchedAt = "2026-09-01T10:00:00Z";
expect(
classifyMovie(
- { releaseDate: "2026-01-01", inWatchlist: true, watchedAt },
+ { releaseDate: "2026-01-01", inWatchlist: true, watched: true },
today,
),
).toBe("watched");
expect(
classifyMovie(
- { releaseDate: "2026-01-01", inWatchlist: false, watchedAt },
+ { releaseDate: "2026-01-01", inWatchlist: false, watched: true },
today,
),
).toBe("watched");
@@ -69,7 +68,7 @@ describe("classifyMovie (AC-13)", () => {
it("puts an unplanned, unwatched movie on no page", () => {
expect(
classifyMovie(
- { releaseDate: "2026-01-01", inWatchlist: false, watchedAt: null },
+ { releaseDate: "2026-01-01", inWatchlist: false, watched: false },
today,
),
).toBeNull();
@@ -104,7 +103,7 @@ describe("isMovieReleased (release gate)", () => {
it("agrees with classifyMovie: markable exactly when on Watchlist (AC-6)", () => {
for (const releaseDate of [today, "2026-10-08", null, "2026-13-40"]) {
const page = classifyMovie(
- { releaseDate, inWatchlist: true, watchedAt: null },
+ { releaseDate, inWatchlist: true, watched: false },
today,
);
expect(page === "watchlist").toBe(isMovieReleased(releaseDate, today));
@@ -119,3 +118,4 @@ describe("movieReleaseNote (release gate)", () => {
expect(movieReleaseNote("2026-13-40")).toBe("Release date to be announced");
});
});
+
diff --git a/lib/catalog/movie-page.ts b/lib/catalog/movie-page.ts
index 04c0185..de93c90 100644
--- a/lib/catalog/movie-page.ts
+++ b/lib/catalog/movie-page.ts
@@ -10,7 +10,7 @@ export type MoviePage = LibraryList;
export type MoviePlacement = {
releaseDate: string | null;
inWatchlist: boolean;
- watchedAt: string | null;
+ watched: boolean;
};
/**
@@ -30,7 +30,7 @@ export function classifyMovie(
movie: MoviePlacement,
today: string,
): MoviePage | null {
- if (movie.watchedAt !== null) return "watched";
+ if (movie.watched) return "watched";
if (!movie.inWatchlist) return null;
return isMovieReleased(movie.releaseDate, today) ? "watchlist" : "upcoming";
}
@@ -85,3 +85,4 @@ export function upcomingReleaseDate(
): string | null {
return airStatus(releaseDate, today) === "upcoming" ? releaseDate : null;
}
+
diff --git a/lib/tracking/library-lists.ts b/lib/tracking/library-lists.ts
index 3c6276b..3066cc5 100644
--- a/lib/tracking/library-lists.ts
+++ b/lib/tracking/library-lists.ts
@@ -484,7 +484,7 @@ export async function getMovieLibraryTab(
read: getMoviesSettled,
place: (row, movie): MovieTabCard | null => {
const result = classifyMovie(
- { releaseDate: movie.releaseDate, inWatchlist: true, watchedAt: null },
+ { releaseDate: movie.releaseDate, inWatchlist: true, watched: false },
today,
);
if (result !== tab) return null;
From 68fc631831cba219cf5c42dda049d5a7ac6deb53 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:30:39 +0200
Subject: [PATCH 11/25] feat(tracking): add the round watched mark for poster
cards
A client button that unmarks a watched movie from a grid card, with the
movie page's score-removed toast and Undo.
Co-Authored-By: Claude Opus 5.5
---
.../tracking/card-watched-button.test.tsx | 88 +++++++++++++++++++
components/tracking/card-watched-button.tsx | 68 ++++++++++++++
2 files changed, 156 insertions(+)
create mode 100644 components/tracking/card-watched-button.test.tsx
create mode 100644 components/tracking/card-watched-button.tsx
diff --git a/components/tracking/card-watched-button.test.tsx b/components/tracking/card-watched-button.test.tsx
new file mode 100644
index 0000000..66fdc0c
--- /dev/null
+++ b/components/tracking/card-watched-button.test.tsx
@@ -0,0 +1,88 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: prompts/catalog-card-status.md, R3
+ */
+let settle: (value: unknown) => void = () => {};
+const setMovieWatched = vi.fn(
+ (..._args: unknown[]) =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+);
+const restoreMovieWatched = vi.fn(async (..._args: unknown[]) => ({
+ ok: true,
+}));
+vi.mock("@/app/movies/actions", () => ({
+ setMovieWatched: (...args: unknown[]) => setMovieWatched(...args),
+ restoreMovieWatched: (...args: unknown[]) => restoreMovieWatched(...args),
+}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
+const toast = vi.fn();
+vi.mock("sonner", () => ({
+ toast: Object.assign((...args: unknown[]) => toast(...args), {
+ dismiss: vi.fn(),
+ }),
+}));
+
+const { CardWatchedButton } = await import("./card-watched-button");
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+function renderButton() {
+ render(
+ ,
+ );
+ return screen.getByRole("button", { name: "Unmark Fight Club as watched" });
+}
+
+describe("CardWatchedButton", () => {
+ it("is the Watched page's round button with a 44px mobile target", () => {
+ expect(renderButton()).toHaveClass("size-11", "md:size-9");
+ });
+
+ it("unmarks the movie and offers the Undo that restores its mark and score", async () => {
+ const user = userEvent.setup();
+ const button = renderButton();
+ await user.click(button);
+ expect(setMovieWatched).toHaveBeenCalledWith(550, false);
+ expect(button).toBeDisabled();
+
+ const watchedAt = "2026-09-23T12:00:00Z";
+ await act(async () =>
+ settle({ ok: true, cleared: { watchedAt, rating: 8 } }),
+ );
+ const [message, options] = toast.mock.calls.at(-1) ?? [];
+ expect(message).toBe("Score removed too.");
+ await act(async () => options.action.onClick({ preventDefault: () => {} }));
+ expect(restoreMovieWatched).toHaveBeenCalledWith(550, watchedAt, 8);
+ });
+
+ it("shows no toast when no score went with the mark", async () => {
+ const user = userEvent.setup();
+ await user.click(renderButton());
+ await act(async () =>
+ settle({
+ ok: true,
+ cleared: { watchedAt: "2026-09-23T12:00:00Z", rating: null },
+ }),
+ );
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("rolls back and says so on a refusal", async () => {
+ const user = userEvent.setup();
+ const button = renderButton();
+ await user.click(button);
+ await act(async () => settle({ ok: false, error: "write_failed" }));
+ await waitFor(() => expect(button).toBeEnabled());
+ expect(toast).toHaveBeenCalledWith(
+ "Couldn't save that change. Try again.",
+ expect.objectContaining({ id: "movie-tracking-550-watched" }),
+ );
+ });
+});
diff --git a/components/tracking/card-watched-button.tsx b/components/tracking/card-watched-button.tsx
new file mode 100644
index 0000000..296a9bb
--- /dev/null
+++ b/components/tracking/card-watched-button.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { startTransition, useOptimistic } from "react";
+
+import { setMovieWatched } from "@/app/movies/actions";
+
+import { CardRoundButton } from "./card-round-button";
+import { WatchedIcon } from "./tracking-icons";
+import { settleResultCall, showTrackingError } from "./tracking-toast";
+import { useMovieClearedUndo } from "./use-movie-cleared-undo";
+
+/**
+ * The filled watched mark on a catalog poster card for a watched movie
+ * (prompts/catalog-card-status.md), the Watched page's button on `/movies`
+ * and `/search`.
+ *
+ * It only unmarks: a movie is marked watched on its own page. Unmarking also
+ * removes the score, so the movie page's toast and Undo follow
+ * (prompts/movie-plan-watched-exclusive.md). The action's `refresh()` then
+ * re-renders the card, which falls back to the TMDB rating and the Plan
+ * bookmark; until then the mark shows empty, and a failure rolls it back.
+ */
+function CardWatchedButton({
+ movieId,
+ title,
+ returnPath,
+}: {
+ movieId: number;
+ title: string;
+ returnPath: string;
+}) {
+ const router = useRouter();
+ const [watched, setWatched] = useOptimistic(true);
+ const showCleared = useMovieClearedUndo({ movieId, returnPath });
+
+ function unmark() {
+ startTransition(async () => {
+ setWatched(false);
+ const result = await settleResultCall(() =>
+ setMovieWatched(movieId, false),
+ );
+ if (!result.ok) {
+ showTrackingError(result.error, {
+ movieId,
+ control: "watched",
+ returnPath,
+ navigate: router.push,
+ });
+ return;
+ }
+ showCleared("unwatched", result.cleared);
+ });
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { CardWatchedButton };
From 08d7d9ff7ccce6e106fb5b4e336b9644c2a86791 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:31:02 +0200
Subject: [PATCH 12/25] feat(catalog): show a movie's library status on its
grid card
Movie cards on /movies and /search read every grid movie's state in one
query and show it as the library page does: the personal score and the
watched mark on a watched movie, the release pill on a planned unreleased
one, and the plan bookmark otherwise. The badge falls back to the TMDB
rating while the state streams in.
Co-Authored-By: Claude Opus 5.5
---
app/movies/page.tsx | 27 ++++-
app/movies/request-scope.test.ts | 16 +--
components/search/search-results.tsx | 87 +++++++++------
components/tracking/card-bookmark.tsx | 50 ---------
components/tracking/movie-card-slots.tsx | 114 ++++++++++++++++++++
components/tracking/tracking-slots.test.tsx | 102 +++++++++++++++---
lib/catalog/movie-page.test.ts | 43 ++++++++
lib/catalog/movie-page.ts | 38 +++++++
lib/tracking/movie-state.test.ts | 56 +++++-----
lib/tracking/movie-state.ts | 40 ++++---
10 files changed, 426 insertions(+), 147 deletions(-)
delete mode 100644 components/tracking/card-bookmark.tsx
create mode 100644 components/tracking/movie-card-slots.tsx
diff --git a/app/movies/page.tsx b/app/movies/page.tsx
index d73d5ec..b57fb4a 100644
--- a/app/movies/page.tsx
+++ b/app/movies/page.tsx
@@ -8,7 +8,10 @@ import { TmdbRatingBadge } from "@/components/rating-badges";
import { RetryLink } from "@/components/retry-link";
import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
-import { CardBookmark } from "@/components/tracking/card-bookmark";
+import {
+ MovieCardBadge,
+ MovieCardControls,
+} from "@/components/tracking/movie-card-slots";
import { ButtonLink } from "@/components/ui/button";
import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages";
import { landingPageMetadata } from "@/lib/landing-metadata";
@@ -57,8 +60,10 @@ export async function generateMetadata({
* The heading is static and prerendered; the grid needs the `page` search
* parameter, so it streams inside the Suspense boundary behind a grid of
* skeleton cards at the same footprint (AC-11). No session is read here
- * (AC-13); each card's bookmark from spec 0007 reads it inside its own
- * Suspense boundary in `components/tracking/`, one query for the whole grid.
+ * (AC-13); each card's badge and controls read it inside their own Suspense
+ * boundaries in `components/tracking/movie-card-slots.tsx`, one query for the
+ * whole grid, so a card shows the movie as its library page does
+ * (prompts/catalog-card-status.md).
*/
export default function MoviesPage({ searchParams }: PageProps<"/movies">) {
return (
@@ -118,12 +123,24 @@ async function PopularMovies({
title={movie.title}
posterUrl={movie.posterUrl}
href={`/movies/${movie.id}`}
- badge={ }
+ badge={
+ }
+ >
+
+
+ }
controls={
-
diff --git a/app/movies/request-scope.test.ts b/app/movies/request-scope.test.ts
index 421164f..6eb5266 100644
--- a/app/movies/request-scope.test.ts
+++ b/app/movies/request-scope.test.ts
@@ -151,10 +151,12 @@ describe("search reads no request scoped value (spec 0010, AC-22)", () => {
);
});
- it("keeps the movie bookmark behind its own Suspense boundary (AC-16)", () => {
- expect(
- readFileSync("components/search/search-results.tsx", "utf8"),
- ).toMatch(/\s* {
+ const source = readFileSync("components/search/search-results.tsx", "utf8");
+ expect(source).toMatch(/\s* \}\s*>\s*\{params\.type === "movie" \? \(\s* {
@@ -291,8 +293,10 @@ describe("private tracking state never enters a cache scope (spec 0007, AC-19; s
expect(readFileSync("app/movies/[id]/page.tsx", "utf8")).toMatch(
/\s*\s*\s* \}\s*>\s*
- {results.map((result, index) => (
-
- }
- controls={
- params.type === "movie" ? (
-
-
-
- ) : (
-
-
+ {results.map((result, index) => {
+ return (
+
+ }
+ >
+ {params.type === "movie" ? (
+
+ ) : (
+
+ )}
- )
- }
- priority={index < EAGER_POSTERS}
- />
-
- ))}
+ }
+ controls={
+ params.type === "movie" ? (
+
+
+
+ ) : (
+
+
+
+ )
+ }
+ priority={index < EAGER_POSTERS}
+ />
+
+ );
+ })}
);
}
diff --git a/components/tracking/card-bookmark.tsx b/components/tracking/card-bookmark.tsx
deleted file mode 100644
index 12f08c3..0000000
--- a/components/tracking/card-bookmark.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import {
- getWatchlistedMovieIds,
- movieIdsKey,
-} from "@/lib/tracking/movie-state";
-
-import { CardBookmarkButton } from "./card-bookmark-button";
-
-/**
- * The bookmark in a `/movies` grid card, for a signed in user (spec 0007,
- * AC-16).
- *
- * Every card on the grid passes the same ids, so every card calls
- * `getWatchlistedMovieIds` with the same key, which React `cache()` collapses
- * into one Supabase read per render. Each card is
- * still its own Suspense boundary, so the posters never wait on the session.
- *
- * A visitor and a failed read both render nothing: the grid is catalog first,
- * and a missing bookmark is quieter than an error on twenty cards (AC-2,
- * AC-17). The failure is logged inside the read.
- *
- * @param movieId The card's movie.
- * @param title The TMDB title, for the accessible name.
- * @param gridMovieIds Every movie id on the grid, the same array for each card.
- * @param returnPath The grid page, for the session expired toast.
- */
-async function CardBookmark({
- movieId,
- title,
- gridMovieIds,
- returnPath,
-}: {
- movieId: number;
- title: string;
- gridMovieIds: readonly number[];
- returnPath: string;
-}) {
- const result = await getWatchlistedMovieIds(movieIdsKey(gridMovieIds));
- if (result.kind !== "ok") return null;
-
- return (
-
- );
-}
-
-export { CardBookmark };
diff --git a/components/tracking/movie-card-slots.tsx b/components/tracking/movie-card-slots.tsx
new file mode 100644
index 0000000..a69dbac
--- /dev/null
+++ b/components/tracking/movie-card-slots.tsx
@@ -0,0 +1,114 @@
+import { ReleasePill, releaseDateFor } from "@/components/library/dated-pill";
+import {
+ PersonalScoreBadge,
+ TmdbRatingBadge,
+} from "@/components/rating-badges";
+import { movieCardStatus } from "@/lib/catalog/movie-page";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { getMovieGridStates, movieIdsKey } from "@/lib/tracking/movie-state";
+
+import { CardBookmarkButton } from "./card-bookmark-button";
+import { CardWatchedButton } from "./card-watched-button";
+
+/**
+ * The two personal slots of a movie poster card on `/movies` and `/search`
+ * (spec 0007, AC-16; prompts/catalog-card-status.md): each card shows the
+ * movie the way the library page it sits on does.
+ *
+ * Every slot on the grid passes the same ids, so `getMovieGridStates` runs
+ * once per render for the whole grid. Each slot is still its own Suspense
+ * boundary, so the posters never wait on the session. A visitor and a failed
+ * read get the catalog card: the grid is catalog first, and a missing status
+ * is quieter than an error on twenty cards (AC-2, AC-17). The failure is
+ * logged inside the read.
+ */
+type MovieCardProps = {
+ movieId: number;
+ /** The TMDB release date already on the card. */
+ releaseDate: string | null;
+ /** Every movie id on the grid, the same array for each card. */
+ gridMovieIds: readonly number[];
+};
+
+/** The card's status, or undefined for a visitor or a failed read. */
+async function readStatus({
+ movieId,
+ releaseDate,
+ gridMovieIds,
+}: MovieCardProps) {
+ const result = await getMovieGridStates(movieIdsKey(gridMovieIds));
+ if (result.kind !== "ok") return undefined;
+ const state = result.state.get(movieId);
+ return {
+ state,
+ status: movieCardStatus(state, releaseDate, requestTodayUtc()),
+ };
+}
+
+/**
+ * The top right badge. The page renders the TMDB rating as the fallback, so
+ * only the user's own titles swap: the cyan score on a scored watched movie,
+ * nothing on an Upcoming one, the TMDB rating otherwise.
+ *
+ * @param tmdbRating The card's TMDB rating.
+ */
+async function MovieCardBadge({
+ tmdbRating,
+ ...props
+}: MovieCardProps & { tmdbRating: number | null }) {
+ const read = await readStatus(props);
+ const status = read?.status;
+ if (status?.page === "watched" && status.rating !== null) {
+ return ;
+ }
+ if (status?.page === "upcoming") return null;
+ return ;
+}
+
+/**
+ * The bottom row: the filled watched mark on a watched movie, the release
+ * pill and the Planned bookmark on an Upcoming one, and the plan bookmark
+ * otherwise. Nothing for a visitor or a failed read.
+ *
+ * @param title The TMDB title, for the accessible names.
+ * @param returnPath The grid page, for the session expired toast.
+ */
+async function MovieCardControls({
+ title,
+ returnPath,
+ ...props
+}: MovieCardProps & { title: string; returnPath: string }) {
+ const read = await readStatus(props);
+ if (read === undefined) return null;
+ const { state, status } = read;
+
+ if (status?.page === "watched") {
+ return (
+
+ );
+ }
+
+ const bookmark = (
+
+ );
+ if (status?.page !== "upcoming") return bookmark;
+ return (
+ <>
+
+ {bookmark}
+ >
+ );
+}
+
+export { MovieCardBadge, MovieCardControls };
diff --git a/components/tracking/tracking-slots.test.tsx b/components/tracking/tracking-slots.test.tsx
index 63b58f3..f41ea4e 100644
--- a/components/tracking/tracking-slots.test.tsx
+++ b/components/tracking/tracking-slots.test.tsx
@@ -2,17 +2,16 @@ import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
- * covers: spec 0007, AC-2, AC-16, AC-17
+ * covers: spec 0007, AC-2, AC-16, AC-17; prompts/catalog-card-status.md
*
* The two server slots decide between nothing, a failure line and the
* controls. The reads are the boundary, so they are what is replaced.
*/
const getMovieTracking = vi.fn();
-const getWatchlistedMovieIds = vi.fn();
+const getMovieGridStates = vi.fn();
vi.mock("@/lib/tracking/movie-state", () => ({
getMovieTracking: (...args: unknown[]) => getMovieTracking(...args),
- getWatchlistedMovieIds: (...args: unknown[]) =>
- getWatchlistedMovieIds(...args),
+ getMovieGridStates: (...args: unknown[]) => getMovieGridStates(...args),
movieIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
}));
vi.mock("@/lib/tracking/episode-state", () => ({
@@ -23,7 +22,9 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
const { MovieTrackingSlot } = await import("./movie-tracking-slot");
-const { CardBookmark } = await import("./card-bookmark");
+const { MovieCardBadge, MovieCardControls } = await import(
+ "./movie-card-slots"
+);
afterEach(() => {
vi.clearAllMocks();
@@ -101,32 +102,103 @@ describe("MovieTrackingSlot", () => {
});
});
-describe("CardBookmark", () => {
+describe("MovieCardBadge and MovieCardControls", () => {
const props = {
movieId: 550,
title: "Fight Club",
+ releaseDate: "1999-10-15" as string | null,
gridMovieIds: [603, 550],
returnPath: "/movies",
};
+ const none: {
+ inWatchlist: boolean;
+ watched: boolean;
+ rating: number | null;
+ } = { inWatchlist: false, watched: false, rating: null };
- it("asks for the whole grid under one shared key (AC-16)", async () => {
- getWatchlistedMovieIds.mockResolvedValue({
+ function grid(state?: typeof none) {
+ getMovieGridStates.mockResolvedValue({
kind: "ok",
- state: new Set([550]),
+ state: new Map(state ? [[550, state]] : []),
});
- render(await CardBookmark(props));
- expect(getWatchlistedMovieIds).toHaveBeenCalledWith("550,603");
+ }
+
+ async function renderCard(overrides: Partial = {}) {
+ const card = { ...props, ...overrides };
+ return render(
+ <>
+ {await MovieCardBadge({ ...card, tmdbRating: 8.4 })}
+ {await MovieCardControls(card)}
+ >,
+ );
+ }
+
+ it("asks for the whole grid under one shared key (AC-16)", async () => {
+ grid({ ...none, inWatchlist: true });
+ await renderCard();
+ expect(getMovieGridStates).toHaveBeenCalledWith("550,603");
expect(
screen.getByRole("button", { name: "Plan Fight Club" }),
).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ });
+
+ it("shows an untouched movie as the catalog does", async () => {
+ grid();
+ await renderCard();
+ expect(
+ screen.getByRole("button", { name: "Plan Fight Club" }),
+ ).toHaveAttribute("aria-pressed", "false");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ });
+
+ it("shows a scored watched movie as Watched does: the score and the watched mark", async () => {
+ grid({ ...none, watched: true, rating: 7 });
+ await renderCard();
+ expect(screen.getByText("Your score")).toBeInTheDocument();
+ expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Unmark Fight Club as watched" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Plan Fight Club" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps the TMDB rating on an unscored watched movie", async () => {
+ grid({ ...none, watched: true });
+ await renderCard();
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Unmark Fight Club as watched" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a planned unreleased movie as Upcoming does: the date and no rating", async () => {
+ grid({ ...none, inWatchlist: true });
+ await renderCard({ releaseDate: "2026-11-20" });
+ expect(screen.getByText("Releases Nov 20, 2026")).toBeInTheDocument();
+ expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Plan Fight Club" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("shows Date TBA for a planned undated movie", async () => {
+ grid({ ...none, inWatchlist: true });
+ await renderCard({ releaseDate: null });
+ expect(
+ screen.getByText("Release date to be announced"),
+ ).toBeInTheDocument();
});
it.each([
["a visitor", { kind: "signed_out" }],
["a failed read", { kind: "failed" }],
- ])("renders nothing for %s (AC-2, AC-17)", async (_, read) => {
- getWatchlistedMovieIds.mockResolvedValue(read);
- const { container } = render(await CardBookmark(props));
- expect(container).toBeEmptyDOMElement();
+ ])("gives %s the catalog card (AC-2, AC-17)", async (_, read) => {
+ getMovieGridStates.mockResolvedValue(read);
+ await renderCard();
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
diff --git a/lib/catalog/movie-page.test.ts b/lib/catalog/movie-page.test.ts
index 62715b8..abe52fc 100644
--- a/lib/catalog/movie-page.test.ts
+++ b/lib/catalog/movie-page.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
classifyMovie,
isMovieReleased,
+ movieCardStatus,
movieReleaseNote,
upcomingReleaseDate,
} from "./movie-page";
@@ -119,3 +120,45 @@ describe("movieReleaseNote (release gate)", () => {
});
});
+describe("movieCardStatus (prompts/catalog-card-status.md)", () => {
+ const none = { inWatchlist: false, watched: false, rating: null };
+
+ it("is null with no row, or a row that is neither planned nor watched", () => {
+ expect(movieCardStatus(undefined, "2026-01-01", today)).toBeNull();
+ expect(movieCardStatus(none, "2026-01-01", today)).toBeNull();
+ });
+
+ it("carries the score on Watched, and null when unscored", () => {
+ expect(
+ movieCardStatus(
+ { ...none, watched: true, rating: 8 },
+ "2026-01-01",
+ today,
+ ),
+ ).toEqual({ page: "watched", rating: 8 });
+ expect(
+ movieCardStatus({ ...none, watched: true }, "2026-01-01", today),
+ ).toEqual({ page: "watched", rating: null });
+ });
+
+ it("puts a watched movie on Watched even before its release", () => {
+ expect(
+ movieCardStatus({ ...none, watched: true }, "2027-01-01", today),
+ ).toEqual({ page: "watched", rating: null });
+ });
+
+ it("places a planned movie by its release date, as the library does", () => {
+ const planned = { ...none, inWatchlist: true };
+ expect(movieCardStatus(planned, today, today)).toEqual({
+ page: "watchlist",
+ });
+ expect(movieCardStatus(planned, "2026-10-08", today)).toEqual({
+ page: "upcoming",
+ releaseDate: "2026-10-08",
+ });
+ expect(movieCardStatus(planned, null, today)).toEqual({
+ page: "upcoming",
+ releaseDate: null,
+ });
+ });
+});
diff --git a/lib/catalog/movie-page.ts b/lib/catalog/movie-page.ts
index de93c90..1993f1e 100644
--- a/lib/catalog/movie-page.ts
+++ b/lib/catalog/movie-page.ts
@@ -86,3 +86,41 @@ export function upcomingReleaseDate(
return airStatus(releaseDate, today) === "upcoming" ? releaseDate : null;
}
+/**
+ * What a catalog poster card shows of the user's own state for one movie
+ * (prompts/catalog-card-status.md): the library page the movie sits on, with
+ * what that page's card carries, or null when it sits on none.
+ *
+ * Placed by `classifyMovie` on the same request day, so a card on `/movies`
+ * or `/search` can never disagree with the library page it mirrors.
+ *
+ * @param state The stored state, or undefined when the user has no row.
+ * @param releaseDate The TMDB release date already on the card.
+ * @param today `requestTodayUtc()`, read once per request (AC-22).
+ */
+export function movieCardStatus(
+ state:
+ | { inWatchlist: boolean; watched: boolean; rating: number | null }
+ | undefined,
+ releaseDate: string | null,
+ today: string,
+): MovieCardStatus | null {
+ if (state === undefined) return null;
+ const page = classifyMovie({ releaseDate, ...state }, today);
+ switch (page) {
+ case null:
+ return null;
+ case "watched":
+ return { page, rating: state.rating };
+ case "watchlist":
+ return { page };
+ case "upcoming":
+ return { page, releaseDate: upcomingReleaseDate(releaseDate, today) };
+ }
+}
+
+/** A movie card's library state, from `movieCardStatus`. */
+export type MovieCardStatus =
+ | { page: "watched"; rating: number | null }
+ | { page: "watchlist" }
+ | { page: "upcoming"; releaseDate: string | null };
diff --git a/lib/tracking/movie-state.test.ts b/lib/tracking/movie-state.test.ts
index b428d54..eed00d3 100644
--- a/lib/tracking/movie-state.test.ts
+++ b/lib/tracking/movie-state.test.ts
@@ -35,7 +35,7 @@ const createClient = vi.fn(async () => ({
}));
vi.mock("@/lib/supabase/server", () => ({ createClient }));
-const { getMovieTracking, getWatchlistedMovieIds, movieIdsKey } = await import(
+const { getMovieTracking, getMovieGridStates, movieIdsKey } = await import(
"./movie-state"
);
@@ -104,58 +104,64 @@ describe("getMovieTracking", () => {
});
});
-describe("getWatchlistedMovieIds", () => {
- it("reads the planned ids for the whole grid in one query (AC-16)", async () => {
- response = { data: [{ movie_id: 550 }], error: null };
- const result = await getWatchlistedMovieIds(movieIdsKey([603, 550, 550]));
- expect(result).toEqual({ kind: "ok", state: new Set([550]) });
+describe("getMovieGridStates", () => {
+ it("reads every grid movie's state in one query (AC-16)", async () => {
+ response = {
+ data: [
+ {
+ movie_id: 550,
+ in_watchlist: false,
+ watched_at: "2026-09-01T10:00:00Z",
+ rating: 8,
+ },
+ { movie_id: 603, in_watchlist: true, watched_at: null, rating: null },
+ ],
+ error: null,
+ };
+ const result = await getMovieGridStates(movieIdsKey([603, 550, 550]));
+ expect(result).toEqual({
+ kind: "ok",
+ state: new Map([
+ [550, { inWatchlist: false, watched: true, rating: 8 }],
+ [603, { inWatchlist: true, watched: false, rating: null }],
+ ]),
+ });
expect(calls).toContainEqual({
method: "in",
args: ["movie_id", [550, 603]],
});
- expect(calls).toContainEqual({
- method: "eq",
- args: ["in_watchlist", true],
- });
});
it("is signed out for a visitor (AC-2)", async () => {
getOptionalUser.mockResolvedValue(null);
- expect(await getWatchlistedMovieIds("550")).toEqual({ kind: "signed_out" });
+ expect(await getMovieGridStates("550")).toEqual({ kind: "signed_out" });
});
- it("returns an empty set with no query for an empty grid", async () => {
- expect(await getWatchlistedMovieIds("")).toEqual({
+ it("returns an empty map with no query for an empty grid", async () => {
+ expect(await getMovieGridStates("")).toEqual({
kind: "ok",
- state: new Set(),
+ state: new Map(),
});
expect(createClient).not.toHaveBeenCalled();
});
it("is signed out, with no session read, when the public env is missing (AC-2)", async () => {
vi.stubEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", undefined);
- expect(await getWatchlistedMovieIds("550")).toEqual({
- kind: "signed_out",
- });
+ expect(await getMovieGridStates("550")).toEqual({ kind: "signed_out" });
expect(getOptionalUser).not.toHaveBeenCalled();
});
it("scopes the grid query to the session user (AC-18)", async () => {
response = { data: [], error: null };
- await getWatchlistedMovieIds("550,603");
+ await getMovieGridStates("550,603");
expect(calls).toContainEqual({ method: "eq", args: ["user_id", "user-a"] });
});
- it("logs a failed grid read with no identifiers (AC-17, AC-21)", async () => {
+ it("reports and logs a failed grid read with no identifiers (AC-17, AC-21)", async () => {
response = { data: null, error: { code: "PGRST000", message: "550" } };
- await getWatchlistedMovieIds("550,603");
+ expect(await getMovieGridStates("550,603")).toEqual({ kind: "failed" });
expect(warn).toHaveBeenCalledWith("movie_tracking.read refused db_error");
});
-
- it("reports a failed read (AC-17)", async () => {
- response = { data: null, error: { code: "PGRST000" } };
- expect(await getWatchlistedMovieIds("550,603")).toEqual({ kind: "failed" });
- });
});
describe("movieIdsKey", () => {
diff --git a/lib/tracking/movie-state.ts b/lib/tracking/movie-state.ts
index 61d0dac..5d2004a 100644
--- a/lib/tracking/movie-state.ts
+++ b/lib/tracking/movie-state.ts
@@ -69,33 +69,35 @@ export async function getMovieTracking(
}
/**
- * Which of a grid's movies the signed in user has planned, in one query for
- * the whole grid (spec 0007, AC-16).
+ * The signed in user's state for every movie on a grid, in one query for the
+ * whole grid (spec 0007, AC-16; prompts/catalog-card-status.md). A movie with
+ * no row is simply absent from the map: the user has never touched it.
*
- * Every card asks for the same set, so the argument is a primitive (a sorted,
- * comma joined id list) rather than an array: React `cache()` compares
- * arguments by identity, and only a string lets twenty cards share one read
- * per request.
+ * Every card's badge and controls ask for the same set, so the argument is a
+ * primitive (a sorted, comma joined id list) rather than an array: React
+ * `cache()` compares arguments by identity, and only a string lets every slot
+ * on the grid share one read per request.
*
* @param idsKey The grid's movie ids, sorted and joined with commas. Build it
* with `movieIdsKey`.
*/
-export const getWatchlistedMovieIds = cache(
- async (idsKey: string): Promise>> => {
+export const getMovieGridStates = cache(
+ async (
+ idsKey: string,
+ ): Promise>> => {
if (publicEnvProblems()) return { kind: "signed_out" };
const user = await getOptionalUser();
if (!user) return { kind: "signed_out" };
const ids = idsKey === "" ? [] : idsKey.split(",").map(Number);
- if (ids.length === 0) return { kind: "ok", state: new Set() };
+ if (ids.length === 0) return { kind: "ok", state: new Map() };
const supabase = await createClient();
const { data, error } = await supabase
.from("user_movie_state")
- .select("movie_id")
+ .select("movie_id, in_watchlist, watched_at, rating")
.eq("user_id", user.id)
- .eq("in_watchlist", true)
.in("movie_id", ids);
if (error) {
@@ -103,12 +105,24 @@ export const getWatchlistedMovieIds = cache(
return { kind: "failed" };
}
- return { kind: "ok", state: new Set(data.map((row) => row.movie_id)) };
+ return {
+ kind: "ok",
+ state: new Map(
+ data.map((row) => [
+ row.movie_id,
+ {
+ inWatchlist: row.in_watchlist,
+ watched: row.watched_at !== null,
+ rating: row.rating,
+ },
+ ]),
+ ),
+ };
},
);
/**
- * The cache key `getWatchlistedMovieIds` expects: the same ids in any order
+ * The cache key `getMovieGridStates` expects: the same ids in any order
* give the same key.
*
* @param ids The movie ids on the grid.
From c911ebe308b01792bbc76af353c019d8cff32376 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:31:26 +0200
Subject: [PATCH 13/25] feat(tracking): place a grid's tracked shows on their
library pages
getShowGridStatus classifies the tracked shows of one catalog grid with
the library pages' own reads and classifier, adding the calculated show
rating for shows on Watched. A failed read leaves the card plain rather
than wrong.
Co-Authored-By: Claude Opus 5.5
---
lib/tracking/library-lists.ts | 5 +-
lib/tracking/show-grid-status.test.ts | 147 ++++++++++++++++++++++++++
lib/tracking/show-grid-status.ts | 131 +++++++++++++++++++++++
3 files changed, 281 insertions(+), 2 deletions(-)
create mode 100644 lib/tracking/show-grid-status.test.ts
create mode 100644 lib/tracking/show-grid-status.ts
diff --git a/lib/tracking/library-lists.ts b/lib/tracking/library-lists.ts
index 3066cc5..8661157 100644
--- a/lib/tracking/library-lists.ts
+++ b/lib/tracking/library-lists.ts
@@ -396,9 +396,10 @@ function compareDesc(a: string, b: string): number {
* `getWatchedEpisodeIds` does, so a long history never loses rows to the
* response cap. The pages are read one at a time, also as that read does:
* every library tab runs this on each request, so a long history must not
- * open a connection per thousand episodes at once.
+ * open a connection per thousand episodes at once. The catalog grids read it
+ * too, for their tracked shows (prompts/catalog-card-status.md).
*/
-async function readWatchedRegular(
+export async function readWatchedRegular(
supabase: Awaited>,
userId: string,
showIds: readonly number[],
diff --git a/lib/tracking/show-grid-status.test.ts b/lib/tracking/show-grid-status.test.ts
new file mode 100644
index 0000000..ee00c75
--- /dev/null
+++ b/lib/tracking/show-grid-status.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: prompts/catalog-card-status.md, R5 to R8
+ *
+ * The composition is under test: the reads and the classifier are replaced,
+ * so each case says only what reaches the cards.
+ */
+vi.mock("server-only", () => ({}));
+const getTrackedShows = vi.fn();
+vi.mock("./show-state", () => ({
+ getTrackedShows: (...args: unknown[]) => getTrackedShows(...args),
+}));
+const getOptionalUser = vi.fn();
+vi.mock("@/lib/auth/user", () => ({ getOptionalUser }));
+vi.mock("@/lib/supabase/server", () => ({ createClient: async () => ({}) }));
+const readWatchedRegular = vi.fn();
+vi.mock("./library-lists", () => ({
+ readWatchedRegular: (...args: unknown[]) => readWatchedRegular(...args),
+}));
+class TmdbError extends Error {}
+const getTvShowsSettled = vi.fn();
+vi.mock("@/lib/tmdb", () => ({
+ TmdbError,
+ getTvShowsSettled: (...args: unknown[]) => getTvShowsSettled(...args),
+}));
+const classifyShow = vi.fn();
+vi.mock("@/lib/tv/library-page", () => ({
+ classifyShow: (...args: unknown[]) => classifyShow(...args),
+}));
+const getShowRatings = vi.fn();
+vi.mock("./show-ratings", () => ({
+ getShowRatings: (...args: unknown[]) => getShowRatings(...args),
+}));
+vi.mock("./episode-state", () => ({ requestTodayUtc: () => "2026-10-10" }));
+
+const { getShowGridStatus } = await import("./show-grid-status");
+
+const WATCHLIST = { page: "watchlist", next: { season: 1, episode: 2 } };
+const WATCHED = { page: "watched", label: "finished" };
+
+let warn: ReturnType;
+
+beforeEach(() => {
+ getOptionalUser.mockResolvedValue({ id: "user-a" });
+ getTrackedShows.mockResolvedValue({ kind: "ok", state: new Set([1, 2]) });
+ readWatchedRegular.mockResolvedValue(new Map());
+ getTvShowsSettled.mockResolvedValue({
+ found: new Map([
+ [1, { id: 1 }],
+ [2, { id: 2 }],
+ ]),
+ missingIds: [],
+ failedIds: [],
+ });
+ classifyShow.mockImplementation((show: { id: number }) =>
+ show.id === 1 ? WATCHLIST : WATCHED,
+ );
+ getShowRatings.mockResolvedValue({
+ kind: "ok",
+ ratings: new Map([[2, 7.5]]),
+ });
+ warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+ warn.mockRestore();
+});
+
+describe("getShowGridStatus", () => {
+ it("passes a visitor or a failed tracked read straight through", async () => {
+ getTrackedShows.mockResolvedValue({ kind: "signed_out" });
+ expect(await getShowGridStatus("1,2,3")).toEqual({ kind: "signed_out" });
+ getTrackedShows.mockResolvedValue({ kind: "failed" });
+ expect(await getShowGridStatus("1,2,4")).toEqual({ kind: "failed" });
+ });
+
+ it("places each tracked show, with the show rating only on Watched", async () => {
+ expect(await getShowGridStatus("1,2,3")).toEqual({
+ kind: "ok",
+ state: new Map([
+ [1, { ...WATCHLIST, rating: null }],
+ [2, { ...WATCHED, rating: 7.5 }],
+ ]),
+ });
+ expect(getTvShowsSettled).toHaveBeenCalledWith([1, 2]);
+ expect(readWatchedRegular).toHaveBeenCalledWith({}, "user-a", [1, 2]);
+ expect(getShowRatings).toHaveBeenCalledWith("user-a", [2]);
+ });
+
+ it("reads nothing more when no grid show is tracked", async () => {
+ getTrackedShows.mockResolvedValue({ kind: "ok", state: new Set() });
+ expect(await getShowGridStatus("5,6")).toEqual({
+ kind: "ok",
+ state: new Map(),
+ });
+ expect(getTvShowsSettled).not.toHaveBeenCalled();
+ });
+
+ it("leaves every tracked show plain when the episodes fail", async () => {
+ readWatchedRegular.mockResolvedValue(null);
+ expect(await getShowGridStatus("1,2,7")).toEqual({
+ kind: "ok",
+ state: new Map([
+ [1, null],
+ [2, null],
+ ]),
+ });
+ expect(warn).toHaveBeenCalledWith(
+ "show_tracking.watched_read refused db_error",
+ );
+ });
+
+ it("leaves every tracked show plain on a systemic TMDB failure", async () => {
+ getTvShowsSettled.mockRejectedValue(new TmdbError("rate limited"));
+ const result = await getShowGridStatus("1,2,8");
+ expect(result).toEqual({
+ kind: "ok",
+ state: new Map([
+ [1, null],
+ [2, null],
+ ]),
+ });
+ });
+
+ it("leaves a show plain when its own TMDB read failed", async () => {
+ getTvShowsSettled.mockResolvedValue({
+ found: new Map([[1, { id: 1 }]]),
+ missingIds: [],
+ failedIds: [2],
+ });
+ const result = await getShowGridStatus("1,2,9");
+ expect(result.kind === "ok" && result.state.get(2)).toBeNull();
+ expect(getShowRatings).not.toHaveBeenCalled();
+ });
+
+ it("leaves a Watched show plain, never unrated, when its rating read fails", async () => {
+ getShowRatings.mockResolvedValue({ kind: "failed" });
+ const result = await getShowGridStatus("1,2,10");
+ expect(result.kind === "ok" && result.state.get(2)).toBeNull();
+ expect(result.kind === "ok" && result.state.get(1)).toEqual({
+ ...WATCHLIST,
+ rating: null,
+ });
+ });
+});
diff --git a/lib/tracking/show-grid-status.ts b/lib/tracking/show-grid-status.ts
new file mode 100644
index 0000000..7ae9600
--- /dev/null
+++ b/lib/tracking/show-grid-status.ts
@@ -0,0 +1,131 @@
+import "server-only";
+
+import { cache } from "react";
+
+import { getOptionalUser } from "@/lib/auth/user";
+import { createClient } from "@/lib/supabase/server";
+import { getTvShowsSettled, TmdbError } from "@/lib/tmdb";
+import { classifyShow, type ShowPage } from "@/lib/tv/library-page";
+
+import { requestTodayUtc } from "./episode-state";
+import { readWatchedRegular } from "./library-lists";
+import { logTrackingEvent, TRACKING_EVENT } from "./log";
+import { getShowRatings } from "./show-ratings";
+import { getTrackedShows } from "./show-state";
+import type { TrackingRead } from "./types";
+
+/**
+ * What a catalog card shows of one tracked show: the library page it sits on
+ * with that page's card data, and on Watched the calculated show rating.
+ * Null when the show is tracked but its page could not be worked out (its
+ * TMDB details or the user's episodes failed to load): the card is then a
+ * plain tracked card, never a wrong label.
+ */
+export type ShowCardStatus =
+ | (Exclude & { rating: null })
+ | (Extract & { rating: number | null });
+
+/**
+ * The signed in user's tracked shows on a grid, each with the library page it
+ * sits on (prompts/catalog-card-status.md). An untracked show is absent from
+ * the map; a tracked one is present, with its status or null.
+ *
+ * One pass per grid: the tracked set (`getTrackedShows`, shared with nothing
+ * else), then for the tracked ids only the watched regular episodes, the
+ * cached TMDB details and `classifyShow`, the same inputs the library pages
+ * read, on the same request day, so a card cannot disagree with its page.
+ * The calculated rating is read only for the shows that land on Watched.
+ *
+ * Request scoped and wrapped in `cache()` with a string key, so a card's
+ * badge, meta line and controls share one pass. Never inside `use cache`
+ * (`AGENTS.md` section 11).
+ *
+ * @param idsKey The grid's show ids, from `showIdsKey`.
+ */
+export const getShowGridStatus = cache(
+ async (
+ idsKey: string,
+ ): Promise>> => {
+ const tracked = await getTrackedShows(idsKey);
+ if (tracked.kind !== "ok") return tracked;
+
+ const ids = [...tracked.state];
+ const state = new Map(
+ ids.map((id) => [id, null]),
+ );
+ if (ids.length === 0) return { kind: "ok", state };
+
+ const user = await getOptionalUser();
+ if (!user) return { kind: "signed_out" };
+
+ const places = await placeShows(user.id, ids);
+ if (places === null) return { kind: "ok", state };
+
+ const watchedIds = [...places]
+ .filter(([, place]) => place.page === "watched")
+ .map(([id]) => id);
+ const ratings =
+ watchedIds.length === 0
+ ? null
+ : await getShowRatings(user.id, watchedIds);
+
+ for (const [id, place] of places) {
+ if (place.page !== "watched") {
+ state.set(id, { ...place, rating: null });
+ continue;
+ }
+ // A failed ratings read leaves the card plain rather than unrated.
+ if (ratings?.kind !== "ok") continue;
+ state.set(id, { ...place, rating: ratings.ratings.get(id) ?? null });
+ }
+ return { kind: "ok", state };
+ },
+);
+
+/**
+ * `classifyShow` for each show whose details loaded, or null when the
+ * episodes or TMDB failed as a whole (logged here, with no identifiers). A
+ * show whose own read failed, or that TMDB no longer has, is simply absent.
+ */
+async function placeShows(
+ userId: string,
+ ids: readonly number[],
+): Promise | null> {
+ const [watched, shows] = await Promise.all([
+ readWatched(userId, ids),
+ readShows(ids),
+ ]);
+ if (watched === null || shows === null) return null;
+
+ const today = requestTodayUtc();
+ const places = new Map();
+ for (const [id, show] of shows.found) {
+ places.set(id, classifyShow(show, watched.get(id) ?? new Set(), today));
+ }
+ return places;
+}
+
+async function readWatched(userId: string, ids: readonly number[]) {
+ try {
+ const supabase = await createClient();
+ const watched = await readWatchedRegular(supabase, userId, ids);
+ if (watched === null)
+ logTrackingEvent(TRACKING_EVENT.watchedEpisodesRead, "db_error");
+ return watched;
+ } catch {
+ // A network failure inside supabase-js. The error is dropped: its
+ // message can carry request details.
+ logTrackingEvent(TRACKING_EVENT.watchedEpisodesRead, "db_error");
+ return null;
+ }
+}
+
+async function readShows(ids: readonly number[]) {
+ try {
+ return await getTvShowsSettled(ids);
+ } catch (error) {
+ if (!(error instanceof TmdbError)) throw error;
+ logTrackingEvent(TRACKING_EVENT.showTrackingRead, "tmdb_unavailable");
+ return null;
+ }
+}
From 1ec0c05c499e765f831224b02bfc4a6a4b622781 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:31:26 +0200
Subject: [PATCH 14/25] feat(catalog): show a tracked show's library status on
its grid card
Show cards on /shows and /search keep the Stop tracking bookmark and add
what the show's library page shows: the next episode date on Upcoming,
and the show rating with Finished or Caught up on Watched.
Co-Authored-By: Claude Opus 5.5
---
app/movies/request-scope.test.ts | 13 +-
app/shows/page.tsx | 29 +++-
components/search/search-results.tsx | 29 +++-
components/tracking/show-card-bookmark.tsx | 44 -------
components/tracking/show-card-slots.test.tsx | 132 +++++++++++++++++++
components/tracking/show-card-slots.tsx | 113 ++++++++++++++++
6 files changed, 304 insertions(+), 56 deletions(-)
delete mode 100644 components/tracking/show-card-bookmark.tsx
create mode 100644 components/tracking/show-card-slots.test.tsx
create mode 100644 components/tracking/show-card-slots.tsx
diff --git a/app/movies/request-scope.test.ts b/app/movies/request-scope.test.ts
index 6eb5266..0dc512c 100644
--- a/app/movies/request-scope.test.ts
+++ b/app/movies/request-scope.test.ts
@@ -304,12 +304,15 @@ describe("private tracking state never enters a cache scope (spec 0007, AC-19; s
const show = readFileSync("app/shows/[id]/page.tsx", "utf8");
expect(show).toMatch(/\s*\s*\s*\s*\s* \}\s*>\s*\s*\s*\s*\s* }
+ badge={
+ }
+ >
+
+
+ }
+ meta={
+
+
+
+ }
controls={
-
{results.map((result, index) => {
+ const year = result.year === null ? null : String(result.year);
return (
+
+
+ )
+ }
badge={
}
@@ -234,7 +251,11 @@ function ResultGrid({
gridMovieIds={gridIds}
/>
) : (
-
+
)}
}
@@ -251,7 +272,7 @@ function ResultGrid({
) : (
-
- );
-}
-
-export { ShowCardBookmark };
diff --git a/components/tracking/show-card-slots.test.tsx b/components/tracking/show-card-slots.test.tsx
new file mode 100644
index 0000000..44d5c0f
--- /dev/null
+++ b/components/tracking/show-card-slots.test.tsx
@@ -0,0 +1,132 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: spec 0020, AC-6; prompts/catalog-card-status.md, R5, R7
+ *
+ * The grid read is the boundary, so it is what is replaced.
+ */
+const getShowGridStatus = vi.fn();
+vi.mock("@/lib/tracking/show-grid-status", () => ({
+ getShowGridStatus: (...args: unknown[]) => getShowGridStatus(...args),
+}));
+vi.mock("@/lib/tracking/show-state", () => ({
+ showIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
+}));
+vi.mock("@/lib/tracking/episode-state", () => ({
+ requestTodayUtc: () => "2026-10-10",
+}));
+vi.mock("@/app/shows/actions", () => ({}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
+vi.mock("sonner", () => ({ toast: vi.fn() }));
+
+const { ShowCardBadge, ShowCardControls, ShowCardMeta } = await import(
+ "./show-card-slots"
+);
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+const props = { showId: 1399, gridShowIds: [1400, 1399] };
+
+function grid(status?: unknown) {
+ getShowGridStatus.mockResolvedValue({
+ kind: "ok",
+ state: new Map(status === undefined ? [] : [[1399, status]]),
+ });
+}
+
+async function renderCard(base: string | null = null) {
+ return render(
+ <>
+ {await ShowCardBadge({ ...props, tmdbRating: 8.4 })}
+ {await ShowCardMeta({ ...props, base })}
+ {
+ await ShowCardControls({
+ ...props,
+ name: "Game of Thrones",
+ returnPath: "/shows",
+ })
+ }
+ >,
+ );
+}
+
+function trackedBookmark() {
+ return screen.getByRole("button", { name: "Stop tracking Game of Thrones" });
+}
+
+describe("show card slots", () => {
+ it("asks for the whole grid under one shared key (AC-6)", async () => {
+ grid();
+ await renderCard();
+ expect(getShowGridStatus).toHaveBeenCalledWith("1399,1400");
+ });
+
+ it("shows an untracked show as the catalog does", async () => {
+ grid();
+ await renderCard("2011");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.getByTestId("meta")).toHaveTextContent(/^2011$/);
+ expect(
+ screen.getByRole("button", { name: "Track Game of Thrones" }),
+ ).toBeInTheDocument();
+ });
+
+ it("keeps a tracked show on Watchlist as it is today", async () => {
+ grid({ page: "watchlist", next: { season: 2, episode: 3 }, rating: null });
+ await renderCard();
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.getByTestId("meta")).toBeEmptyDOMElement();
+ expect(trackedBookmark()).toBeInTheDocument();
+ });
+
+ it("shows a show on Upcoming with its next episode date and no rating", async () => {
+ grid({
+ page: "upcoming",
+ airDate: "2026-10-20",
+ next: { season: 2, episode: 1 },
+ rating: null,
+ });
+ await renderCard();
+ expect(screen.getByText("S2E1 · Oct 20")).toBeInTheDocument();
+ expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
+ expect(trackedBookmark()).toBeInTheDocument();
+ });
+
+ it("shows a rated finished show with its show rating and label", async () => {
+ grid({ page: "watched", label: "finished", rating: 7.25 });
+ await renderCard("2011");
+ expect(screen.getByText("Your show rating")).toBeInTheDocument();
+ expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
+ expect(screen.getByTestId("meta")).toHaveTextContent("2011 · Finished");
+ expect(trackedBookmark()).toBeInTheDocument();
+ });
+
+ it("keeps the TMDB rating on an unrated caught up show", async () => {
+ grid({ page: "watched", label: "caught_up", rating: null });
+ await renderCard();
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.getByTestId("meta")).toHaveTextContent(/^Caught up$/);
+ });
+
+ it("leaves a tracked show whose page is unknown a plain tracked card", async () => {
+ grid(null);
+ await renderCard("2011");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.getByTestId("meta")).toHaveTextContent(/^2011$/);
+ expect(trackedBookmark()).toBeInTheDocument();
+ });
+
+ it.each([
+ ["a visitor", { kind: "signed_out" }],
+ ["a failed read", { kind: "failed" }],
+ ])("gives %s the catalog card", async (_, read) => {
+ getShowGridStatus.mockResolvedValue(read);
+ await renderCard("2011");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.getByTestId("meta")).toHaveTextContent(/^2011$/);
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+});
diff --git a/components/tracking/show-card-slots.tsx b/components/tracking/show-card-slots.tsx
new file mode 100644
index 0000000..98c7784
--- /dev/null
+++ b/components/tracking/show-card-slots.tsx
@@ -0,0 +1,113 @@
+import { UpcomingShowPill } from "@/components/library/show-cards";
+import {
+ CalculatedRatingBadge,
+ TmdbRatingBadge,
+} from "@/components/rating-badges";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { WATCHED_SHOW_LABELS } from "@/lib/tracking/messages";
+import { getShowGridStatus } from "@/lib/tracking/show-grid-status";
+import { showIdsKey } from "@/lib/tracking/show-state";
+
+import { ShowCardBookmarkButton } from "./show-card-bookmark-button";
+
+/**
+ * The three personal slots of a TV poster card on `/shows` and `/search`
+ * (spec 0020, AC-6; prompts/catalog-card-status.md): a tracked show carries
+ * what its library page shows, and keeps the bookmark that stops tracking.
+ *
+ * Every slot on the grid passes the same ids, so `getShowGridStatus` runs
+ * once per render for the whole grid, and each slot is its own Suspense
+ * boundary, so the posters never wait on the session. A visitor and a failed
+ * read get the catalog card.
+ */
+type ShowCardProps = {
+ showId: number;
+ /** Every show id on the grid, the same array for each card. */
+ gridShowIds: readonly number[];
+};
+
+/** The grid's read, or undefined for a visitor or a failed read. */
+async function readGrid(gridShowIds: readonly number[]) {
+ const result = await getShowGridStatus(showIdsKey(gridShowIds));
+ return result.kind === "ok" ? result.state : undefined;
+}
+
+/**
+ * The top right badge. The page renders the TMDB rating as the fallback, so
+ * only tracked shows swap: the calculated show rating on a rated show on
+ * Watched, nothing on Upcoming, the TMDB rating otherwise.
+ *
+ * @param tmdbRating The card's TMDB rating.
+ */
+async function ShowCardBadge({
+ showId,
+ gridShowIds,
+ tmdbRating,
+}: ShowCardProps & { tmdbRating: number | null }) {
+ const status = (await readGrid(gridShowIds))?.get(showId);
+ if (status?.page === "watched" && status.rating !== null) {
+ return (
+
+ );
+ }
+ if (status?.page === "upcoming") return null;
+ return ;
+}
+
+/**
+ * The line under the name: Finished or Caught up for a show on Watched,
+ * after the catalog's own text (the year on `/search`) when there is one.
+ *
+ * @param base What the line shows today, and the Suspense fallback.
+ */
+async function ShowCardMeta({
+ showId,
+ gridShowIds,
+ base,
+}: ShowCardProps & { base: string | null }) {
+ const status = (await readGrid(gridShowIds))?.get(showId);
+ if (status?.page !== "watched") return base;
+ const label = WATCHED_SHOW_LABELS[status.label];
+ return base === null ? label : `${base} · ${label}`;
+}
+
+/**
+ * The bottom row: the next episode's date pill or Date TBA on Upcoming, and
+ * the bookmark, filled for every tracked show. Nothing for a visitor or a
+ * failed read.
+ *
+ * @param name The TMDB name, for the accessible name and the toasts.
+ * @param returnPath The grid page, for the session expired toast.
+ */
+async function ShowCardControls({
+ showId,
+ gridShowIds,
+ name,
+ returnPath,
+}: ShowCardProps & { name: string; returnPath: string }) {
+ const grid = await readGrid(gridShowIds);
+ if (grid === undefined) return null;
+ const status = grid.get(showId);
+
+ const bookmark = (
+
+ );
+ if (status?.page !== "upcoming") return bookmark;
+ return (
+ <>
+
+ {bookmark}
+ >
+ );
+}
+
+export { ShowCardBadge, ShowCardControls, ShowCardMeta };
From 3004216180704a931384d267b6d36fa9590556a5 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:39:17 +0200
Subject: [PATCH 15/25] refactor(format): move the release date pill formatting
into lib/format
Co-Authored-By: Claude Opus 5.5
---
components/library/dated-pill.tsx | 26 ++---------------------
components/library/movie-library-tab.tsx | 4 ++--
components/library/types.ts | 8 +++----
components/tracking/movie-card-slots.tsx | 5 +++--
lib/format.test.ts | 18 ++++++++++++++++
lib/format.ts | 27 ++++++++++++++++++++++++
6 files changed, 55 insertions(+), 33 deletions(-)
diff --git a/components/library/dated-pill.tsx b/components/library/dated-pill.tsx
index 4142ac3..9c9f978 100644
--- a/components/library/dated-pill.tsx
+++ b/components/library/dated-pill.tsx
@@ -2,11 +2,9 @@ import { cn } from "cn";
import { GlassPill } from "@/components/glass-pill";
import { CalendarIcon } from "@/components/tracking/tracking-icons";
-import { formatAirDate, formatShortDate } from "@/lib/format";
+import type { ReleaseDate } from "@/lib/format";
import { UPCOMING_MESSAGES } from "@/lib/tracking/messages";
-import type { ReleaseDate } from "./types";
-
/** A short date that carries its year, such as `Feb 3, 2027`. */
const ENDS_IN_YEAR = /\d{4}$/;
@@ -72,26 +70,6 @@ function DateTbaPill({ label }: { label: string }) {
);
}
-/**
- * A movie's Upcoming release date, formatted beside the `today` that decided
- * it (spec 0020, AC-13), or null for Date TBA. The library Upcoming card and
- * the catalog cards both format through it (prompts/catalog-card-status.md).
- *
- * @param releaseDate A date `upcomingReleaseDate` returned, or null.
- * @param today `requestTodayUtc()`, for `formatShortDate`'s year rule.
- */
-function releaseDateFor(
- releaseDate: string | null,
- today: string,
-): ReleaseDate {
- if (releaseDate === null) return null;
- const fullDate = formatAirDate(releaseDate) ?? releaseDate;
- return {
- fullDate,
- shortDate: formatShortDate(releaseDate, today) ?? fullDate,
- };
-}
-
/** The release date on an Upcoming movie card, or Date TBA (AC-13). */
function ReleasePill({ release }: { release: ReleaseDate }) {
if (release === null) {
@@ -106,4 +84,4 @@ function ReleasePill({ release }: { release: ReleaseDate }) {
);
}
-export { DatedPill, DateTbaPill, ReleasePill, releaseDateFor };
+export { DatedPill, DateTbaPill, ReleasePill };
diff --git a/components/library/movie-library-tab.tsx b/components/library/movie-library-tab.tsx
index ea98c2a..a9e8e44 100644
--- a/components/library/movie-library-tab.tsx
+++ b/components/library/movie-library-tab.tsx
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { PaginationLinks } from "@/components/pagination-links";
+import { formatReleaseDate } from "@/lib/format";
import {
getLibraryMovieTitles,
getMovieLibraryTab,
@@ -13,7 +14,6 @@ import {
} from "@/lib/tracking/library-lists";
import { LIBRARY_COPY } from "@/lib/tracking/messages";
-import { releaseDateFor } from "./dated-pill";
import { LibraryGrid } from "./library-grid";
import {
EmptyPanel,
@@ -67,7 +67,7 @@ function movieItem(card: MovieTabCard, today: string): LibraryMovieItem {
tmdbRating: card.title.tmdbRating,
};
if (card.page === "watchlist") return base;
- return { ...base, release: releaseDateFor(card.releaseDate, today) };
+ return { ...base, release: formatReleaseDate(card.releaseDate, today) };
}
/**
diff --git a/components/library/types.ts b/components/library/types.ts
index 404831e..7a4f6c0 100644
--- a/components/library/types.ts
+++ b/components/library/types.ts
@@ -6,11 +6,9 @@
export type { LibraryList } from "@/lib/catalog/library-list";
-/**
- * A movie's release date on an Upcoming card, formatted on the server beside
- * the `today` that decided it (spec 0020, AC-13), or null for Date TBA.
- */
-export type ReleaseDate = { shortDate: string; fullDate: string } | null;
+import type { ReleaseDate } from "@/lib/format";
+
+export type { ReleaseDate };
/**
* One movie card: a Postgres row joined with its TMDB title, when TMDB has
diff --git a/components/tracking/movie-card-slots.tsx b/components/tracking/movie-card-slots.tsx
index a69dbac..967ba02 100644
--- a/components/tracking/movie-card-slots.tsx
+++ b/components/tracking/movie-card-slots.tsx
@@ -1,9 +1,10 @@
-import { ReleasePill, releaseDateFor } from "@/components/library/dated-pill";
+import { ReleasePill } from "@/components/library/dated-pill";
import {
PersonalScoreBadge,
TmdbRatingBadge,
} from "@/components/rating-badges";
import { movieCardStatus } from "@/lib/catalog/movie-page";
+import { formatReleaseDate } from "@/lib/format";
import { requestTodayUtc } from "@/lib/tracking/episode-state";
import { getMovieGridStates, movieIdsKey } from "@/lib/tracking/movie-state";
@@ -104,7 +105,7 @@ async function MovieCardControls({
return (
<>
{bookmark}
>
diff --git a/lib/format.test.ts b/lib/format.test.ts
index 4519473..f4a1cbb 100644
--- a/lib/format.test.ts
+++ b/lib/format.test.ts
@@ -5,6 +5,7 @@ import {
formatAirSpan,
formatEpisodeCount,
formatPersonalScore,
+ formatReleaseDate,
formatRuntime,
formatShortDate,
formatVoteCount,
@@ -233,6 +234,23 @@ describe("formatShortDate · covers spec 0014 AC-5", () => {
});
});
+describe("formatReleaseDate · covers spec 0020 AC-13", () => {
+ it("gives the full date and the year aware short date", () => {
+ expect(formatReleaseDate("2027-02-03", "2026-09-28")).toEqual({
+ fullDate: "Feb 3, 2027",
+ shortDate: "Feb 3, 2027",
+ });
+ expect(formatReleaseDate("2026-12-25", "2026-09-28")).toEqual({
+ fullDate: "Dec 25, 2026",
+ shortDate: "Dec 25",
+ });
+ });
+
+ it("is null for Date TBA", () => {
+ expect(formatReleaseDate(null, "2026-09-28")).toBeNull();
+ });
+});
+
describe("parseTmdbDate · covers spec 0011 AC-3", () => {
it("is UTC midnight of the day TMDB wrote", () => {
expect(parseTmdbDate("2013-03-03")?.toISOString()).toBe(
diff --git a/lib/format.ts b/lib/format.ts
index 6ab3b30..face8c2 100644
--- a/lib/format.ts
+++ b/lib/format.ts
@@ -210,6 +210,33 @@ export function formatShortDate(
: AIR_DATE_FORMAT.format(utc);
}
+/**
+ * A movie's release date on an Upcoming card, formatted on the server beside
+ * the `today` that decided it (spec 0020, AC-13), or null for Date TBA.
+ */
+export type ReleaseDate = { shortDate: string; fullDate: string } | null;
+
+/**
+ * A movie's Upcoming release date in both pill forms (spec 0020, AC-13). The
+ * library Upcoming card and the catalog cards both format through it
+ * (prompts/catalog-card-status.md), so the two pills never disagree.
+ *
+ * @param releaseDate A date `upcomingReleaseDate` returned, or null.
+ * @param today `requestTodayUtc()`, for `formatShortDate`'s year rule.
+ * @returns Both forms, or null for Date TBA.
+ */
+export function formatReleaseDate(
+ releaseDate: string | null,
+ today: string,
+): ReleaseDate {
+ if (releaseDate === null) return null;
+ const fullDate = formatAirDate(releaseDate) ?? releaseDate;
+ return {
+ fullDate,
+ shortDate: formatShortDate(releaseDate, today) ?? fullDate,
+ };
+}
+
/**
* A TMDB calendar date as a UTC midnight `Date`, or null when it is missing,
* not `YYYY-MM-DD`, or not a real day.
From 39d85b163b8d5bc227484c5b8f10db878796a636 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:39:28 +0200
Subject: [PATCH 16/25] refactor(catalog): type a movie card's state as
MovieTrackingState
Co-Authored-By: Claude Opus 5.5
---
lib/catalog/movie-page.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/lib/catalog/movie-page.ts b/lib/catalog/movie-page.ts
index 1993f1e..7174589 100644
--- a/lib/catalog/movie-page.ts
+++ b/lib/catalog/movie-page.ts
@@ -1,6 +1,7 @@
import type { LibraryList } from "@/lib/catalog/library-list";
import { formatAirDate } from "@/lib/format";
import { UPCOMING_MESSAGES } from "@/lib/tracking/messages";
+import type { MovieTrackingState } from "@/lib/tracking/types";
import { airStatus } from "@/lib/tv/air-status";
/** The library pages a movie can sit on (spec 0020, AC-13). */
@@ -99,9 +100,7 @@ export function upcomingReleaseDate(
* @param today `requestTodayUtc()`, read once per request (AC-22).
*/
export function movieCardStatus(
- state:
- | { inWatchlist: boolean; watched: boolean; rating: number | null }
- | undefined,
+ state: MovieTrackingState | undefined,
releaseDate: string | null,
today: string,
): MovieCardStatus | null {
From e75cb109d91ec7963a87a823ca39f2ef9b02ec61 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:39:50 +0200
Subject: [PATCH 17/25] fix(tracking): log a grid's TMDB failure under its own
event
Co-Authored-By: Claude Opus 5.5
---
lib/tracking/log.ts | 1 +
lib/tracking/show-grid-status.test.ts | 3 +++
lib/tracking/show-grid-status.ts | 2 +-
3 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/lib/tracking/log.ts b/lib/tracking/log.ts
index c2b8560..9bdd6ae 100644
--- a/lib/tracking/log.ts
+++ b/lib/tracking/log.ts
@@ -34,6 +34,7 @@ export const TRACKING_EVENT = {
restoreShowTracking: "show_tracking.restore",
showTrackingRead: "show_tracking.read",
watchedEpisodesRead: "show_tracking.watched_read",
+ showGridRead: "show_tracking.grid_read",
} as const;
export type TrackingEvent =
diff --git a/lib/tracking/show-grid-status.test.ts b/lib/tracking/show-grid-status.test.ts
index ee00c75..f7c2a25 100644
--- a/lib/tracking/show-grid-status.test.ts
+++ b/lib/tracking/show-grid-status.test.ts
@@ -122,6 +122,9 @@ describe("getShowGridStatus", () => {
[2, null],
]),
});
+ expect(warn).toHaveBeenCalledWith(
+ "show_tracking.grid_read refused tmdb_unavailable",
+ );
});
it("leaves a show plain when its own TMDB read failed", async () => {
diff --git a/lib/tracking/show-grid-status.ts b/lib/tracking/show-grid-status.ts
index 7ae9600..7a33f9a 100644
--- a/lib/tracking/show-grid-status.ts
+++ b/lib/tracking/show-grid-status.ts
@@ -125,7 +125,7 @@ async function readShows(ids: readonly number[]) {
return await getTvShowsSettled(ids);
} catch (error) {
if (!(error instanceof TmdbError)) throw error;
- logTrackingEvent(TRACKING_EVENT.showTrackingRead, "tmdb_unavailable");
+ logTrackingEvent(TRACKING_EVENT.showGridRead, "tmdb_unavailable");
return null;
}
}
From 4d9aca6b15f85f73d0b3e8917b32f36dfa14bf49 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:40:37 +0200
Subject: [PATCH 18/25] refactor(tracking): share the catalog card badge rule
between movies and shows
Co-Authored-By: Claude Opus 5.5
---
components/tracking/card-rating-badge.tsx | 31 +++++++++
components/tracking/movie-card-slots.tsx | 65 +++++++++---------
components/tracking/show-card-slots.tsx | 82 ++++++++++++-----------
3 files changed, 108 insertions(+), 70 deletions(-)
create mode 100644 components/tracking/card-rating-badge.tsx
diff --git a/components/tracking/card-rating-badge.tsx b/components/tracking/card-rating-badge.tsx
new file mode 100644
index 0000000..1ccf733
--- /dev/null
+++ b/components/tracking/card-rating-badge.tsx
@@ -0,0 +1,31 @@
+import type { ReactNode } from "react";
+
+import { TmdbRatingBadge } from "@/components/rating-badges";
+import type { LibraryList } from "@/lib/catalog/library-list";
+
+/**
+ * The top right badge of a catalog poster card, shared by movies and shows
+ * (prompts/catalog-card-status.md, R1 to R5): the user's own rating when the
+ * title is on Watched with one, nothing on Upcoming, where the date pill
+ * speaks instead, and the TMDB rating otherwise. One rule for both media
+ * types, so the two grids cannot drift apart.
+ *
+ * @param page The library page the title sits on, or undefined when none.
+ * @param personal The personal badge, or null when there is no rating.
+ * @param tmdbRating The card's TMDB rating.
+ */
+function CardRatingBadge({
+ page,
+ personal,
+ tmdbRating,
+}: {
+ page: LibraryList | undefined;
+ personal: ReactNode;
+ tmdbRating: number | null;
+}) {
+ if (page === "watched" && personal !== null) return personal;
+ if (page === "upcoming") return null;
+ return ;
+}
+
+export { CardRatingBadge };
diff --git a/components/tracking/movie-card-slots.tsx b/components/tracking/movie-card-slots.tsx
index 967ba02..98733bc 100644
--- a/components/tracking/movie-card-slots.tsx
+++ b/components/tracking/movie-card-slots.tsx
@@ -1,16 +1,3 @@
-import { ReleasePill } from "@/components/library/dated-pill";
-import {
- PersonalScoreBadge,
- TmdbRatingBadge,
-} from "@/components/rating-badges";
-import { movieCardStatus } from "@/lib/catalog/movie-page";
-import { formatReleaseDate } from "@/lib/format";
-import { requestTodayUtc } from "@/lib/tracking/episode-state";
-import { getMovieGridStates, movieIdsKey } from "@/lib/tracking/movie-state";
-
-import { CardBookmarkButton } from "./card-bookmark-button";
-import { CardWatchedButton } from "./card-watched-button";
-
/**
* The two personal slots of a movie poster card on `/movies` and `/search`
* (spec 0007, AC-16; prompts/catalog-card-status.md): each card shows the
@@ -23,6 +10,17 @@ import { CardWatchedButton } from "./card-watched-button";
* is quieter than an error on twenty cards (AC-2, AC-17). The failure is
* logged inside the read.
*/
+import { ReleasePill } from "@/components/library/dated-pill";
+import { PersonalScoreBadge } from "@/components/rating-badges";
+import { movieCardStatus } from "@/lib/catalog/movie-page";
+import { formatReleaseDate } from "@/lib/format";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { getMovieGridStates, movieIdsKey } from "@/lib/tracking/movie-state";
+
+import { CardBookmarkButton } from "./card-bookmark-button";
+import { CardRatingBadge } from "./card-rating-badge";
+import { CardWatchedButton } from "./card-watched-button";
+
type MovieCardProps = {
movieId: number;
/** The TMDB release date already on the card. */
@@ -57,13 +55,18 @@ async function MovieCardBadge({
tmdbRating,
...props
}: MovieCardProps & { tmdbRating: number | null }) {
- const read = await readStatus(props);
- const status = read?.status;
- if (status?.page === "watched" && status.rating !== null) {
- return ;
- }
- if (status?.page === "upcoming") return null;
- return ;
+ const status = (await readStatus(props))?.status;
+ return (
+
+ ) : null
+ }
+ tmdbRating={tmdbRating}
+ />
+ );
}
/**
@@ -93,21 +96,19 @@ async function MovieCardControls({
);
}
- const bookmark = (
-
- );
- if (status?.page !== "upcoming") return bookmark;
return (
<>
-
+ )}
+
- {bookmark}
>
);
}
diff --git a/components/tracking/show-card-slots.tsx b/components/tracking/show-card-slots.tsx
index 98c7784..d53f339 100644
--- a/components/tracking/show-card-slots.tsx
+++ b/components/tracking/show-card-slots.tsx
@@ -1,15 +1,3 @@
-import { UpcomingShowPill } from "@/components/library/show-cards";
-import {
- CalculatedRatingBadge,
- TmdbRatingBadge,
-} from "@/components/rating-badges";
-import { requestTodayUtc } from "@/lib/tracking/episode-state";
-import { WATCHED_SHOW_LABELS } from "@/lib/tracking/messages";
-import { getShowGridStatus } from "@/lib/tracking/show-grid-status";
-import { showIdsKey } from "@/lib/tracking/show-state";
-
-import { ShowCardBookmarkButton } from "./show-card-bookmark-button";
-
/**
* The three personal slots of a TV poster card on `/shows` and `/search`
* (spec 0020, AC-6; prompts/catalog-card-status.md): a tracked show carries
@@ -20,14 +8,27 @@ import { ShowCardBookmarkButton } from "./show-card-bookmark-button";
* boundary, so the posters never wait on the session. A visitor and a failed
* read get the catalog card.
*/
+import { UpcomingShowPill } from "@/components/library/show-cards";
+import { CalculatedRatingBadge } from "@/components/rating-badges";
+import { requestTodayUtc } from "@/lib/tracking/episode-state";
+import { WATCHED_SHOW_LABELS } from "@/lib/tracking/messages";
+import { getShowGridStatus } from "@/lib/tracking/show-grid-status";
+import { showIdsKey } from "@/lib/tracking/show-state";
+
+import { CardRatingBadge } from "./card-rating-badge";
+import { ShowCardBookmarkButton } from "./show-card-bookmark-button";
+
type ShowCardProps = {
showId: number;
/** Every show id on the grid, the same array for each card. */
gridShowIds: readonly number[];
};
-/** The grid's read, or undefined for a visitor or a failed read. */
-async function readGrid(gridShowIds: readonly number[]) {
+/**
+ * The grid's tracked shows mapped to their status, or undefined for a
+ * visitor or a failed read.
+ */
+async function readTrackedStatuses(gridShowIds: readonly number[]) {
const result = await getShowGridStatus(showIdsKey(gridShowIds));
return result.kind === "ok" ? result.state : undefined;
}
@@ -44,14 +45,21 @@ async function ShowCardBadge({
gridShowIds,
tmdbRating,
}: ShowCardProps & { tmdbRating: number | null }) {
- const status = (await readGrid(gridShowIds))?.get(showId);
- if (status?.page === "watched" && status.rating !== null) {
- return (
-
- );
- }
- if (status?.page === "upcoming") return null;
- return ;
+ const status = (await readTrackedStatuses(gridShowIds))?.get(showId);
+ return (
+
+ ) : null
+ }
+ tmdbRating={tmdbRating}
+ />
+ );
}
/**
@@ -65,7 +73,7 @@ async function ShowCardMeta({
gridShowIds,
base,
}: ShowCardProps & { base: string | null }) {
- const status = (await readGrid(gridShowIds))?.get(showId);
+ const status = (await readTrackedStatuses(gridShowIds))?.get(showId);
if (status?.page !== "watched") return base;
const label = WATCHED_SHOW_LABELS[status.label];
return base === null ? label : `${base} · ${label}`;
@@ -85,27 +93,25 @@ async function ShowCardControls({
name,
returnPath,
}: ShowCardProps & { name: string; returnPath: string }) {
- const grid = await readGrid(gridShowIds);
+ const grid = await readTrackedStatuses(gridShowIds);
if (grid === undefined) return null;
const status = grid.get(showId);
- const bookmark = (
-
- );
- if (status?.page !== "upcoming") return bookmark;
return (
<>
-
+ )}
+
- {bookmark}
>
);
}
From b5d23414619f2f7617bda94caba18520ced6ce66 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:41:13 +0200
Subject: [PATCH 19/25] refactor(search): split a result card by media type
instead of branching per slot
Co-Authored-By: Claude Opus 5.5
---
app/movies/request-scope.test.ts | 2 +-
components/search/search-results.tsx | 171 ++++++++++++++++-----------
2 files changed, 104 insertions(+), 69 deletions(-)
diff --git a/app/movies/request-scope.test.ts b/app/movies/request-scope.test.ts
index 0dc512c..4d8d06c 100644
--- a/app/movies/request-scope.test.ts
+++ b/app/movies/request-scope.test.ts
@@ -155,7 +155,7 @@ describe("search reads no request scoped value (spec 0010, AC-22)", () => {
const source = readFileSync("components/search/search-results.tsx", "utf8");
expect(source).toMatch(/\s* \}\s*>\s*\{params\.type === "movie" \? \(\s* \}\s*>\s* result.id);
+ const ResultCard = params.type === "movie" ? MovieResultCard : ShowResultCard;
return (
- {results.map((result, index) => {
- const year = result.year === null ? null : String(result.year);
- return (
-
-
-
-
- )
- }
- badge={
- }
- >
- {params.type === "movie" ? (
-
- ) : (
-
- )}
-
- }
- controls={
- params.type === "movie" ? (
-
-
-
- ) : (
-
-
-
- )
- }
- priority={index < EAGER_POSTERS}
- />
-
- );
- })}
+ {results.map((result, index) => (
+
+
+
+ ))}
);
}
+type ResultCardProps = {
+ result: SearchResult;
+ /** Every id on the results page, the same array for each card. */
+ gridIds: readonly number[];
+ returnPath: string;
+ priority: boolean;
+};
+
+/** A movie result, carrying the slots `/movies` gives the same card. */
+function MovieResultCard({
+ result,
+ gridIds,
+ returnPath,
+ priority,
+}: ResultCardProps) {
+ return (
+ }>
+
+
+ }
+ controls={
+
+
+
+ }
+ priority={priority}
+ />
+ );
+}
+
+/**
+ * A TV result, carrying the slots `/shows` gives the same card, with the
+ * first air year ahead of a Watched show's label on the meta line.
+ */
+function ShowResultCard({
+ result,
+ gridIds,
+ returnPath,
+ priority,
+}: ResultCardProps) {
+ const year = result.year === null ? null : String(result.year);
+ return (
+
+
+
+ }
+ badge={
+ }>
+
+
+ }
+ controls={
+
+
+
+ }
+ priority={priority}
+ />
+ );
+}
+
/** The two empty answers of AC-15: a query alone, or any filter. */
function NoMatches({ params }: { params: SearchParams }) {
const noun = mediaNoun(params.type);
From 7458060b8e598705ca3ff8cab497dadd33f6db28 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:41:39 +0200
Subject: [PATCH 20/25] refactor(ui): name the skeleton row constants for what
they hold
Co-Authored-By: Claude Opus 5.5
---
components/catalog/detail-skeletons.tsx | 29 +++++++++++++------------
components/library/library-panels.tsx | 5 +++--
2 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/components/catalog/detail-skeletons.tsx b/components/catalog/detail-skeletons.tsx
index f24abff..bfc7380 100644
--- a/components/catalog/detail-skeletons.tsx
+++ b/components/catalog/detail-skeletons.tsx
@@ -76,13 +76,10 @@ function OverviewSkeleton() {
- {OVERVIEW_LINES.map((className, index) => (
+ {OVERVIEW_LINES.map(({ visibility, width }, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
-
@@ -90,15 +87,19 @@ function OverviewSkeleton() {
);
}
+/**
+ * The overview's lines: when each shows, and the shorter width that ends the
+ * paragraph at that breakpoint (the fourth line from `md`, the eighth below).
+ */
const OVERVIEW_LINES = [
- "flex",
- "flex",
- "flex",
- "flex",
- "flex md:hidden",
- "flex md:hidden",
- "flex md:hidden",
- "flex md:hidden",
+ { visibility: "flex" },
+ { visibility: "flex" },
+ { visibility: "flex" },
+ { visibility: "flex", width: "md:w-2/3" },
+ { visibility: "flex md:hidden" },
+ { visibility: "flex md:hidden" },
+ { visibility: "flex md:hidden" },
+ { visibility: "flex md:hidden", width: "w-2/3" },
];
/**
diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx
index 52b0561..8888aa6 100644
--- a/components/library/library-panels.tsx
+++ b/components/library/library-panels.tsx
@@ -241,7 +241,7 @@ function LoadFailed({
export function LibrarySkeleton() {
return (
- {ONE_ROW.map((visibility, index) => (
+ {ROW_CARD_VISIBILITY.map((visibility, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
@@ -251,4 +251,5 @@ export function LibrarySkeleton() {
);
}
-const ONE_ROW = ["", "", "hidden md:block", "hidden md:block"];
+/** Each card's visibility in that row: the last two show only from `md`. */
+const ROW_CARD_VISIBILITY = ["", "", "hidden md:block", "hidden md:block"];
From 200265bf97538d0f39324ed7b3ffdcd1735f9b86 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:41:44 +0200
Subject: [PATCH 21/25] docs(layout): rewrap the footer's JSDoc
Co-Authored-By: Claude Opus 5.5
---
components/layout/site-footer.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx
index 1c11fc8..d598e72 100644
--- a/components/layout/site-footer.tsx
+++ b/components/layout/site-footer.tsx
@@ -10,8 +10,9 @@ import { IconKey } from "./icon-key";
/**
* The quiet footer under every route: the icon key to the card marks
* (`IconKey`), then TMDB's logo and the notice its API terms require, then
- * the two legal pages and the credit line (spec 0017, AC-1 to AC-5). The credit names the operator by handle, linked to their GitHub
- * profile, the same way the legal pages do.
+ * the two legal pages and the credit line (spec 0017, AC-1 to AC-5). The
+ * credit names the operator by handle, linked to their GitHub profile, the
+ * same way the legal pages do.
*
* A Server Component that reads no cookie, header or session, so it sits in
* every route's prerendered shell (`app/layout-purity.test.ts` holds it to
From 4b74a156c9741c413131c9a718acb2824897086f Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:42:16 +0200
Subject: [PATCH 22/25] feat(showcase): render the footer icon key
Co-Authored-By: Claude Opus 5.5
---
app/showcase/page.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index 3d53014..f3a5be3 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type * as React from "react";
import { GlassPill } from "@/components/glass-pill";
+import { IconKey } from "@/components/layout/icon-key";
import { LibraryNavView } from "@/components/layout/library-nav";
import { MobileMenuSheet } from "@/components/layout/mobile-menu-sheet";
import {
@@ -237,6 +238,13 @@ export default function ShowcasePage() {
+
+
Glass, sm
From f039e4b435d42346ee1252a659a9b04f3f667ce8 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:42:49 +0200
Subject: [PATCH 23/25] test(tracking): move the movie card slot cases into
their own file
Co-Authored-By: Claude Opus 5.5
---
components/tracking/movie-card-slots.test.tsx | 132 ++++++++++++++++++
components/tracking/tracking-slots.test.tsx | 111 +--------------
2 files changed, 134 insertions(+), 109 deletions(-)
create mode 100644 components/tracking/movie-card-slots.test.tsx
diff --git a/components/tracking/movie-card-slots.test.tsx b/components/tracking/movie-card-slots.test.tsx
new file mode 100644
index 0000000..57317c3
--- /dev/null
+++ b/components/tracking/movie-card-slots.test.tsx
@@ -0,0 +1,132 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { MovieTrackingState } from "@/lib/tracking/types";
+
+/**
+ * covers: spec 0007, AC-2, AC-16, AC-17; prompts/catalog-card-status.md, R1
+ * to R4
+ *
+ * The two slots of a movie poster card show the movie the way its library
+ * page does. The grid read is the boundary, so it is what is replaced.
+ */
+const getMovieGridStates = vi.fn();
+vi.mock("@/lib/tracking/movie-state", () => ({
+ getMovieGridStates: (...args: unknown[]) => getMovieGridStates(...args),
+ movieIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
+}));
+vi.mock("@/lib/tracking/episode-state", () => ({
+ requestTodayUtc: () => "2026-10-10",
+}));
+vi.mock("@/app/movies/actions", () => ({}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
+vi.mock("sonner", () => ({ toast: vi.fn() }));
+
+const { MovieCardBadge, MovieCardControls } = await import(
+ "./movie-card-slots"
+);
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("MovieCardBadge and MovieCardControls", () => {
+ const props = {
+ movieId: 550,
+ title: "Fight Club",
+ releaseDate: "1999-10-15" as string | null,
+ gridMovieIds: [603, 550],
+ returnPath: "/movies",
+ };
+ const none: MovieTrackingState = {
+ inWatchlist: false,
+ watched: false,
+ rating: null,
+ };
+
+ function grid(state?: typeof none) {
+ getMovieGridStates.mockResolvedValue({
+ kind: "ok",
+ state: new Map(state ? [[550, state]] : []),
+ });
+ }
+
+ async function renderCard(overrides: Partial = {}) {
+ const card = { ...props, ...overrides };
+ return render(
+ <>
+ {await MovieCardBadge({ ...card, tmdbRating: 8.4 })}
+ {await MovieCardControls(card)}
+ >,
+ );
+ }
+
+ it("asks for the whole grid under one shared key (AC-16)", async () => {
+ grid({ ...none, inWatchlist: true });
+ await renderCard();
+ expect(getMovieGridStates).toHaveBeenCalledWith("550,603");
+ expect(
+ screen.getByRole("button", { name: "Plan Fight Club" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ });
+
+ it("shows an untouched movie as the catalog does", async () => {
+ grid();
+ await renderCard();
+ expect(
+ screen.getByRole("button", { name: "Plan Fight Club" }),
+ ).toHaveAttribute("aria-pressed", "false");
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ });
+
+ it("shows a scored watched movie as Watched does: the score and the watched mark", async () => {
+ grid({ ...none, watched: true, rating: 7 });
+ await renderCard();
+ expect(screen.getByText("Your score")).toBeInTheDocument();
+ expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Unmark Fight Club as watched" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Plan Fight Club" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps the TMDB rating on an unscored watched movie", async () => {
+ grid({ ...none, watched: true });
+ await renderCard();
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Unmark Fight Club as watched" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a planned unreleased movie as Upcoming does: the date and no rating", async () => {
+ grid({ ...none, inWatchlist: true });
+ await renderCard({ releaseDate: "2026-11-20" });
+ expect(screen.getByText("Releases Nov 20, 2026")).toBeInTheDocument();
+ expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Plan Fight Club" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("shows Date TBA for a planned undated movie", async () => {
+ grid({ ...none, inWatchlist: true });
+ await renderCard({ releaseDate: null });
+ expect(
+ screen.getByText("Release date to be announced"),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ ["a visitor", { kind: "signed_out" }],
+ ["a failed read", { kind: "failed" }],
+ ])("gives %s the catalog card (AC-2, AC-17)", async (_, read) => {
+ getMovieGridStates.mockResolvedValue(read);
+ await renderCard();
+ expect(screen.getByText("TMDB rating")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+});
diff --git a/components/tracking/tracking-slots.test.tsx b/components/tracking/tracking-slots.test.tsx
index f41ea4e..311fc4b 100644
--- a/components/tracking/tracking-slots.test.tsx
+++ b/components/tracking/tracking-slots.test.tsx
@@ -2,17 +2,14 @@ import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
- * covers: spec 0007, AC-2, AC-16, AC-17; prompts/catalog-card-status.md
+ * covers: spec 0007, AC-2, AC-17
*
- * The two server slots decide between nothing, a failure line and the
+ * The server slot decides between nothing, a failure line and the
* controls. The reads are the boundary, so they are what is replaced.
*/
const getMovieTracking = vi.fn();
-const getMovieGridStates = vi.fn();
vi.mock("@/lib/tracking/movie-state", () => ({
getMovieTracking: (...args: unknown[]) => getMovieTracking(...args),
- getMovieGridStates: (...args: unknown[]) => getMovieGridStates(...args),
- movieIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
}));
vi.mock("@/lib/tracking/episode-state", () => ({
requestTodayUtc: () => "2026-10-10",
@@ -22,9 +19,6 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
const { MovieTrackingSlot } = await import("./movie-tracking-slot");
-const { MovieCardBadge, MovieCardControls } = await import(
- "./movie-card-slots"
-);
afterEach(() => {
vi.clearAllMocks();
@@ -101,104 +95,3 @@ describe("MovieTrackingSlot", () => {
).toBeInTheDocument();
});
});
-
-describe("MovieCardBadge and MovieCardControls", () => {
- const props = {
- movieId: 550,
- title: "Fight Club",
- releaseDate: "1999-10-15" as string | null,
- gridMovieIds: [603, 550],
- returnPath: "/movies",
- };
- const none: {
- inWatchlist: boolean;
- watched: boolean;
- rating: number | null;
- } = { inWatchlist: false, watched: false, rating: null };
-
- function grid(state?: typeof none) {
- getMovieGridStates.mockResolvedValue({
- kind: "ok",
- state: new Map(state ? [[550, state]] : []),
- });
- }
-
- async function renderCard(overrides: Partial = {}) {
- const card = { ...props, ...overrides };
- return render(
- <>
- {await MovieCardBadge({ ...card, tmdbRating: 8.4 })}
- {await MovieCardControls(card)}
- >,
- );
- }
-
- it("asks for the whole grid under one shared key (AC-16)", async () => {
- grid({ ...none, inWatchlist: true });
- await renderCard();
- expect(getMovieGridStates).toHaveBeenCalledWith("550,603");
- expect(
- screen.getByRole("button", { name: "Plan Fight Club" }),
- ).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByText("TMDB rating")).toBeInTheDocument();
- });
-
- it("shows an untouched movie as the catalog does", async () => {
- grid();
- await renderCard();
- expect(
- screen.getByRole("button", { name: "Plan Fight Club" }),
- ).toHaveAttribute("aria-pressed", "false");
- expect(screen.getByText("TMDB rating")).toBeInTheDocument();
- });
-
- it("shows a scored watched movie as Watched does: the score and the watched mark", async () => {
- grid({ ...none, watched: true, rating: 7 });
- await renderCard();
- expect(screen.getByText("Your score")).toBeInTheDocument();
- expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Unmark Fight Club as watched" }),
- ).toBeInTheDocument();
- expect(
- screen.queryByRole("button", { name: "Plan Fight Club" }),
- ).not.toBeInTheDocument();
- });
-
- it("keeps the TMDB rating on an unscored watched movie", async () => {
- grid({ ...none, watched: true });
- await renderCard();
- expect(screen.getByText("TMDB rating")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Unmark Fight Club as watched" }),
- ).toBeInTheDocument();
- });
-
- it("shows a planned unreleased movie as Upcoming does: the date and no rating", async () => {
- grid({ ...none, inWatchlist: true });
- await renderCard({ releaseDate: "2026-11-20" });
- expect(screen.getByText("Releases Nov 20, 2026")).toBeInTheDocument();
- expect(screen.queryByText("TMDB rating")).not.toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Plan Fight Club" }),
- ).toHaveAttribute("aria-pressed", "true");
- });
-
- it("shows Date TBA for a planned undated movie", async () => {
- grid({ ...none, inWatchlist: true });
- await renderCard({ releaseDate: null });
- expect(
- screen.getByText("Release date to be announced"),
- ).toBeInTheDocument();
- });
-
- it.each([
- ["a visitor", { kind: "signed_out" }],
- ["a failed read", { kind: "failed" }],
- ])("gives %s the catalog card (AC-2, AC-17)", async (_, read) => {
- getMovieGridStates.mockResolvedValue(read);
- await renderCard();
- expect(screen.getByText("TMDB rating")).toBeInTheDocument();
- expect(screen.queryByRole("button")).not.toBeInTheDocument();
- });
-});
From 9dcfec25e75a1d78b902425f1ec29d4e329f2b27 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:43:06 +0200
Subject: [PATCH 24/25] docs(prompts): record where the shipped grid and card
status differ from the plans
Co-Authored-By: Claude Opus 5.5
---
prompts/catalog-card-status.md | 13 +++++++++++++
prompts/grid-tabs-footer-legend.md | 5 +++++
2 files changed, 18 insertions(+)
diff --git a/prompts/catalog-card-status.md b/prompts/catalog-card-status.md
index 3a70822..a2e5e17 100644
--- a/prompts/catalog-card-status.md
+++ b/prompts/catalog-card-status.md
@@ -140,3 +140,16 @@ One request-scoped read per grid and media type, shared by every card through Re
6. Stop tracking the Watched show from its card. The card returns to the TMDB rating with an empty bookmark.
7. Sign out. All grids look as they did before this change.
8. Narrow the window to phone width. The pills and badges don't overlap, and the tap targets stay 44px.
+
+## Amendments (2026-10-11, after code review)
+
+The shipped code differs from the design above in these places. Behaviour and requirements R1 to R8 are unchanged.
+
+- `movieCardStatus` lives in `lib/catalog/movie-page.ts` beside `classifyMovie`, not in `lib/tracking/movie-state.ts`, because it is pure and that file is server-only. It takes a `MovieTrackingState`.
+- `classifyMovie` takes a `watched` flag instead of `watchedAt` (commit 0a27029), so `getMovieGridStates` does not need the timestamp.
+- `ReleasePill` moved to `components/library/dated-pill.tsx` beside `DatedPill` and takes the formatted `release` instead of the whole card. The formatting is `formatReleaseDate` in `lib/format.ts`, shared by the library Upcoming card and the catalog cards.
+- The badge rule (your rating on Watched, nothing on Upcoming, the TMDB rating otherwise) is one component, `components/tracking/card-rating-badge.tsx`, used by both the movie and the show slots.
+- `/search` renders a `MovieResultCard` or a `ShowResultCard` per result instead of branching on the media type inside each slot, and its skeleton cards take `PosterCardSkeleton meta`, so the year line under each name is reserved and the cards do not grow when the results arrive.
+- The movie card slot tests are in `components/tracking/movie-card-slots.test.tsx`, as listed.
+- Decision 9 is kept as written: when the ratings read fails, a show on Watched stays a plain tracked card and loses its Finished or Caught up label too. A label with the TMDB rating would look like an unrated show, which is the wrong state the decision rules out.
+- A systemic TMDB failure while placing a grid's shows logs `show_tracking.grid_read`, not the tracked set's `show_tracking.read`.
diff --git a/prompts/grid-tabs-footer-legend.md b/prompts/grid-tabs-footer-legend.md
index 9ed4fd4..7fada5c 100644
--- a/prompts/grid-tabs-footer-legend.md
+++ b/prompts/grid-tabs-footer-legend.md
@@ -88,3 +88,8 @@ UI only: no new data, input, route or credential. The footer key is static, so i
5. Check the navbar on `/shows`, `/movies?page=2`, `/watchlist?type=movie`: the lit tab is cyan glass with dark text; keyboard Tab shows the focus ring on it.
6. Scroll to the footer on `/shows`, `/sign-in`, `/privacy` and `/nope`: the icon key is the footer's centred first row with all eight entries; at 390px it wraps centred.
7. Open `/showcase`: the tab and key examples match the shipped UI.
+
+## Amendments (2026-10-11, after code review)
+
+- Decision 4: `POSTER_SIZES` shipped as `(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw`. The 1200px cap is the grid's content width, so the cap is reached at a 1232px viewport (two 16px gutters), and each column is 288px, not 300px, once the three 16px gaps are taken out.
+- Decision 4, `LibrarySkeleton`: "one row" means one row at the current column count, so 2 cards below `md` and 4 from it. Four cards on a two-column phone grid would be two rows and would shrink when a one-row library arrives.
From 315ffedcda941b3ee20819f048feeab97936c085 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Sun, 11 Oct 2026 10:43:21 +0200
Subject: [PATCH 25/25] docs(agents): note that the catalog grids place their
titles too
Co-Authored-By: Claude Opus 5.5
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 00cb143..fe01a1a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -296,7 +296,7 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke
- `pnpm-workspace.yaml` exists only to pin `allowBuilds`. This is a single package repo, not a monorepo.
- `cacheComponents: true` is on in `next.config.ts`. Every route must be prerenderable or opt out with `export const instant = false`, and every cached read calls `cacheLife` inside its own `use cache` scope. A rejection thrown inside a cached scope loses its class and its fields, so return a plain result and rebuild the error outside the scope.
- The TMDB token is server only. Only `lib/tmdb/env.ts` may read `TMDB_READ_ACCESS_TOKEN`, and `security-boundary.test.ts` fails if any other file names it or gives it a `NEXT_PUBLIC_` prefix, the same rule the Supabase service role key carries.
-- No page writes when it loads (spec 0020, AC-21): the library pages and `/shows/{id}` only read. A library tab classifies every tracked show (or every planned unwatched movie) per request, at most 500 per media type, from one cached TMDB details read per title (`lib/tracking/library-lists.ts`, the pure `lib/tv/library-page.ts` and `lib/catalog/movie-page.ts`). Keep it that way unless a spec says otherwise.
+- No page writes when it loads (spec 0020, AC-21): the library pages and `/shows/{id}` only read. A library tab classifies every tracked show (or every planned unwatched movie) per request, at most 500 per media type, from one cached TMDB details read per title (`lib/tracking/library-lists.ts`, the pure `lib/tv/library-page.ts` and `lib/catalog/movie-page.ts`). The catalog grids on `/shows`, `/movies` and `/search` place their own titles with the same classifiers, so a card always matches its library page: one `cache()`d read per grid, and TMDB details only for the grid's tracked shows (`lib/tracking/show-grid-status.ts`, `getMovieGridStates`; prompts/catalog-card-status.md). Keep it that way unless a spec says otherwise.
- `user_show_state` is `user_id`, `show_id`, `tracked_at` and the timestamps, nothing more: the spec 0020 contract migration (`20261010120000_show_tracking_contract.sql`) dropped the legacy status columns, the `tv_status`, `status_source` and `show_hold` enums, the old views and every status and hold function. `user_tracked_shows` is the one view. A show page control is a Plan to watch / Tracking toggle.
- The navbar's SHOWS | MOVIES tabs filter every page (scope feature 22, [prompts/media-tabs-everywhere.md](prompts/media-tabs-everywhere.md)). `/watchlist`, `/upcoming`, `/watched` and `/search` take their media type from `?type=tv|movie`. A bare URL means `tv`, and an invalid value shows a "That page doesn't exist" panel, never a redirect. Each tab has its own reads, count and pages. The parsing and URL building live only in `lib/catalog/media-type.ts`, so use `typedHref` for any link to these pages and always write `type`. The client shell reads `useSearchParams` only on those four paths, inside its own Suspense boundary; reading it anywhere else takes `/shows`, `/movies` and the title pages out of their prerendered shells.
- TMDB images render through `next/image`; `image.tmdb.org` is the one allowed remote pattern.