))}
@@ -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 03fb181..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,18 +19,18 @@ function ShowDetailSkeleton() {
-
+
- {Array.from({ length: 6 }, (_, index) => (
+ {SEASON_CARD_VISIBILITY.map((visibility, index) => (
-
+
))}
@@ -41,22 +42,15 @@ 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() {
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 };
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/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/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 };
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/movie-card-slots.tsx b/components/tracking/movie-card-slots.tsx
new file mode 100644
index 0000000..98733bc
--- /dev/null
+++ b/components/tracking/movie-card-slots.tsx
@@ -0,0 +1,116 @@
+/**
+ * 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.
+ */
+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. */
+ 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 status = (await readStatus(props))?.status;
+ return (
+
+ ) : null
+ }
+ tmdbRating={tmdbRating}
+ />
+ );
+}
+
+/**
+ * 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 (
+
+ );
+ }
+
+ return (
+ <>
+ {status?.page === "upcoming" && (
+
+ )}
+
+ >
+ );
+}
+
+export { MovieCardBadge, MovieCardControls };
diff --git a/components/tracking/show-card-bookmark.tsx b/components/tracking/show-card-bookmark.tsx
deleted file mode 100644
index 3ea2151..0000000
--- a/components/tracking/show-card-bookmark.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { getTrackedShows, showIdsKey } from "@/lib/tracking/show-state";
-
-import { ShowCardBookmarkButton } from "./show-card-bookmark-button";
-
-/**
- * The TV bookmark on a `/shows` card or a `/search` TV result, for a signed in
- * user (spec 0020, AC-6), the `CardBookmark` pattern.
- *
- * Every card on the grid passes the same ids, so `getTrackedShows` runs once
- * per render for the whole grid. The bookmark is filled for a tracked show
- * and empty otherwise. A visitor and a failed read render
- * nothing, and each card is its own Suspense boundary, so the posters never
- * wait on the session.
- *
- * @param showId The card's show.
- * @param name The TMDB name, for the accessible name and the toasts.
- * @param gridShowIds Every show id on the grid, the same array for each card.
- * @param returnPath The grid page, for the session expired toast.
- */
-async function ShowCardBookmark({
- showId,
- name,
- gridShowIds,
- returnPath,
-}: {
- showId: number;
- name: string;
- gridShowIds: readonly number[];
- returnPath: string;
-}) {
- const result = await getTrackedShows(showIdsKey(gridShowIds));
- if (result.kind !== "ok") return null;
-
- return (
-
- );
-}
-
-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..d53f339
--- /dev/null
+++ b/components/tracking/show-card-slots.tsx
@@ -0,0 +1,119 @@
+/**
+ * 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.
+ */
+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 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;
+}
+
+/**
+ * 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 readTrackedStatuses(gridShowIds))?.get(showId);
+ return (
+
+ ) : null
+ }
+ tmdbRating={tmdbRating}
+ />
+ );
+}
+
+/**
+ * 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 readTrackedStatuses(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 readTrackedStatuses(gridShowIds);
+ if (grid === undefined) return null;
+ const status = grid.get(showId);
+
+ return (
+ <>
+ {status?.page === "upcoming" && (
+
+ )}
+
+ >
+ );
+}
+
+export { ShowCardBadge, ShowCardControls, ShowCardMeta };
diff --git a/components/tracking/tracking-slots.test.tsx b/components/tracking/tracking-slots.test.tsx
index 63b58f3..311fc4b 100644
--- a/components/tracking/tracking-slots.test.tsx
+++ b/components/tracking/tracking-slots.test.tsx
@@ -2,18 +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
+ * 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 getWatchlistedMovieIds = vi.fn();
vi.mock("@/lib/tracking/movie-state", () => ({
getMovieTracking: (...args: unknown[]) => getMovieTracking(...args),
- getWatchlistedMovieIds: (...args: unknown[]) =>
- getWatchlistedMovieIds(...args),
- movieIdsKey: (ids: number[]) => [...ids].sort((a, b) => a - b).join(","),
}));
vi.mock("@/lib/tracking/episode-state", () => ({
requestTodayUtc: () => "2026-10-10",
@@ -23,7 +19,6 @@ 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");
afterEach(() => {
vi.clearAllMocks();
@@ -100,33 +95,3 @@ describe("MovieTrackingSlot", () => {
).toBeInTheDocument();
});
});
-
-describe("CardBookmark", () => {
- const props = {
- movieId: 550,
- title: "Fight Club",
- gridMovieIds: [603, 550],
- returnPath: "/movies",
- };
-
- it("asks for the whole grid under one shared key (AC-16)", async () => {
- getWatchlistedMovieIds.mockResolvedValue({
- kind: "ok",
- state: new Set([550]),
- });
- render(await CardBookmark(props));
- expect(getWatchlistedMovieIds).toHaveBeenCalledWith("550,603");
- expect(
- screen.getByRole("button", { name: "Plan Fight Club" }),
- ).toHaveAttribute("aria-pressed", "true");
- });
-
- 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();
- });
-});
diff --git a/lib/catalog/movie-page.test.ts b/lib/catalog/movie-page.test.ts
index fcb1b32..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";
@@ -14,13 +15,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 +30,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 +39,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 +69,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 +104,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 +119,46 @@ describe("movieReleaseNote (release gate)", () => {
expect(movieReleaseNote("2026-13-40")).toBe("Release date to be announced");
});
});
+
+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 04c0185..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). */
@@ -10,7 +11,7 @@ export type MoviePage = LibraryList;
export type MoviePlacement = {
releaseDate: string | null;
inWatchlist: boolean;
- watchedAt: string | null;
+ watched: boolean;
};
/**
@@ -30,7 +31,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 +86,40 @@ export function upcomingReleaseDate(
): string | null {
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: MovieTrackingState | 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/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.
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,
diff --git a/lib/tracking/library-lists.ts b/lib/tracking/library-lists.ts
index 3c6276b..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[],
@@ -484,7 +485,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;
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/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.
diff --git a/lib/tracking/show-grid-status.test.ts b/lib/tracking/show-grid-status.test.ts
new file mode 100644
index 0000000..f7c2a25
--- /dev/null
+++ b/lib/tracking/show-grid-status.test.ts
@@ -0,0 +1,150 @@
+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],
+ ]),
+ });
+ expect(warn).toHaveBeenCalledWith(
+ "show_tracking.grid_read refused tmdb_unavailable",
+ );
+ });
+
+ 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..7a33f9a
--- /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