diff --git a/app/catalog-lists.test.ts b/app/catalog-lists.test.ts new file mode 100644 index 0000000..c7e2658 --- /dev/null +++ b/app/catalog-lists.test.ts @@ -0,0 +1,36 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { MOVIE_LIST_SLUGS, TV_LIST_SLUGS } from "@/lib/catalog/lists"; + +/** + * covers: spec 0021, the registry invariant + * + * A slug is defined once, in `lib/catalog/lists.ts`, and each list has its + * own static route folder. This keeps the two in step: a folder with no + * registry entry, an entry with no folder, or a route rendering another + * list's entry fails here rather than as a 404 or a wrong page. + */ +function staticRouteFolders(dir: string): string[] { + return readdirSync(dir) + .filter((name) => !name.startsWith("[") && !name.startsWith("(")) + .filter((name) => statSync(join(dir, name)).isDirectory()) + .sort(); +} + +describe.each([ + ["app/shows", "tv", TV_LIST_SLUGS], + ["app/movies", "movie", MOVIE_LIST_SLUGS], +] as const)("%s", (dir, mediaType, slugs) => { + it("has one static folder per registry slug, and no other", () => { + expect(staticRouteFolders(dir)).toEqual([...slugs].sort()); + }); + + it.each(slugs)("renders the %s entry from its own folder", (slug) => { + const source = readFileSync(join(dir, slug, "page.tsx"), "utf8"); + expect(source).toContain(`catalogList("${mediaType}", "${slug}")`); + expect(source).toContain("): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Movies now playing, paged (spec 0021, AC-9). */ +export default function MoviesNowPlayingPage({ + searchParams, +}: PageProps<"/movies/now-playing">) { + return ; +} diff --git a/app/movies/page.test.ts b/app/movies/page.test.ts index 4809baa..11e7d11 100644 --- a/app/movies/page.test.ts +++ b/app/movies/page.test.ts @@ -1,117 +1,44 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; /** - * covers: spec 0016, AC-17 + * covers: spec 0021, AC-15 * - * The movie landing indexes only a page that shows results: page N declares - * its own canonical, and an invalid page, a page past the last one or a failed - * TMDB read is `noindex`. The discover read is replaced; `TmdbError` is the - * real one, so the catch branch is exercised as is. `/shows` is built the same - * way from `discoverTvShows`. + * The landings are one page each now, so their metadata is static: the + * catalog title, the AC-15 description, and the bare path as canonical. The + * module is imported after the site URL is stubbed, because the metadata is + * built when the module loads. */ -const discoverMovies = vi.fn(); - -vi.mock("@/lib/tmdb", async () => { - const errors = await import("@/lib/tmdb/errors"); - return { - discoverMovies: (input: { page: number }) => discoverMovies(input), - TmdbError: errors.TmdbError, - }; -}); - -const { TmdbError } = await import("@/lib/tmdb/errors"); -const { generateMetadata } = await import("./page"); - const ORIGIN = "https://bestats.example"; -const DESCRIPTION = - "Browse popular movies on BeStats. See the cast, ratings and details, and keep track of what you watch."; - -function metadataFor(page?: string | string[]) { - return generateMetadata({ - searchParams: Promise.resolve(page === undefined ? {} : { page }), - } as Parameters[0]); -} - -beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN)); afterEach(() => { - discoverMovies.mockReset(); vi.unstubAllEnvs(); + vi.resetModules(); }); -describe("the movie landing's metadata (AC-17)", () => { - it("declares /movies as the canonical of page 1, with the site card", async () => { - discoverMovies.mockResolvedValue({ totalPages: 50 }); +async function landingMetadataOf(path: "./page" | "../shows/page") { + vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN); + return (await import(path)).metadata; +} - const metadata = await metadataFor(); +describe("the landings' metadata (AC-15)", () => { + it("gives /movies its title, description and bare canonical", async () => { + const metadata = await landingMetadataOf("./page"); expect(metadata.title).toBe("Movies"); - expect(metadata.description).toBe(DESCRIPTION); + expect(metadata.description).toBe( + "Browse trending, popular, now playing, upcoming and top rated movies on BeStats, and keep track of what you watch.", + ); expect(metadata.alternates).toEqual({ canonical: `${ORIGIN}/movies` }); - expect(metadata.openGraph).toMatchObject({ - type: "website", - images: [`${ORIGIN}/opengraph-image`], - }); - expect(metadata.robots).toBeUndefined(); - }); - - it("gives page N its own canonical", async () => { - discoverMovies.mockResolvedValue({ totalPages: 50 }); - - const metadata = await metadataFor("2"); - - expect(discoverMovies).toHaveBeenCalledWith({ page: 2 }); - expect(metadata.alternates).toEqual({ - canonical: `${ORIGIN}/movies?page=2`, - }); - }); - - it("indexes the last page itself", async () => { - discoverMovies.mockResolvedValue({ totalPages: 7 }); - - const metadata = await metadataFor("7"); - expect(metadata.robots).toBeUndefined(); - expect(metadata.alternates?.canonical).toBe(`${ORIGIN}/movies?page=7`); - }); - - it("is noindex with no canonical for a page past the last one", async () => { - discoverMovies.mockResolvedValue({ totalPages: 7 }); - - const metadata = await metadataFor("8"); - - expect(metadata).toEqual({ - title: "Movies", - description: DESCRIPTION, - robots: { index: false }, - }); }); - it.each(["abc", "0", "", ["1", "2"]])( - "is noindex without asking TMDB for the page value %j", - async (page) => { - const metadata = await metadataFor(page); - - expect(metadata.robots).toEqual({ index: false }); - expect(metadata.alternates).toBeUndefined(); - expect(discoverMovies).not.toHaveBeenCalled(); - }, - ); + it("gives /shows its title, description and bare canonical", async () => { + const metadata = await landingMetadataOf("../shows/page"); - it("is noindex when the TMDB read fails", async () => { - discoverMovies.mockRejectedValue( - new TmdbError("unauthorized", "/discover/movie", "bad token", 401), + expect(metadata.title).toBe("Shows"); + expect(metadata.description).toBe( + "Browse trending, popular, airing and top rated TV shows on BeStats, and keep track of what you watch.", ); - - const metadata = await metadataFor("7"); - - expect(metadata.robots).toEqual({ index: false }); - expect(metadata.alternates).toBeUndefined(); - }); - - it("rethrows a rejection that is not a TMDB failure", async () => { - discoverMovies.mockRejectedValue(new TypeError("a bug")); - - await expect(metadataFor()).rejects.toThrow("a bug"); + expect(metadata.alternates).toEqual({ canonical: `${ORIGIN}/shows` }); }); }); diff --git a/app/movies/page.tsx b/app/movies/page.tsx index b57fb4a..ed97ab1 100644 --- a/app/movies/page.tsx +++ b/app/movies/page.tsx @@ -1,192 +1,27 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; -import { PaginationLinks } from "@/components/pagination-links"; -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 { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; -import { StatePanel } from "@/components/state-panel"; -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"; -import { discoverMovies, TmdbError } from "@/lib/tmdb"; - -/** TMDB's discover page size, which the skeleton mirrors. */ -const PAGE_SIZE = 20; - -/** - * 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 = 4; - -/** One URL per page: page 1 is the bare path. */ -function pageHref(page: number): string { - return page === 1 ? "/movies" : `/movies?page=${page}`; -} - -/** Every page of the landing shares one description (spec 0016, AC-17). */ -const DESCRIPTION = - "Browse popular movies on BeStats. See the cast, ratings and details, and keep track of what you watch."; +import { CatalogLanding } from "@/components/catalog/catalog-landing"; +import { catalogListsFor } from "@/lib/catalog/lists"; +import { landingMetadata } from "@/lib/seo/metadata"; /** - * The landing's metadata (spec 0016, AC-17). Page N declares its own - * canonical through `pageHref`, the same address the pagination links use. A - * page that shows no results is `noindex`. `landingPageMetadata` decides it - * from the same cached `discoverMovies` call the body makes, so metadata costs no - * extra TMDB request. + * The landing's metadata (spec 0021, AC-15): one page now, so one canonical, + * the bare path. A `?page=N` request never reaches it; `next.config.ts` + * redirects it to `/movies/popular` (AC-14). */ -export async function generateMetadata({ - searchParams, -}: PageProps<"/movies">): Promise { - return landingPageMetadata({ - pageParam: (await searchParams).page, - discover: discoverMovies, - title: "Movies", - description: DESCRIPTION, - pageHref, - }); -} +export const metadata: Metadata = landingMetadata({ + title: "Movies", + description: + "Browse trending, popular, now playing, upcoming and top rated movies on BeStats, and keep track of what you watch.", + path: "/movies", +}); /** - * The movie landing: popular movies from TMDB, paged (spec 0006, AC-1, AC-2). - * - * 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 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). + * The movie landing: one carousel row per movie list, in registry order + * (spec 0021, AC-1). No session is read here (spec 0006, AC-13); each card's + * badge and controls read it inside their own Suspense boundaries + * (`components/catalog/list-cards.tsx`). */ -export default function MoviesPage({ searchParams }: PageProps<"/movies">) { - return ( -
-
-

- Popular movies -

-

- Ordered by current popularity on TMDB. -

-
- - }> - - -
- ); -} - -async function PopularMovies({ - searchParams, -}: { - searchParams: PageProps<"/movies">["searchParams"]; -}) { - const page = parsePageParam((await searchParams).page); - if (page === null) return ; - - let result: Awaited>; - try { - result = await discoverMovies({ page }); - } catch (error) { - if (!(error instanceof TmdbError)) throw error; - return ( -
- } - /> -
- ); - } - - const lastPage = lastReachablePage(result.totalPages); - if (page > lastPage) return ; - - const gridMovieIds = result.results.map((movie) => movie.id); - - return ( -
- - {result.results.map((movie, index) => ( -
  • - } - > - - - } - controls={ - - - - } - priority={index < EAGER_POSTERS} - /> -
  • - ))} -
    - - -
    - ); -} - -/** Shown for a malformed page number, and for one past the last page. */ -function NoSuchPage() { - return ( -
    - - Back to page 1 - - } - /> -
    - ); -} - -function PopularMoviesSkeleton() { - return ( -
    - - -
    - ); +export default function MoviesPage() { + return ; } diff --git a/app/movies/popular/page.test.ts b/app/movies/popular/page.test.ts new file mode 100644 index 0000000..639f872 --- /dev/null +++ b/app/movies/popular/page.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0016, AC-17, as amended by spec 0021, AC-15 + * + * The paged popular grid moved from `/movies` to `/movies/popular`, and its + * metadata with it: page N declares its own canonical, and an invalid page, a + * page past the last one or a failed TMDB read is `noindex`. The discover read + * is replaced; `TmdbError` is the real one, so the catch branch is exercised + * as is. Every list page is built the same way through `catalogListMetadata`. + */ +const discoverMovies = vi.fn(); + +vi.mock("@/lib/tmdb", async () => { + const errors = await import("@/lib/tmdb/errors"); + return { + discoverMovies: (input: { page: number }) => discoverMovies(input), + TmdbError: errors.TmdbError, + }; +}); + +const { TmdbError } = await import("@/lib/tmdb/errors"); +const { generateMetadata } = await import("./page"); + +const ORIGIN = "https://bestats.example"; +const TITLE = "Popular movies"; +const DESCRIPTION = "Ordered by current popularity on TMDB."; + +function metadataFor(page?: string | string[]) { + return generateMetadata({ + searchParams: Promise.resolve(page === undefined ? {} : { page }), + } as Parameters[0]); +} + +beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN)); + +afterEach(() => { + discoverMovies.mockReset(); + vi.unstubAllEnvs(); +}); + +describe("the popular movies list's metadata (spec 0021, AC-15)", () => { + it("declares /movies/popular as the canonical of page 1, with the site card", async () => { + discoverMovies.mockResolvedValue({ totalPages: 50 }); + + const metadata = await metadataFor(); + + expect(metadata.title).toBe(TITLE); + expect(metadata.description).toBe(DESCRIPTION); + expect(metadata.alternates).toEqual({ + canonical: `${ORIGIN}/movies/popular`, + }); + expect(metadata.openGraph).toMatchObject({ + type: "website", + images: [`${ORIGIN}/opengraph-image`], + }); + expect(metadata.robots).toBeUndefined(); + }); + + it("gives page N its own canonical", async () => { + discoverMovies.mockResolvedValue({ totalPages: 50 }); + + const metadata = await metadataFor("2"); + + expect(discoverMovies).toHaveBeenCalledWith({ page: 2 }); + expect(metadata.alternates).toEqual({ + canonical: `${ORIGIN}/movies/popular?page=2`, + }); + }); + + it("indexes the last page itself", async () => { + discoverMovies.mockResolvedValue({ totalPages: 7 }); + + const metadata = await metadataFor("7"); + + expect(metadata.robots).toBeUndefined(); + expect(metadata.alternates?.canonical).toBe( + `${ORIGIN}/movies/popular?page=7`, + ); + }); + + it("is noindex with no canonical for a page past the last one", async () => { + discoverMovies.mockResolvedValue({ totalPages: 7 }); + + const metadata = await metadataFor("8"); + + expect(metadata).toEqual({ + title: TITLE, + description: DESCRIPTION, + robots: { index: false }, + }); + }); + + it.each(["abc", "0", "", ["1", "2"]])( + "is noindex without asking TMDB for the page value %j", + async (page) => { + const metadata = await metadataFor(page); + + expect(metadata.robots).toEqual({ index: false }); + expect(metadata.alternates).toBeUndefined(); + expect(discoverMovies).not.toHaveBeenCalled(); + }, + ); + + it("is noindex when the TMDB read fails", async () => { + discoverMovies.mockRejectedValue( + new TmdbError("unauthorized", "/discover/movie", "bad token", 401), + ); + + const metadata = await metadataFor("7"); + + expect(metadata.robots).toEqual({ index: false }); + expect(metadata.alternates).toBeUndefined(); + }); + + it("rethrows a rejection that is not a TMDB failure", async () => { + discoverMovies.mockRejectedValue(new TypeError("a bug")); + + await expect(metadataFor()).rejects.toThrow("a bug"); + }); +}); diff --git a/app/movies/popular/page.tsx b/app/movies/popular/page.tsx new file mode 100644 index 0000000..b1164ae --- /dev/null +++ b/app/movies/popular/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("movie", "popular"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/movies/popular">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Popular movies, paged (spec 0021, AC-9). */ +export default function PopularMoviesPage({ + searchParams, +}: PageProps<"/movies/popular">) { + return ; +} diff --git a/app/movies/request-scope.test.ts b/app/movies/request-scope.test.ts index 4d8d06c..2946880 100644 --- a/app/movies/request-scope.test.ts +++ b/app/movies/request-scope.test.ts @@ -2,6 +2,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; +import { MOVIE_LIST_SLUGS, TV_LIST_SLUGS } from "@/lib/catalog/lists"; + /** * covers: spec 0006, AC-13, as amended by spec 0007, AC-19 * @@ -49,6 +51,10 @@ describe("the movie routes read no request scoped value directly (AC-13)", () => it("finds the route files", () => { expect(files).toContain("app/movies/page.tsx"); expect(files).toContain(join("app/movies/[id]/page.tsx")); + // spec 0021, AC-17: every list page is scanned too. + for (const slug of MOVIE_LIST_SLUGS) { + expect(files).toContain(join(`app/movies/${slug}/page.tsx`)); + } }); it.each(files)("%s reaches for none of them", (path) => { @@ -58,8 +64,12 @@ describe("the movie routes read no request scoped value directly (AC-13)", () => } }); - it("does not opt either route out of the static shell (AC-11)", () => { - for (const path of ["app/movies/page.tsx", "app/movies/[id]/page.tsx"]) { + it("does not opt any route out of the static shell (AC-11; spec 0021, AC-7)", () => { + for (const path of [ + "app/movies/page.tsx", + "app/movies/[id]/page.tsx", + ...MOVIE_LIST_SLUGS.map((slug) => `app/movies/${slug}/page.tsx`), + ]) { expect(readFileSync(path, "utf8")).not.toMatch(/instant\s*=\s*false/); } }); @@ -87,6 +97,12 @@ describe("the show routes read no request scoped value (spec 0009, AC-18; spec 0 expect(files).toContain("app/shows/page.tsx"); expect(files).toContain(join("app/shows/[id]/page.tsx")); expect(files).toContain(join("app/shows/[id]/season/[number]/page.tsx")); + // spec 0021, AC-17: every list page and the landing's row pieces too. + for (const slug of TV_LIST_SLUGS) { + expect(files).toContain(join(`app/shows/${slug}/page.tsx`)); + } + expect(files).toContain(join("components/catalog/carousel-row.tsx")); + expect(files).toContain(join("components/catalog/catalog-list-page.tsx")); }); it.each(files)("%s reaches for none of them", (path) => { @@ -100,6 +116,7 @@ describe("the show routes read no request scoped value (spec 0009, AC-18; spec 0 "app/shows/page.tsx", "app/shows/[id]/page.tsx", "app/shows/[id]/season/[number]/page.tsx", + ...TV_LIST_SLUGS.map((slug) => `app/shows/${slug}/page.tsx`), ])("%s does not opt out of the static shell (AC-17)", (path) => { expect(readFileSync(path, "utf8")).not.toMatch(/instant\s*=\s*false/); }); @@ -293,7 +310,8 @@ 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*\s*\s*\s* { + const files = [ + ...sourceFiles("components/catalog"), + "lib/catalog-list-read.ts", + "lib/landing-metadata.ts", + ]; + + it.each(files)("%s declares no use cache scope", (path) => { + expect(readFileSync(path, "utf8")).not.toMatch(/["']use cache/); + }); + + it("waits for the request before a row reads TMDB", () => { + const source = readFileSync("components/catalog/carousel-row.tsx", "utf8"); + expect(source.indexOf("await connection()")).toBeGreaterThan(-1); + expect(source.indexOf("await connection()")).toBeLessThan( + source.indexOf("readTaggedCatalogList(list, 1)"), + ); + }); +}); diff --git a/app/movies/top-rated/page.tsx b/app/movies/top-rated/page.tsx new file mode 100644 index 0000000..42d80d2 --- /dev/null +++ b/app/movies/top-rated/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("movie", "top-rated"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/movies/top-rated">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Top rated movies, paged (spec 0021, AC-9). */ +export default function TopRatedMoviesPage({ + searchParams, +}: PageProps<"/movies/top-rated">) { + return ; +} diff --git a/app/movies/trending/page.tsx b/app/movies/trending/page.tsx new file mode 100644 index 0000000..47d2800 --- /dev/null +++ b/app/movies/trending/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("movie", "trending"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/movies/trending">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Trending movies this week, paged (spec 0021, AC-9). */ +export default function TrendingMoviesPage({ + searchParams, +}: PageProps<"/movies/trending">) { + return ; +} diff --git a/app/movies/upcoming/page.tsx b/app/movies/upcoming/page.tsx new file mode 100644 index 0000000..18f7081 --- /dev/null +++ b/app/movies/upcoming/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("movie", "upcoming"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/movies/upcoming">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Upcoming movies, paged (spec 0021, AC-9). */ +export default function UpcomingMoviesPage({ + searchParams, +}: PageProps<"/movies/upcoming">) { + return ; +} diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index cc5932a..e1f964d 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -2,6 +2,14 @@ import { CircleStopIcon, SearchIcon, TvIcon } from "lucide-react"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import type * as React from "react"; +import { + CarouselRowEmpty, + CarouselRowError, + CarouselRowSkeleton, + ROW_ITEM_CLASS, + ROW_POSTER_SIZES, +} from "@/components/catalog/carousel-row-states"; +import { CarouselScroller } from "@/components/catalog/carousel-scroller"; import { GlassPill } from "@/components/glass-pill"; import { IconKey } from "@/components/layout/icon-key"; import { LibraryNavView } from "@/components/layout/library-nav"; @@ -41,6 +49,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { catalogList, listPath } from "@/lib/catalog/lists"; import { CardRoundButtonDemo } from "./card-round-button-demo"; @@ -90,6 +99,38 @@ function Section({ ); } +/** A carousel row as a landing renders it, around fixed demo content. */ +function ShowcaseRow({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
    + + {title} + + } + seeAll={ + + See all + + } + > + {children} + +
    + ); +} + function Swatch({ name, className }: { name: string; className: string }) { return (
    @@ -350,6 +391,39 @@ export default function ShowcasePage() { +
    +
    + +
      + {Array.from({ length: 8 }, (_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static demo cards with no identity. +
    • + } + sizes={ROW_POSTER_SIZES} + /> +
    • + ))} +
    +
    + + + + + + + + + +
    +
    +
    ): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Shows airing today, paged (spec 0021, AC-9). */ +export default function ShowsAiringTodayPage({ + searchParams, +}: PageProps<"/shows/airing-today">) { + return ; +} diff --git a/app/shows/on-the-air/page.tsx b/app/shows/on-the-air/page.tsx new file mode 100644 index 0000000..385c840 --- /dev/null +++ b/app/shows/on-the-air/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("tv", "on-the-air"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/shows/on-the-air">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Shows on the air, paged (spec 0021, AC-9). */ +export default function ShowsOnTheAirPage({ + searchParams, +}: PageProps<"/shows/on-the-air">) { + return ; +} diff --git a/app/shows/page.tsx b/app/shows/page.tsx index b63dfab..61a9ddc 100644 --- a/app/shows/page.tsx +++ b/app/shows/page.tsx @@ -1,199 +1,26 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; -import { PaginationLinks } from "@/components/pagination-links"; -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 { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; -import { StatePanel } from "@/components/state-panel"; -import { - ShowCardBadge, - ShowCardControls, - ShowCardMeta, -} from "@/components/tracking/show-card-slots"; -import { ButtonLink } from "@/components/ui/button"; -import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages"; -import { landingPageMetadata } from "@/lib/landing-metadata"; -import { discoverTvShows, TmdbError } from "@/lib/tmdb"; - -/** TMDB's discover page size, which the skeleton mirrors. */ -const PAGE_SIZE = 20; - -/** - * 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 = 4; - -/** One URL per page: page 1 is the bare path. */ -function pageHref(page: number): string { - return page === 1 ? "/shows" : `/shows?page=${page}`; -} - -/** Every page of the landing shares one description (spec 0016, AC-17). */ -const DESCRIPTION = - "Browse popular TV shows on BeStats. See seasons, episodes and ratings, and keep track of what you watch."; +import { CatalogLanding } from "@/components/catalog/catalog-landing"; +import { catalogListsFor } from "@/lib/catalog/lists"; +import { landingMetadata } from "@/lib/seo/metadata"; /** - * The landing's metadata (spec 0016, AC-17). Page N declares its own - * canonical through `pageHref`, the same address the pagination links use. A - * page that shows no results is `noindex`. `landingPageMetadata` decides it - * from the same cached `discoverTvShows` call the body makes, so metadata costs no - * extra TMDB request. + * The landing's metadata (spec 0021, AC-15): one page now, so one canonical, + * the bare path. A `?page=N` request never reaches it; `next.config.ts` + * redirects it to `/shows/popular` (AC-14). */ -export async function generateMetadata({ - searchParams, -}: PageProps<"/shows">): Promise { - return landingPageMetadata({ - pageParam: (await searchParams).page, - discover: discoverTvShows, - title: "Shows", - description: DESCRIPTION, - pageHref, - }); -} +export const metadata: Metadata = landingMetadata({ + title: "Shows", + description: + "Browse trending, popular, airing and top rated TV shows on BeStats, and keep track of what you watch.", + path: "/shows", +}); /** - * The TV landing: popular shows from TMDB, paged, the same page as `/movies` - * (spec 0009, AC-1, AC-2). - * - * 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-17). No session is read here: each - * card's TV bookmark (spec 0013, AC-18) reads it in its own Suspense boundary, - * all sharing one read, so the grid never waits on it. + * The TV landing: one carousel row per TV list, in registry order (spec + * 0021, AC-1). No session is read here; each card's slots read it inside + * their own Suspense boundaries (`components/catalog/list-cards.tsx`). */ -export default function ShowsPage({ searchParams }: PageProps<"/shows">) { - return ( -
    -
    -

    - Popular shows -

    -

    - Ordered by current popularity on TMDB. -

    -
    - - }> - - -
    - ); -} - -async function PopularShows({ - searchParams, -}: { - searchParams: PageProps<"/shows">["searchParams"]; -}) { - const page = parsePageParam((await searchParams).page); - if (page === null) return ; - - let result: Awaited>; - try { - result = await discoverTvShows({ page }); - } catch (error) { - if (!(error instanceof TmdbError)) throw error; - return ( -
    - } - /> -
    - ); - } - - const lastPage = lastReachablePage(result.totalPages); - if (page > lastPage) return ; - - const gridShowIds = result.results.map((show) => show.id); - - return ( -
    - - {result.results.map((show, index) => ( -
  • - } - > - - - } - meta={ - - - - } - controls={ - - - - } - priority={index < EAGER_POSTERS} - /> -
  • - ))} -
    - - -
    - ); -} - -/** Shown for a malformed page number, and for one past the last page. */ -function NoSuchPage() { - return ( -
    - - Back to page 1 - - } - /> -
    - ); -} - -function PopularShowsSkeleton() { - return ( -
    - - -
    - ); +export default function ShowsPage() { + return ; } diff --git a/app/shows/popular/page.tsx b/app/shows/popular/page.tsx new file mode 100644 index 0000000..f1af783 --- /dev/null +++ b/app/shows/popular/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("tv", "popular"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/shows/popular">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Popular shows, paged (spec 0021, AC-9). */ +export default function PopularShowsPage({ + searchParams, +}: PageProps<"/shows/popular">) { + return ; +} diff --git a/app/shows/top-rated/page.test.ts b/app/shows/top-rated/page.test.ts new file mode 100644 index 0000000..3ac4433 --- /dev/null +++ b/app/shows/top-rated/page.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0021, AC-15 + * + * A TV list page through the same flow as `/movies/popular`: its h1 as the + * title, its note as the description, page N's own canonical, and `noindex` + * past the last page. The list read is replaced. + */ +const getTvList = vi.fn(); + +vi.mock("@/lib/tmdb", async () => { + const errors = await import("@/lib/tmdb/errors"); + return { + getTvList: (...args: unknown[]) => getTvList(...args), + TmdbError: errors.TmdbError, + }; +}); + +const { generateMetadata } = await import("./page"); + +const ORIGIN = "https://bestats.example"; + +function metadataFor(page?: string) { + return generateMetadata({ + searchParams: Promise.resolve(page === undefined ? {} : { page }), + } as Parameters[0]); +} + +beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN)); + +afterEach(() => { + getTvList.mockReset(); + vi.unstubAllEnvs(); +}); + +describe("the top rated shows list's metadata", () => { + it("reads the top_rated list and names the page by its h1 and note", async () => { + getTvList.mockResolvedValue({ totalPages: 100 }); + + const metadata = await metadataFor(); + + expect(getTvList).toHaveBeenCalledWith("top_rated", { page: 1 }); + expect(metadata.title).toBe("Top rated shows"); + expect(metadata.description).toBe("Ordered by TMDB community rating."); + expect(metadata.alternates).toEqual({ + canonical: `${ORIGIN}/shows/top-rated`, + }); + }); + + it("gives page 2 its own canonical", async () => { + getTvList.mockResolvedValue({ totalPages: 100 }); + + const metadata = await metadataFor("2"); + + expect(metadata.alternates).toEqual({ + canonical: `${ORIGIN}/shows/top-rated?page=2`, + }); + }); + + it("is noindex past the last page", async () => { + getTvList.mockResolvedValue({ totalPages: 100 }); + + const metadata = await metadataFor("999"); + + expect(metadata.robots).toEqual({ index: false }); + expect(metadata.alternates).toBeUndefined(); + }); +}); diff --git a/app/shows/top-rated/page.tsx b/app/shows/top-rated/page.tsx new file mode 100644 index 0000000..ee45330 --- /dev/null +++ b/app/shows/top-rated/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("tv", "top-rated"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/shows/top-rated">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Top rated shows, paged (spec 0021, AC-9). */ +export default function TopRatedShowsPage({ + searchParams, +}: PageProps<"/shows/top-rated">) { + return ; +} diff --git a/app/shows/trending/page.tsx b/app/shows/trending/page.tsx new file mode 100644 index 0000000..1ab6713 --- /dev/null +++ b/app/shows/trending/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; + +import { CatalogListPage } from "@/components/catalog/catalog-list-page"; +import { catalogList } from "@/lib/catalog/lists"; +import { catalogListMetadata } from "@/lib/landing-metadata"; + +const LIST = catalogList("tv", "trending"); + +/** The list's title, note and canonical (spec 0021, AC-15). */ +export async function generateMetadata({ + searchParams, +}: PageProps<"/shows/trending">): Promise { + return catalogListMetadata(LIST, searchParams); +} + +/** Trending shows this week, paged (spec 0021, AC-9). */ +export default function TrendingShowsPage({ + searchParams, +}: PageProps<"/shows/trending">) { + return ; +} diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index e8dab78..6f3de75 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TmdbError } from "@/lib/tmdb/errors"; /** - * covers: spec 0016, AC-6, AC-7, AC-8, AC-9; spec 0017, AC-13 + * covers: spec 0016, AC-6, AC-7, AC-8, AC-9; spec 0017, AC-13; spec 0021, AC-16 * * The sitemap is the one SEO surface that reads TMDB, so its order, dedupe, * independent failure and lifetime choice are pinned against mocked discover @@ -30,6 +30,29 @@ const { default: sitemap } = await import("./sitemap"); const ORIGIN = "https://bestats.example"; +/** The ten list pages at page 1, movies first, in row order (spec 0021, AC-16). */ +const LISTS = [ + "/movies/trending", + "/movies/popular", + "/movies/now-playing", + "/movies/upcoming", + "/movies/top-rated", + "/shows/trending", + "/shows/popular", + "/shows/airing-today", + "/shows/on-the-air", + "/shows/top-rated", +].map((path) => `${ORIGIN}${path}`); + +/** Everything listed before the titles. */ +const FIXED = [ + `${ORIGIN}/movies`, + `${ORIGIN}/shows`, + ...LISTS, + `${ORIGIN}/privacy`, + `${ORIGIN}/terms`, +]; + /** A discover page holding the given ids. */ function page(ids: number[]) { return { @@ -67,22 +90,19 @@ afterEach(() => { }); describe("the sitemap on an indexable deployment", () => { - it("lists the landings, the legal pages, then movies, then shows, in page order (AC-6; spec 0017, AC-13)", async () => { + it("lists the landings, the list pages, the legal pages, then movies, then shows, in page order (AC-6; spec 0017, AC-13; spec 0021, AC-16)", async () => { happyReads(); const listed = urls(await sitemap()); - expect(listed.slice(0, 6)).toEqual([ - `${ORIGIN}/movies`, - `${ORIGIN}/shows`, - `${ORIGIN}/privacy`, - `${ORIGIN}/terms`, + expect(listed.slice(0, 16)).toEqual([ + ...FIXED, `${ORIGIN}/movies/101`, `${ORIGIN}/movies/102`, ]); - expect(listed).toHaveLength(4 + 20 + 20); - expect(listed[23]).toBe(`${ORIGIN}/movies/1002`); - expect(listed[24]).toBe(`${ORIGIN}/shows/5101`); + expect(listed).toHaveLength(14 + 20 + 20); + expect(listed[33]).toBe(`${ORIGIN}/movies/1002`); + expect(listed[34]).toBe(`${ORIGIN}/shows/5101`); expect(listed.at(-1)).toBe(`${ORIGIN}/shows/6002`); }); @@ -116,10 +136,7 @@ describe("the sitemap on an indexable deployment", () => { ); expect(urls(await sitemap())).toEqual([ - `${ORIGIN}/movies`, - `${ORIGIN}/shows`, - `${ORIGIN}/privacy`, - `${ORIGIN}/terms`, + ...FIXED, `${ORIGIN}/movies/1`, `${ORIGIN}/movies/2`, `${ORIGIN}/movies/3`, @@ -130,9 +147,12 @@ describe("the sitemap on an indexable deployment", () => { it("never lists search, seasons, page variants or private paths (AC-7)", async () => { happyReads(); + // Anchored at the origin: `/movies/upcoming` is a public list page, the + // private library page is `/upcoming` (spec 0021, AC-16). for (const url of urls(await sitemap())) { - expect(url).not.toMatch( - /\/search|\/season\/|\?|\/showcase|\/watchlist|\/watched|\/upcoming|\/account|\/sign-|\/api\/|\/auth\//, + const path = url.slice(ORIGIN.length); + expect(path).not.toMatch( + /^\/(search|showcase|watchlist|watched|upcoming|account|sign-|api\/|auth\/)|\/season\/|\?/, ); } }); @@ -162,14 +182,14 @@ describe("the sitemap on an indexable deployment", () => { const listed = urls(await sitemap()); - expect(listed).toHaveLength(4 + 18 + 18); + expect(listed).toHaveLength(14 + 18 + 18); expect(listed).not.toContain(`${ORIGIN}/movies/201`); expect(listed).not.toContain(`${ORIGIN}/shows/6001`); expect(listed).toContain(`${ORIGIN}/movies/301`); expect(cacheLife.mock.calls).toEqual([["minutes"]]); }); - it("still lists the landings and legal pages when every read fails (AC-8)", async () => { + it("still lists the landings, list pages and legal pages when every read fails (AC-8)", async () => { discoverMovies.mockRejectedValue( new TmdbError("rate_limited", "/discover", "failed"), ); @@ -177,12 +197,7 @@ describe("the sitemap on an indexable deployment", () => { new TmdbError("rate_limited", "/discover", "failed"), ); - expect(urls(await sitemap())).toEqual([ - `${ORIGIN}/movies`, - `${ORIGIN}/shows`, - `${ORIGIN}/privacy`, - `${ORIGIN}/terms`, - ]); + expect(urls(await sitemap())).toEqual(FIXED); expect(cacheLife.mock.calls).toEqual([["minutes"]]); }); diff --git a/app/sitemap.ts b/app/sitemap.ts index cc6da6b..a1b3db9 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,13 +1,14 @@ import type { MetadataRoute } from "next"; import { cacheLife } from "next/cache"; +import { catalogListsFor, listPath } from "@/lib/catalog/lists"; import { isIndexableDeployment, siteUrl } from "@/lib/seo/site"; import { DISCOVER_PAGES_PER_TYPE, sitemapEntries } from "@/lib/seo/sitemap"; import { discoverMovies, discoverTvShows, TmdbError } from "@/lib/tmdb"; /** - * `/sitemap.xml` (spec 0016, AC-6 to AC-9): the two landings, the two legal - * pages (spec 0017, AC-13), then the 200 most popular movies and 200 most + * `/sitemap.xml` (spec 0016, AC-6 to AC-9): the two landings, the ten list + * pages (spec 0021, AC-16), the two legal pages (spec 0017, AC-13), then the 200 most popular movies and 200 most * popular shows, all pages a signed out visitor can open from a landing. * * A deployment that is not indexable answers an empty `urlset` and asks TMDB @@ -20,13 +21,22 @@ export default async function sitemap(): Promise { if (!isIndexableDeployment() || origin === null) return []; const { moviePages, showPages } = await popularTitleIds(); - return sitemapEntries(origin, moviePages, showPages); + return sitemapEntries(origin, LIST_PATHS, moviePages, showPages); } +/** + * Every list page at page 1, movie lists first, each in row order (spec 0021, + * AC-16). From the registry, so a new list is listed with no change here. + */ +const LIST_PATHS = [...catalogListsFor("movie"), ...catalogListsFor("tv")].map( + listPath, +); + /** * The ids on discover pages 1 to 10 for each media type, read with exactly the - * calls the `/movies` and `/shows` landings make, so they share cache entries - * and every listed title is linked from a public page (spec 0016, AC-6, AC-8). + * calls `/movies/popular` and `/shows/popular` make, so they share cache + * entries and every listed title is linked from a public page (spec 0016, + * AC-6, AC-8; spec 0021, AC-3). * * The 20 reads settle independently: a `TmdbError` drops only its own page, * so a TMDB hiccup shrinks the list instead of failing the file. Any other diff --git a/components/catalog/carousel-row-states.tsx b/components/catalog/carousel-row-states.tsx new file mode 100644 index 0000000..d01d1c3 --- /dev/null +++ b/components/catalog/carousel-row-states.tsx @@ -0,0 +1,80 @@ +import type * as React from "react"; + +import { RetryLink } from "@/components/retry-link"; +import { PosterCardSkeleton } from "@/components/skeleton"; +import type { CatalogList } from "@/lib/catalog/lists"; +import { CATALOG_PATHS } from "@/lib/catalog/media-type"; + +/** How many titles a row holds: the first 12 of TMDB's page 1 (AC-3). */ +const ROW_SIZE = 12; + +/** A row card's fixed width, mirrored by `ROW_POSTER_SIZES` (AC-5). */ +const ROW_ITEM_CLASS = "w-[140px] shrink-0 snap-start md:w-[180px]"; + +/** The `sizes` of a row card's poster, matching `ROW_ITEM_CLASS` (AC-5). */ +const ROW_POSTER_SIZES = "(min-width: 768px) 180px, 140px"; + +/** A row whose TMDB read failed: only this row, with Try again (AC-11). */ +function CarouselRowError({ list }: { list: CatalogList }) { + return ( + +

    + Couldn't load {list.rowTitle} +

    +

    TMDB didn't respond.

    + +
    + ); +} + +/** A row whose list loaded with no titles: the same line on every row (AC-12). */ +function CarouselRowEmpty() { + return ( + +

    Nothing here right now.

    +
    + ); +} + +/** + * A row's error or empty message, over an invisible card skeleton so it + * keeps exactly a card's height and the page does not shift (AC-11, AC-12). + * One card wide, so the row does not overflow and both arrows stay disabled. + */ +function RowMessage({ children }: { children: React.ReactNode }) { + return ( +
    + +
    + {children} +
    +
    + ); +} + +/** Twelve card skeletons at the row's card width (AC-7). */ +function CarouselRowSkeleton() { + return ( + + ); +} + +export { + CarouselRowEmpty, + CarouselRowError, + CarouselRowSkeleton, + ROW_ITEM_CLASS, + ROW_POSTER_SIZES, + ROW_SIZE, +}; diff --git a/components/catalog/carousel-row.test.tsx b/components/catalog/carousel-row.test.tsx new file mode 100644 index 0000000..2048cbd --- /dev/null +++ b/components/catalog/carousel-row.test.tsx @@ -0,0 +1,247 @@ +import { render, screen } from "@testing-library/react"; +import type * as React from "react"; +import { Suspense } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0021, AC-3, AC-7, AC-11, AC-12 + * + * A row's cards on their own: the first 12 titles, the first `eagerCount` + * posters eager, a `TmdbError` kept inside the row with a Try again to the + * landing, an empty list said in one line, and any other error rethrown. The + * read and the cards are replaced; `TmdbError` is the real one. + */ +const read = vi.fn(); + +vi.mock("next/server", () => ({ connection: async () => {} })); +vi.mock("@/lib/tmdb", async () => ({ + TmdbError: (await import("@/lib/tmdb/errors")).TmdbError, +})); +vi.mock("@/lib/catalog-list-read", () => ({ + readTaggedCatalogList: (...args: unknown[]) => read(...args), +})); +vi.mock("@/components/catalog/list-cards", () => ({ + ShowListCard: ({ + show, + priority, + returnPath, + sizes, + }: { + show: { name: string }; + priority: boolean; + returnPath: string; + sizes: string; + }) => ( + + {show.name} + + ), + MovieListCard: ({ movie }: { movie: { title: string } }) => ( + {movie.title} + ), +})); + +const { TmdbError } = await import("@/lib/tmdb/errors"); +const { catalogList } = await import("@/lib/catalog/lists"); +const { CarouselRow, CarouselRowItems } = await import("./carousel-row"); +const { CarouselRowSkeleton } = await import("./carousel-row-states"); +const { CarouselScroller } = await import("./carousel-scroller"); + +const TOP_RATED = catalogList("tv", "top-rated"); + +function shows(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Show ${index + 1}`, + })); +} + +afterEach(() => read.mockReset()); + +describe("CarouselRowItems", () => { + it("reads page 1 and shows the first 12 titles (AC-3)", async () => { + read.mockResolvedValue({ + mediaType: "tv", + paged: { results: shows(20), totalPages: 5, page: 1, totalResults: 99 }, + }); + + render(await CarouselRowItems({ list: TOP_RATED, eagerCount: 6 })); + + expect(read).toHaveBeenCalledWith(TOP_RATED, 1); + const cards = screen.getAllByRole("listitem"); + expect(cards).toHaveLength(12); + expect(screen.getByText("Show 12")).toBeInTheDocument(); + expect(screen.queryByText("Show 13")).not.toBeInTheDocument(); + }); + + it("loads the first eagerCount posters eagerly, at the row's sizes (AC-5, AC-7)", async () => { + read.mockResolvedValue({ + mediaType: "tv", + paged: { results: shows(8), totalPages: 1, page: 1, totalResults: 8 }, + }); + + render(await CarouselRowItems({ list: TOP_RATED, eagerCount: 6 })); + + expect(screen.getByText("Show 6")).toHaveAttribute("data-priority", "true"); + expect(screen.getByText("Show 7")).toHaveAttribute( + "data-priority", + "false", + ); + expect(screen.getByText("Show 1")).toHaveAttribute( + "data-sizes", + "(min-width: 768px) 180px, 140px", + ); + expect(screen.getByText("Show 1")).toHaveAttribute("data-return", "/shows"); + }); + + it("keeps a TMDB failure inside the row, with Try again to the landing (AC-11)", async () => { + read.mockRejectedValue( + new TmdbError("upstream", "/tv/top_rated", "503", 503), + ); + + render(await CarouselRowItems({ list: TOP_RATED, eagerCount: 0 })); + + expect(screen.getByText("Couldn't load Top rated")).toBeInTheDocument(); + expect(screen.getByText("TMDB didn't respond.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + "/shows", + ); + }); + + it("says so in one line when the list is empty (AC-12)", async () => { + read.mockResolvedValue({ + mediaType: "tv", + paged: { results: [], totalPages: 0, page: 1, totalResults: 0 }, + }); + + render(await CarouselRowItems({ list: TOP_RATED, eagerCount: 0 })); + + expect(screen.getByText("Nothing here right now.")).toBeInTheDocument(); + expect(screen.queryByRole("listitem")).not.toBeInTheDocument(); + }); + + it("rethrows anything that is not a TMDB failure", async () => { + read.mockRejectedValue(new TypeError("a bug")); + + await expect( + CarouselRowItems({ list: TOP_RATED, eagerCount: 0 }), + ).rejects.toThrow("a bug"); + }); +}); + +type Element = React.ReactElement>; + +/** + * covers: spec 0021, AC-1, AC-7 + * + * The row's static half, read from the element tree: the section named by its + * h2, the See all link, and the cards behind their own Suspense boundary with + * 12 skeletons as the fallback. The scroller itself is covered in + * `carousel-scroller.test.tsx`. + */ +describe("CarouselRow", () => { + const AIRING = catalogList("tv", "airing-today"); + + function rowParts(eagerCount = 0) { + const section = CarouselRow({ list: AIRING, eagerCount }) as Element; + const scroller = section.props.children as Element; + return { section, scroller }; + } + + it("is a section labelled by its h2", () => { + const { section, scroller } = rowParts(); + expect(section.type).toBe("section"); + expect(scroller.type).toBe(CarouselScroller); + + render(scroller.props.heading as Element); + + const heading = screen.getByRole("heading", { + level: 2, + name: "Airing today", + }); + expect(heading).toHaveAttribute("id", section.props["aria-labelledby"]); + }); + + it("names the scroll region and its buttons by the row title (AC-5, AC-6)", () => { + expect(rowParts().scroller.props.label).toBe("Airing today"); + }); + + it("links See all to the list page", () => { + render(rowParts().scroller.props.seeAll as Element); + + expect(screen.getByRole("link", { name: "See all" })).toHaveAttribute( + "href", + "/shows/airing-today", + ); + }); + + it("streams the cards in their own boundary, behind the 12 card skeleton (AC-7)", () => { + const boundary = rowParts(6).scroller.props.children as Element; + expect(boundary.type).toBe(Suspense); + + const items = boundary.props.children as Element; + expect(items.type).toBe(CarouselRowItems); + expect(items.props).toMatchObject({ list: AIRING, eagerCount: 6 }); + + const fallback = boundary.props.fallback as Element; + expect(fallback.type).toBe(CarouselRowSkeleton); + const { container } = render(fallback); + expect(container.querySelectorAll("li")).toHaveLength(12); + expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true"); + }); + + it("gives each row a distinct heading id", () => { + const top = CarouselRow({ list: TOP_RATED, eagerCount: 0 }) as Element; + const airing = rowParts().section; + + expect(top.props["aria-labelledby"]).not.toBe( + airing.props["aria-labelledby"], + ); + }); +}); + +describe("CarouselRowItems for movies", () => { + it("returns movie cards to /movies", async () => { + read.mockResolvedValue({ + mediaType: "movie", + paged: { + results: [{ id: 550, title: "Fight Club" }], + totalPages: 1, + page: 1, + totalResults: 1, + }, + }); + + render( + await CarouselRowItems({ + list: catalogList("movie", "upcoming"), + eagerCount: 0, + }), + ); + + expect(screen.getByText("Fight Club")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + }); + + it("sends Try again on a failed movie row to /movies (AC-11)", async () => { + read.mockRejectedValue(new TmdbError("timeout", "/movie/upcoming", "x")); + + render( + await CarouselRowItems({ + list: catalogList("movie", "upcoming"), + eagerCount: 0, + }), + ); + + expect(screen.getByText("Couldn't load Upcoming")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + "/movies", + ); + }); +}); diff --git a/components/catalog/carousel-row.tsx b/components/catalog/carousel-row.tsx new file mode 100644 index 0000000..543fced --- /dev/null +++ b/components/catalog/carousel-row.tsx @@ -0,0 +1,121 @@ +import { ChevronRightIcon } from "lucide-react"; +import Link from "next/link"; +import { connection } from "next/server"; +import { Suspense } from "react"; + +import { + CarouselRowEmpty, + CarouselRowError, + CarouselRowSkeleton, + ROW_ITEM_CLASS, + ROW_POSTER_SIZES, + ROW_SIZE, +} from "@/components/catalog/carousel-row-states"; +import { CarouselScroller } from "@/components/catalog/carousel-scroller"; +import { CatalogListCards } from "@/components/catalog/catalog-list-cards"; +import { type CatalogList, listPath } from "@/lib/catalog/lists"; +import { CATALOG_PATHS } from "@/lib/catalog/media-type"; +import { + readTaggedCatalogList, + type TaggedCatalogPage, +} from "@/lib/catalog-list-read"; +import { TmdbError } from "@/lib/tmdb"; + +/** + * One landing row: an h2 with See all and the arrow buttons, then up to 12 + * full cards that scroll sideways (spec 0021, AC-1, AC-5, AC-6). + * + * The header is part of the prerendered shell. The cards stream in their own + * Suspense boundary behind 12 skeletons at the cards' width, so a row + * arriving shifts nothing, and a slow or failing list never holds up the + * others (AC-7, AC-11). + * + * @param eagerCount How many of the row's posters load eagerly: 6 on a + * landing's first row, 0 on every other (AC-7). + */ +function CarouselRow({ + list, + eagerCount, +}: { + list: CatalogList; + eagerCount: number; +}) { + const headingId = `row-${list.slug}`; + + return ( +
    + + {list.rowTitle} + + } + seeAll={ + + See all + +
    + ); +} + +/** + * The row's cards, read at request time (`connection()` before the read), so + * the build asks TMDB nothing and passes with no token (AC-7). Page 1 is the + * same cached call page 1 of the list page makes (AC-3). + * + * A `TmdbError` stays inside this row as a compact error with Try again + * (AC-11); anything else is a bug and is rethrown. A list that loaded empty + * says so in one line (AC-12). Both reserve a card's height. + */ +async function CarouselRowItems({ + list, + eagerCount, +}: { + list: CatalogList; + eagerCount: number; +}) { + await connection(); + + let result: TaggedCatalogPage; + try { + result = await readTaggedCatalogList(list, 1); + } catch (error) { + if (!(error instanceof TmdbError)) throw error; + return ; + } + + if (result.paged.results.length === 0) { + return ; + } + + const returnPath = CATALOG_PATHS[list.mediaType]; + + return ( +
      + +
    + ); +} + +export { CarouselRow, CarouselRowItems }; diff --git a/components/catalog/carousel-scroller.test.tsx b/components/catalog/carousel-scroller.test.tsx new file mode 100644 index 0000000..17ba332 --- /dev/null +++ b/components/catalog/carousel-scroller.test.tsx @@ -0,0 +1,155 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CarouselScroller } from "@/components/catalog/carousel-scroller"; + +/** + * covers: spec 0021, AC-5, AC-6 + * + * jsdom lays nothing out, so each test gives the region the sizes a browser + * would measure and fires what a browser would: a scroll, or a resize from + * the observer. What is under test is the wiring: which button is enabled at + * which position, how far a press scrolls, and whether reduced motion makes + * it instant. + */ +let resize: () => void = () => {}; +let reducedMotion = false; + +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)" && reducedMotion, + })), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + reducedMotion = false; +}); + +function renderRow() { + render( + Top rated} + seeAll={See all} + > +
      +
    • One
    • +
    +
    , + ); + const region = screen.getByRole("region", { name: "Top rated" }); + const scrollBy = vi.fn(); + region.scrollBy = scrollBy as unknown as typeof region.scrollBy; + return { + region, + scrollBy, + back: screen.getByRole("button", { name: "Scroll Top rated back" }), + forward: screen.getByRole("button", { name: "Scroll Top rated forward" }), + }; +} + +function layout( + region: HTMLElement, + sizes: { scrollLeft: number; clientWidth: number; scrollWidth: number }, +) { + Object.defineProperty(region, "clientWidth", { + configurable: true, + value: sizes.clientWidth, + }); + Object.defineProperty(region, "scrollWidth", { + configurable: true, + value: sizes.scrollWidth, + }); + region.scrollLeft = sizes.scrollLeft; +} + +describe("CarouselScroller", () => { + it("is a focusable region named by the row title (AC-5)", () => { + const { region } = renderRow(); + + expect(region).toHaveAttribute("tabindex", "0"); + }); + + it("starts with both buttons disabled while nothing overflows (AC-6)", () => { + const { back, forward } = renderRow(); + + expect(back).toBeDisabled(); + expect(forward).toBeDisabled(); + }); + + it("enables forward only once the row overflows", () => { + const { region, back, forward } = renderRow(); + + layout(region, { scrollLeft: 0, clientWidth: 1000, scrollWidth: 2400 }); + act(() => resize()); + + expect(back).toBeDisabled(); + expect(forward).toBeEnabled(); + }); + + it("enables back after a scroll and disables forward at the end", () => { + const { region, back, forward } = renderRow(); + + layout(region, { scrollLeft: 1400, clientWidth: 1000, scrollWidth: 2400 }); + act(() => { + region.dispatchEvent(new Event("scroll")); + }); + + expect(back).toBeEnabled(); + expect(forward).toBeDisabled(); + }); + + it("treats within 1px of an end as the end", () => { + const { region, back, forward } = renderRow(); + + layout(region, { scrollLeft: 1, clientWidth: 1000, scrollWidth: 1002 }); + act(() => resize()); + + expect(back).toBeDisabled(); + expect(forward).toBeDisabled(); + }); + + it("scrolls 90% of the visible width, smoothly", async () => { + const user = userEvent.setup(); + const { region, scrollBy, forward } = renderRow(); + layout(region, { scrollLeft: 0, clientWidth: 1000, scrollWidth: 2400 }); + act(() => resize()); + + await user.click(forward); + + expect(scrollBy).toHaveBeenCalledWith({ left: 900, behavior: "smooth" }); + }); + + it("scrolls back instantly under reduced motion", async () => { + reducedMotion = true; + const user = userEvent.setup(); + const { region, scrollBy, back } = renderRow(); + layout(region, { scrollLeft: 1400, clientWidth: 1000, scrollWidth: 2400 }); + act(() => resize()); + + await user.click(back); + + expect(scrollBy).toHaveBeenCalledWith({ left: -900, behavior: "instant" }); + }); + + it("hides the buttons below md (AC-6)", () => { + const { back } = renderRow(); + + expect(back.parentElement).toHaveClass("hidden", "md:flex"); + }); +}); diff --git a/components/catalog/carousel-scroller.tsx b/components/catalog/carousel-scroller.tsx new file mode 100644 index 0000000..1b1bf94 --- /dev/null +++ b/components/catalog/carousel-scroller.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import type * as React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; + +/** How far one press scrolls, as a share of the row's visible width (AC-6). */ +const SCROLL_STEP = 0.9; + +/** + * A row's header and its sideways scroll area, so one client component owns + * both ends of the arrow buttons (spec 0021, AC-5, AC-6). + * + * The scroll area is a focusable, labelled region: a keyboard user tabs to it + * and scrolls with the arrow keys, and touch and trackpad scroll it natively, + * with or without this component's script. The two buttons show from `md` + * and start disabled, so before hydration (or with the bundle failing) they + * are inert while the row still scrolls. After that, each is enabled only + * while the row can move its way, measured on scroll and whenever the row or + * its streamed cards change size. + * + * It holds no data and reads no session: the header pieces and the cards are + * Server Components passed in. + */ +function CarouselScroller({ + label, + heading, + seeAll, + children, +}: { + /** The row title, naming the region and the buttons. */ + label: string; + heading: React.ReactNode; + seeAll: React.ReactNode; + children: React.ReactNode; +}) { + const regionRef = useRef(null); + const contentRef = useRef(null); + const [canGoBack, setCanGoBack] = useState(false); + const [canGoForward, setCanGoForward] = useState(false); + + const measure = useCallback(() => { + const region = regionRef.current; + if (!region) return; + setCanGoBack(region.scrollLeft > 1); + setCanGoForward( + region.scrollLeft + region.clientWidth < region.scrollWidth - 1, + ); + }, []); + + useEffect(() => { + const region = regionRef.current; + const content = contentRef.current; + if (!region || !content) return; + + measure(); + region.addEventListener("scroll", measure, { passive: true }); + const observer = new ResizeObserver(measure); + observer.observe(region); + observer.observe(content); + return () => { + region.removeEventListener("scroll", measure); + observer.disconnect(); + }; + }, [measure]); + + function scroll(direction: -1 | 1) { + const region = regionRef.current; + if (!region) return; + const reduced = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + region.scrollBy({ + left: direction * region.clientWidth * SCROLL_STEP, + behavior: reduced ? "instant" : "smooth", + }); + } + + return ( + <> +
    + {heading} +
    + {seeAll} +
    + + +
    +
    +
    + +
    +
    + {children} +
    +
    + + ); +} + +export { CarouselScroller }; diff --git a/components/catalog/catalog-landing.test.tsx b/components/catalog/catalog-landing.test.tsx new file mode 100644 index 0000000..f908502 --- /dev/null +++ b/components/catalog/catalog-landing.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { CatalogList } from "@/lib/catalog/lists"; + +/** + * covers: spec 0021, AC-1, AC-7 + * + * The two landings through their real route files: the h1, then one row per + * list in AC-1 order, with only the first row's posters eager. The row is + * replaced by a stub that prints what it was given; its own header and cards + * are covered in `carousel-row.test.tsx`. + */ +vi.mock("@/components/catalog/carousel-row", () => ({ + CarouselRow: ({ + list, + eagerCount, + }: { + list: CatalogList; + eagerCount: number; + }) => ( +
    + {list.slug} +
    + ), +})); + +const { default: ShowsPage } = await import("@/app/shows/page"); +const { default: MoviesPage } = await import("@/app/movies/page"); + +function rowTitles() { + return screen + .getAllByRole("region") + .map((row) => row.getAttribute("aria-label")); +} + +describe("the TV landing (AC-1)", () => { + it("shows the h1 Shows", () => { + render(); + + expect( + screen.getByRole("heading", { level: 1, name: "Shows" }), + ).toBeInTheDocument(); + }); + + it("shows the five TV rows in order", () => { + render(); + + expect(rowTitles()).toEqual([ + "Trending this week", + "Popular", + "Airing today", + "On the air", + "Top rated", + ]); + }); + + it("loads 6 posters eagerly on the first row only (AC-7)", () => { + render(); + + const eager = screen + .getAllByRole("region") + .map((row) => row.getAttribute("data-eager")); + expect(eager).toEqual(["6", "0", "0", "0", "0"]); + }); +}); + +describe("the movie landing (AC-1)", () => { + it("shows the h1 Movies", () => { + render(); + + expect( + screen.getByRole("heading", { level: 1, name: "Movies" }), + ).toBeInTheDocument(); + }); + + it("shows the five movie rows in order", () => { + render(); + + expect(rowTitles()).toEqual([ + "Trending this week", + "Popular", + "Now playing", + "Upcoming", + "Top rated", + ]); + }); + + it("loads 6 posters eagerly on the first row only (AC-7)", () => { + render(); + + const eager = screen + .getAllByRole("region") + .map((row) => row.getAttribute("data-eager")); + expect(eager).toEqual(["6", "0", "0", "0", "0"]); + }); + + it("has no subtitle under the h1", () => { + const { container } = render(); + + expect(container.querySelector("p")).toBeNull(); + }); +}); diff --git a/components/catalog/catalog-landing.tsx b/components/catalog/catalog-landing.tsx new file mode 100644 index 0000000..3034a66 --- /dev/null +++ b/components/catalog/catalog-landing.tsx @@ -0,0 +1,37 @@ +import { CarouselRow } from "@/components/catalog/carousel-row"; +import type { CatalogList } from "@/lib/catalog/lists"; + +/** Posters loaded eagerly on a landing: the first 6 cards of row 0 (AC-7). */ +const FIRST_ROW_EAGER = 6; + +/** + * A catalog landing, `/shows` or `/movies` (spec 0021, AC-1): the h1, then one + * carousel row per list in registry order. The h1 and every row header are + * prerendered; each row's cards stream on their own. + */ +function CatalogLanding({ + heading, + lists, +}: { + heading: string; + lists: readonly CatalogList[]; +}) { + return ( +
    +

    + {heading} +

    +
    + {lists.map((list, index) => ( + + ))} +
    +
    + ); +} + +export { CatalogLanding }; diff --git a/components/catalog/catalog-list-cards.tsx b/components/catalog/catalog-list-cards.tsx new file mode 100644 index 0000000..6e3a76b --- /dev/null +++ b/components/catalog/catalog-list-cards.tsx @@ -0,0 +1,60 @@ +import { MovieListCard, ShowListCard } from "@/components/catalog/list-cards"; +import type { TaggedCatalogPage } from "@/lib/catalog-list-read"; + +/** + * One `
  • ` and full card per title of a list page, the one place a tagged + * page becomes show or movie cards (spec 0021, AC-4). A landing row and a + * list page grid both render through it and differ only in the props. + * + * `ids` is every id the caller renders, so all the cards' slots share one + * read (prompts/catalog-card-status.md). + * + * @param limit How many titles to render; all of them when left out. + * @param eagerCount How many leading posters load eagerly (AC-7). + */ +function CatalogListCards({ + result, + returnPath, + eagerCount, + limit, + sizes, + itemClassName, +}: { + result: TaggedCatalogPage; + returnPath: string; + eagerCount: number; + limit?: number; + sizes?: string; + itemClassName?: string; +}) { + if (result.mediaType === "tv") { + const shows = result.paged.results.slice(0, limit); + const ids = shows.map((show) => show.id); + return shows.map((show, index) => ( +
  • + +
  • + )); + } + const movies = result.paged.results.slice(0, limit); + const ids = movies.map((movie) => movie.id); + return movies.map((movie, index) => ( +
  • + +
  • + )); +} + +export { CatalogListCards }; diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx new file mode 100644 index 0000000..07c62b2 --- /dev/null +++ b/components/catalog/catalog-list-page.test.tsx @@ -0,0 +1,350 @@ +import { render, screen, within } from "@testing-library/react"; +import type * as React from "react"; +import { Suspense } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { CatalogList } from "@/lib/catalog/lists"; + +/** + * covers: spec 0021, AC-4, AC-7, AC-9, AC-10 + * + * A list's See all page. The header (back link, h1, note) is the static part, + * outside the grid's Suspense boundary. The grid is the async part: it is + * awaited here as a function, the way the boundary would resolve it, because + * jsdom's client renderer does not reliably commit an async Server Component + * (see the sign up page test). The read and the cards are replaced; + * `TmdbError` is the real one. + */ +const read = vi.fn(); + +vi.mock("@/lib/tmdb", async () => ({ + TmdbError: (await import("@/lib/tmdb/errors")).TmdbError, +})); +vi.mock("@/lib/catalog-list-read", () => ({ + readTaggedCatalogList: (...args: unknown[]) => read(...args), +})); +vi.mock("@/components/catalog/list-cards", () => ({ + ShowListCard: (props: { + show: { name: string }; + gridShowIds: number[]; + priority: boolean; + returnPath: string; + }) => ( + + {props.show.name} + + ), + MovieListCard: (props: { + movie: { title: string }; + priority: boolean; + returnPath: string; + }) => ( + + {props.movie.title} + + ), +})); + +const { TmdbError } = await import("@/lib/tmdb/errors"); +const { catalogList } = await import("@/lib/catalog/lists"); +const { CatalogListPage } = await import("./catalog-list-page"); + +type Element = React.ReactElement>; + +const TOP_RATED: CatalogList = catalogList("tv", "top-rated"); +const NOW_PLAYING = catalogList("movie", "now-playing"); + +function pageTree( + list = TOP_RATED, + params: Record = {}, +): Element[] { + const page = CatalogListPage({ + list, + searchParams: Promise.resolve(params), + }) as Element; + return page.props.children as Element[]; +} + +function gridBoundary( + list = TOP_RATED, + params: Record = {}, +): Element { + const boundary = pageTree(list, params).find( + (child) => child?.type === Suspense, + ); + if (!boundary) throw new Error("the list page has no grid boundary"); + return boundary; +} + +/** Resolves the grid the way its Suspense boundary would. */ +async function renderGrid( + list = TOP_RATED, + params: Record = {}, +) { + const slot = gridBoundary(list, params).props.children as Element; + const resolve = slot.type as (props: unknown) => Promise; + return render(await resolve(slot.props)); +} + +function shows(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Show ${index + 1}`, + })); +} + +function tvPage(count: number, totalPages: number) { + return { + mediaType: "tv", + paged: { + page: 1, + results: shows(count), + totalPages, + totalResults: totalPages * 20, + }, + }; +} + +afterEach(() => read.mockReset()); + +describe("the list page header (AC-9)", () => { + it("links back to the landing above the h1 and the note", () => { + const header = pageTree().find((child) => child?.type === "header"); + if (!header) throw new Error("the list page has no header"); + + render(header); + + expect(screen.getByRole("link", { name: "Shows" })).toHaveAttribute( + "href", + "/shows", + ); + expect( + screen.getByRole("heading", { level: 1, name: "Top rated shows" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Ordered by TMDB community rating."), + ).toBeInTheDocument(); + }); + + it("links a movie list back to Movies", () => { + const header = pageTree(NOW_PLAYING).find( + (child) => child?.type === "header", + ); + if (!header) throw new Error("the list page has no header"); + + render(header); + + expect(screen.getByRole("link", { name: "Movies" })).toHaveAttribute( + "href", + "/movies", + ); + expect( + screen.getByRole("heading", { level: 1, name: "Movies now playing" }), + ).toBeInTheDocument(); + expect( + screen.getByText("In Polish cinemas now, per TMDB."), + ).toBeInTheDocument(); + }); + + it("keeps the header outside the grid's Suspense boundary (AC-7)", () => { + const children = pageTree(); + const header = children.findIndex((child) => child?.type === "header"); + const boundary = children.findIndex((child) => child?.type === Suspense); + + expect(header).toBeGreaterThanOrEqual(0); + expect(boundary).toBeGreaterThan(header); + }); +}); + +describe("the list page grid (AC-4, AC-9)", () => { + it("reads the page from the URL and shows one card per title", async () => { + read.mockResolvedValue(tvPage(20, 30)); + + await renderGrid(TOP_RATED, { page: "2" }); + + expect(read).toHaveBeenCalledWith(TOP_RATED, 2); + const grid = screen.getByRole("list", { name: "Top rated shows, page 2" }); + expect(within(grid).getAllByRole("listitem")).toHaveLength(20); + }); + + it("reads page 1 when the URL has no page", async () => { + read.mockResolvedValue(tvPage(20, 30)); + + await renderGrid(); + + expect(read).toHaveBeenCalledWith(TOP_RATED, 1); + }); + + it("gives every card the whole page's ids and the page URL to return to", async () => { + read.mockResolvedValue(tvPage(3, 30)); + + await renderGrid(TOP_RATED, { page: "4" }); + + const card = screen.getByText("Show 2"); + expect(card).toHaveAttribute("data-ids", "1,2,3"); + expect(card).toHaveAttribute("data-return", "/shows/top-rated?page=4"); + }); + + it("returns to the bare list path from page 1", async () => { + read.mockResolvedValue(tvPage(3, 30)); + + await renderGrid(); + + expect(screen.getByText("Show 1")).toHaveAttribute( + "data-return", + "/shows/top-rated", + ); + }); + + it("loads the first 4 posters eagerly and the rest lazily (AC-7)", async () => { + read.mockResolvedValue(tvPage(8, 30)); + + await renderGrid(); + + expect(screen.getByText("Show 4")).toHaveAttribute("data-priority", "true"); + expect(screen.getByText("Show 5")).toHaveAttribute( + "data-priority", + "false", + ); + }); + + it("renders movie cards for a movie list", async () => { + read.mockResolvedValue({ + mediaType: "movie", + paged: { + page: 1, + results: [{ id: 550, title: "Fight Club" }], + totalPages: 1, + totalResults: 1, + }, + }); + + await renderGrid(NOW_PLAYING); + + expect(screen.getByText("Fight Club")).toHaveAttribute( + "data-return", + "/movies/now-playing", + ); + }); + + it("pages with Page N of M, page 1 as the bare path", async () => { + read.mockResolvedValue(tvPage(20, 30)); + + await renderGrid(TOP_RATED, { page: "2" }); + + const nav = screen.getByRole("navigation", { name: "Pagination" }); + expect(within(nav).getByText("Page 2 of 30")).toBeInTheDocument(); + expect(within(nav).getByRole("link", { name: "Previous" })).toHaveAttribute( + "href", + "/shows/top-rated", + ); + expect(within(nav).getByRole("link", { name: "Next" })).toHaveAttribute( + "href", + "/shows/top-rated?page=3", + ); + }); + + it("caps the last page at TMDB's 500", async () => { + read.mockResolvedValue(tvPage(20, 900)); + + await renderGrid(TOP_RATED, { page: "500" }); + + expect(screen.getByText("Page 500 of 500")).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Next" }), + ).not.toBeInTheDocument(); + }); +}); + +describe("the list page states (AC-10)", () => { + it.each([["abc"], ["0"], ["-1"], ["1.5"], ["501"]])( + "says page %s doesn't exist without reading TMDB", + async (page) => { + await renderGrid(TOP_RATED, { page }); + + expect(read).not.toHaveBeenCalled(); + expect(screen.getByText("That page doesn't exist")).toBeInTheDocument(); + expect( + screen.getByText("There are no top rated shows at this page number."), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Back to page 1" }), + ).toHaveAttribute("href", "/shows/top-rated"); + }, + ); + + it("says a repeated page parameter doesn't exist", async () => { + await renderGrid(TOP_RATED, { page: ["1", "2"] }); + + expect(read).not.toHaveBeenCalled(); + expect(screen.getByText("That page doesn't exist")).toBeInTheDocument(); + }); + + it("says a page past the last one doesn't exist, using the list's noun", async () => { + read.mockResolvedValue({ + mediaType: "movie", + paged: { page: 9, results: [], totalPages: 3, totalResults: 60 }, + }); + + await renderGrid(NOW_PLAYING, { page: "9" }); + + expect(screen.getByText("That page doesn't exist")).toBeInTheDocument(); + expect( + screen.getByText("There are no movies now playing at this page number."), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Back to page 1" }), + ).toHaveAttribute("href", "/movies/now-playing"); + expect(screen.queryByRole("navigation")).not.toBeInTheDocument(); + }); + + it("shows the TMDB error with Try again to the current page", async () => { + read.mockRejectedValue( + new TmdbError("upstream", "/tv/top_rated", "503", 503), + ); + + await renderGrid(TOP_RATED, { page: "3" }); + + expect(screen.getByText("Couldn't reach TMDB")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + "/shows/top-rated?page=3", + ); + }); + + it("rethrows a failure that is not a TMDB error", async () => { + read.mockRejectedValue(new TypeError("a bug")); + const slot = gridBoundary().props.children as Element; + const resolve = slot.type as (props: unknown) => Promise; + + await expect(resolve(slot.props)).rejects.toThrow("a bug"); + }); + + it("says the list is empty when page 1 has no titles", async () => { + read.mockResolvedValue(tvPage(0, 0)); + + await renderGrid(); + + expect( + screen.getByText("TMDB lists nothing here right now."), + ).toBeInTheDocument(); + expect( + screen.queryByText("That page doesn't exist"), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("holds the grid's place with 20 card skeletons and the pagination skeleton", () => { + const fallback = gridBoundary().props.fallback as Element; + + const { container } = render(fallback); + + const grid = container.querySelector('[aria-hidden="true"]'); + expect(grid?.querySelectorAll("li")).toHaveLength(20); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); +}); diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx new file mode 100644 index 0000000..8c862f1 --- /dev/null +++ b/components/catalog/catalog-list-page.tsx @@ -0,0 +1,170 @@ +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; +import { Suspense } from "react"; + +import { CatalogListCards } from "@/components/catalog/catalog-list-cards"; +import { PaginationLinks } from "@/components/pagination-links"; +import { PosterGrid } from "@/components/poster-grid"; +import { RetryLink } from "@/components/retry-link"; +import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; +import { StatePanel } from "@/components/state-panel"; +import { ButtonLink } from "@/components/ui/button"; +import { type CatalogList, listPageHref, listPath } from "@/lib/catalog/lists"; +import { CATALOG_PATHS } from "@/lib/catalog/media-type"; +import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages"; +import { + readTaggedCatalogList, + type TaggedCatalogPage, +} from "@/lib/catalog-list-read"; +import { TmdbError } from "@/lib/tmdb"; + +/** TMDB's list page size, which the skeleton mirrors. */ +const PAGE_SIZE = 20; + +/** + * 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 0021, + * AC-7, keeping spec 0006, AC-1). + */ +const EAGER_POSTERS = 4; + +const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const; + +type SearchParams = Promise>; + +/** + * One list's See all page, `/shows/{slug}` or `/movies/{slug}` (spec 0021, + * AC-9, AC-10): a back link to the landing, the list's h1 and note, then the + * paged grid the landing used to be. + * + * The header is static and prerendered; the grid needs the `page` search + * parameter, so it streams behind a skeleton of the same footprint. No + * session is read here: each card's slots read it in their own Suspense + * boundaries (`list-cards.tsx`). + */ +function CatalogListPage({ + list, + searchParams, +}: { + list: CatalogList; + searchParams: SearchParams; +}) { + return ( +
    +
    + +
    + + }> + + +
    + ); +} + +async function ListGrid({ + list, + searchParams, +}: { + list: CatalogList; + searchParams: SearchParams; +}) { + const page = parsePageParam((await searchParams).page); + if (page === null) return ; + + const pageHref = (target: number) => listPageHref(list, target); + + let result: TaggedCatalogPage; + try { + result = await readTaggedCatalogList(list, page); + } catch (error) { + if (!(error instanceof TmdbError)) throw error; + return ( +
    + } + /> +
    + ); + } + + if (page === 1 && result.paged.results.length === 0) { + return ( +
    + +
    + ); + } + + const lastPage = lastReachablePage(result.paged.totalPages); + if (page > lastPage) return ; + + return ( +
    + + + + + +
    + ); +} + +/** Shown for a malformed page number, and for one past the last page. */ +function NoSuchPage({ list }: { list: CatalogList }) { + return ( +
    + + Back to page 1 + + } + /> +
    + ); +} + +function ListGridSkeleton() { + return ( +
    + + +
    + ); +} + +export { CatalogListPage }; diff --git a/components/catalog/list-cards.test.tsx b/components/catalog/list-cards.test.tsx new file mode 100644 index 0000000..8417d96 --- /dev/null +++ b/components/catalog/list-cards.test.tsx @@ -0,0 +1,245 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0021, AC-4, AC-5 + * + * A list card is the full grid card: the personal badge, the TV meta line and + * the controls, each handed the row's ids and the page's return path. The + * slots are replaced by stubs that print what they were given, so the test + * pins the wiring a card on a row or a list page relies on, not the slots' + * own reads (their own tests cover those). + */ +vi.mock("@/components/tracking/show-card-slots", () => ({ + ShowCardBadge: (props: { showId: number; gridShowIds: number[] }) => ( + + badge {props.showId} + + ), + ShowCardMeta: (props: { showId: number; gridShowIds: number[] }) => ( + + meta {props.showId} + + ), + ShowCardControls: (props: { + showId: number; + name: string; + gridShowIds: number[]; + returnPath: string; + }) => ( + + controls {props.name} + + ), +})); +vi.mock("@/components/tracking/movie-card-slots", () => ({ + MovieCardBadge: (props: { + movieId: number; + releaseDate: string | null; + gridMovieIds: number[]; + }) => ( + + badge {props.movieId} + + ), + MovieCardControls: (props: { + movieId: number; + title: string; + releaseDate: string | null; + gridMovieIds: number[]; + returnPath: string; + }) => ( + + controls {props.title} + + ), +})); + +const { MovieListCard, ShowListCard } = await import("./list-cards"); +const { ROW_POSTER_SIZES } = await import("./carousel-row-states"); +const { POSTER_SIZES } = await import("@/components/poster-card"); + +const SHOW = { + id: 1396, + name: "Breaking Bad", + posterUrl: "https://image.tmdb.org/t/p/w500/bb.jpg", + tmdbRating: 8.9, +} as Parameters[0]["show"]; + +const MOVIE = { + id: 550, + title: "Fight Club", + posterUrl: null, + releaseDate: "2027-03-01", + tmdbRating: 8.4, +} as Parameters[0]["movie"]; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("ShowListCard (AC-4)", () => { + it("links the card to the show page", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Breaking Bad" })).toHaveAttribute( + "href", + "/shows/1396", + ); + }); + + it("gives the badge, meta and controls the whole row's ids", () => { + render( + , + ); + + for (const slot of ["badge", "meta", "controls"]) { + expect(screen.getByTestId(slot)).toHaveAttribute("data-ids", "1396,2,3"); + } + }); + + it("sends the controls the return path it was given", () => { + render( + , + ); + + expect(screen.getByTestId("controls")).toHaveAttribute( + "data-return", + "/shows/top-rated?page=3", + ); + }); + + it("uses the row's poster sizes when given, and the grid's otherwise (AC-5)", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("img")).toHaveAttribute( + "sizes", + "(min-width: 768px) 180px, 140px", + ); + + rerender( + , + ); + expect(container.querySelector("img")).toHaveAttribute( + "sizes", + POSTER_SIZES, + ); + }); +}); + +describe("MovieListCard (AC-4)", () => { + it("links the card to the movie page", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Fight Club" })).toHaveAttribute( + "href", + "/movies/550", + ); + }); + + it("passes the release date to the badge and controls, for the release gate", () => { + render( + , + ); + + expect(screen.getByTestId("badge")).toHaveAttribute( + "data-release", + "2027-03-01", + ); + expect(screen.getByTestId("controls")).toHaveAttribute( + "data-release", + "2027-03-01", + ); + expect(screen.getByTestId("controls")).toHaveAttribute( + "data-ids", + "550,13", + ); + expect(screen.getByTestId("controls")).toHaveAttribute( + "data-return", + "/movies/upcoming", + ); + }); + + it("shows the fallback tile when the movie has no poster", () => { + const { container } = render( + , + ); + + expect(container.querySelector("img")).toBeNull(); + expect( + container.querySelector('[data-slot="poster-fallback"]'), + ).toBeInTheDocument(); + }); + + it("has no meta line, unlike a show card", () => { + render( + , + ); + + expect(screen.queryByTestId("meta")).not.toBeInTheDocument(); + }); +}); diff --git a/components/catalog/list-cards.tsx b/components/catalog/list-cards.tsx new file mode 100644 index 0000000..925b038 --- /dev/null +++ b/components/catalog/list-cards.tsx @@ -0,0 +1,129 @@ +import { Suspense } from "react"; + +import { PosterCard } from "@/components/poster-card"; +import { TmdbRatingBadge } from "@/components/rating-badges"; +import { + MovieCardBadge, + MovieCardControls, +} from "@/components/tracking/movie-card-slots"; +import { + ShowCardBadge, + ShowCardControls, + ShowCardMeta, +} from "@/components/tracking/show-card-slots"; +import type { MovieSummary, TvShowSummary } from "@/lib/tmdb"; + +type ListCardProps = { + /** Where a tracking control sends the visitor back to after signing in. */ + returnPath: string; + priority: boolean; + /** The poster `sizes`; left out on a grid, set by a carousel row. */ + sizes?: string; +}; + +/** + * A show's card on a catalog list, a landing row or a list page (spec 0021, + * AC-4): the full `PosterCard` with the personal badge, the meta line and the + * controls, each in its own Suspense boundary so the card never waits on the + * session. + * + * `gridShowIds` is every id of the row or page the card sits in, so the + * slots share one read and a card shows the show exactly as its library page + * does (prompts/catalog-card-status.md). + */ +function ShowListCard({ + show, + gridShowIds, + returnPath, + priority, + sizes, +}: ListCardProps & { + show: TvShowSummary; + gridShowIds: readonly number[]; +}) { + return ( + }> + + + } + meta={ + + + + } + controls={ + + + + } + priority={priority} + sizes={sizes} + /> + ); +} + +/** + * The movie twin of `ShowListCard` (spec 0021, AC-4). The badge and controls + * follow the release gate through `releaseDate`, as on the old landing grid. + */ +function MovieListCard({ + movie, + gridMovieIds, + returnPath, + priority, + sizes, +}: ListCardProps & { + movie: MovieSummary; + gridMovieIds: readonly number[]; +}) { + return ( + }> + + + } + controls={ + + + + } + priority={priority} + sizes={sizes} + /> + ); +} + +export { MovieListCard, ShowListCard }; diff --git a/components/poster-card.tsx b/components/poster-card.tsx index 33b544d..a568d84 100644 --- a/components/poster-card.tsx +++ b/components/poster-card.tsx @@ -38,6 +38,7 @@ function PosterCard({ controls, meta, priority = false, + sizes = POSTER_SIZES, className, }: { title: string; @@ -65,6 +66,11 @@ function PosterCard({ */ meta?: React.ReactNode; priority?: boolean; + /** + * The image `sizes`, `POSTER_SIZES` unless the card sits outside the grid + * at a fixed width, as a carousel row's card does (spec 0021, AC-5). + */ + sizes?: string; className?: string; }) { return ( @@ -75,7 +81,7 @@ function PosterCard({ src={posterUrl} alt="" fill - sizes={POSTER_SIZES} + sizes={sizes} priority={priority} className="object-cover transition-[filter] duration-200 group-hover:brightness-110" /> diff --git a/docs/scope/scope.md b/docs/scope/scope.md index d933d9c..39e16f4 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -37,7 +37,8 @@ _2026-10-06: the `design/` artboards were removed. The UI built from them carrie | 20 | Deploy and provider setup | Slice 8 | done | | 21 | TV history on /watched | Slice 9 | done | | 22 | Media tabs on every page | Slice 9 | done | -| 23 | Progress based library pages | Slice 10 | in progress | +| 23 | Progress based library pages | Slice 10 | done | +| 24 | Home carousels | Slice 11 | done | ## Foundations @@ -380,7 +381,7 @@ Code in `lib/catalog/media-type.ts`, `components/layout/`, `components/library/` ## Slice 10: Progress based pages -### 23. Progress based library pages · in progress · GA +### 23. Progress based library pages · done · GA Statuses are replaced by tracked or not; Stop tracking (with Undo) is the only way off the pages. Every title sits on exactly one page, worked out from what you watched and what TMDB has aired or dated: Watchlist (something aired to watch), Upcoming (a future or unknown date), Watched (caught up, nothing dated). Movies follow the same rule. Settled in a grilling session on 2026-10-07 (supersedes 0015 and parts of 0013, 0014 and 0019). Pause and Drop were removed in a second grilling session on 2026-10-10, and existing holds are cleared (0020 amended in place). A third amendment the same day offers the episode after the furthest watched one, not the first unwatched one. **Done when:** every tracked show and planned movie is on the one page its progress and air dates give, the show page offers a Plan to watch / Tracking toggle with no Pause or Drop, no page writes on load, the rollout keeps the old app working until the contract migration, and `AGENTS.md` describes the new model. - [x] Design it (spec): `/architect progress based library pages` @@ -391,16 +392,32 @@ Statuses are replaced by tracked or not; Stop tracking (with Undo) is the only w - [x] Docs and proof: `AGENTS.md`, checks, 375px and keyboard, timing, `verify.md`, expand push and deploy — AC-23, AC-24 (production check folded into the contract strand) - [x] Contract migration and hold removal, one PR pushed then merged at once, production verified after deploy: [prompts/remove-show-holds.md](../../prompts/remove-show-holds.md) — AC-1 to AC-4, AC-10, AC-21 - [x] Amendment 2026-10-10, next episode after the furthest watched: [prompts/next-after-furthest-watched.md](../../prompts/next-after-furthest-watched.md) — AC-7, AC-8 - - [ ] Verify the amendment in the running app (the plan's manual steps) — AC-7 + - [x] Verify the amendment in the running app (the plan's manual steps) — AC-7 - [x] Verify it: `/check verify progress based library pages` - [x] Test it: `/test progress based library pages` - [x] Review it (fresh model): `/check review progress based library pages` - [x] Document it: `/document progress based library pages` spec [0020](../specs/0020-progress-based-library-pages/index.md) · code in `lib/tv/library-page.ts`, `lib/catalog/movie-page.ts`, `lib/tracking/library-lists.ts`, `components/library/`, `components/tracking/`, `supabase/migrations/20261007120000_show_tracking_expand.sql`, `supabase/migrations/20261010120000_show_tracking_contract.sql` +## Slice 11: Discovery + +### 24. Home carousels · done +`/shows` and `/movies` become rows of sideways scrolling carousels instead of one paged grid of 20. TV rows: Trending this week, Popular, Airing today, On the air, Top rated. Movie rows: Trending this week, Popular, Now playing, Upcoming, Top rated (region `PL`). Each row has a See all link to its own paged list page at `/shows/{slug}` or `/movies/{slug}`. Settled in a grilling session on 2026-10-12. +**Done when:** both landings show their five rows with the full card (badge, meta, controls), each row streams on its own and fails on its own with Retry; every See all page pages like today's grid with a back link; the proxy accepts the known slugs and still 404s anything else; old `?page=N` links redirect (308) to `/…/popular?page=N`; the sitemap lists page 1 of each list. +- [x] Design it (spec): `/architect home carousels` +- [x] Build it: `/develop home carousels` + - [x] TMDB list reads, the list registry and the slug dispatcher (AC-2, AC-3, AC-8, AC-17) + - [x] Tracer bullet: proxy slugs, the shared list page and one `/shows` row end to end, built with no TMDB token (AC-1, AC-4, AC-5, AC-7, AC-9 to AC-13, AC-15) + - [x] All ten lists on both landings, the `?page=N` 308 redirects, and the arrow scroller (AC-1, AC-2, AC-6, AC-14) + - [x] Sitemap list paths, request scope tests and the running app pass at 375px and 1280px (AC-16, AC-17) +- [x] Verify it: `/check verify home carousels` +- [x] Test it: `/test home carousels` +spec [0021](../specs/0021-home-carousels/index.md) · code in `lib/catalog/lists.ts`, `lib/catalog-list-read.ts`, `lib/tmdb/lists.ts`, `components/catalog/`, `app/shows/{slug}/`, `app/movies/{slug}/`, `proxy.ts`, `next.config.ts` + ## Deferred Out of scope for the current build pass, kept so the plan stays honest. - **Public profiles and social features**: ruled out of the MVP by `AGENTS.md` +- **Per visitor region and timezone on the home rows** (from spec 0021): Now playing, Upcoming, Airing today and On the air use the fixed `PL` region and `Europe/Warsaw`. Following the visitor needs its own decision (a request header or an account setting). - **Error monitoring and product analytics**: not selected for this pass - **Self serve account deletion and data export** (from specs 0005 and 0017): GDPR requests are handled by email for now. Undesigned; a `/account` flow needs the Supabase admin API or a security definer function, an elevated call nothing else in the app uses, so it deserves its own decision, including what happens to the cascading tracking data - **Rate limit on sign in and sign up** (from spec 0018): Supabase counts Vercel's addresses, so its auth limit is shared by every visitor ([docs/deploy.md](../deploy.md) section 6). If it bites, add a second Vercel Firewall rule on sign in and sign up posts per IP. (The `/api/search` limit from spec 0010 shipped as a firewall rule in feature 20.) diff --git a/docs/specs/0021-home-carousels/index.md b/docs/specs/0021-home-carousels/index.md new file mode 100644 index 0000000..79df2c8 --- /dev/null +++ b/docs/specs/0021-home-carousels/index.md @@ -0,0 +1,216 @@ +# 0021. Home carousels: rows of TMDB lists on /shows and /movies, each with a paged See all page + +**Date**: 2026-10-12 +**Status**: Accepted + +Scope feature: [24. Home carousels](../../scope/scope.md) · Beta tier + +## Summary + +`/shows` and `/movies` stop being one paged grid of 20 popular titles. Each becomes five rows that scroll sideways (carousels): trending, popular, two date based lists, and top rated, each holding 12 full poster cards with the same badge, meta and controls the grid has today. Every row links to its own See all page (`/shows/popular`, `/movies/now-playing` and so on) that pages exactly like today's grid. Nothing is stored and no schema changes: this is new TMDB reads, ten new public routes, a proxy rule, a redirect for old `?page=N` links, and sitemap entries. + +## Requirements + +**User stories**: + +- As a visitor, I want the shows and movies landings to show several kinds of lists at once (trending, popular, airing, top rated), so that I can find something to watch without paging through one list. +- As a visitor, I want each row to scroll sideways by touch, trackpad, keyboard or arrow buttons, so that I can browse it on any device. +- As a visitor, I want a See all link on each row that opens the whole list with pages, so that I can go deeper into the one list I care about. +- As a signed in user, I want every card in a row to show and change my tracking state exactly as the grid does today, so that I can plan or track straight from the landing. +- As someone with an old `/shows?page=3` bookmark, I want it to keep working, so that my link does not break. + +**Acceptance criteria** (the contract, each independently checkable): + +- **AC-1**: `/shows` shows the h1 `Shows` and, under it, five rows in this order: `Trending this week`, `Popular`, `Airing today`, `On the air`, `Top rated`. `/movies` shows the h1 `Movies` and the rows `Trending this week`, `Popular`, `Now playing`, `Upcoming`, `Top rated`. Each row is a `
    ` labelled by its h2, with a `See all` link at the right of the row header leading to that row's list page (AC-9). The h1 uses today's landing heading style; there is no subtitle line under it. Each row h2 is `text-xl font-semibold tracking-[-0.02em] text-foreground md:text-2xl`; rows are spaced `gap-10`, and the h1 sits `gap-8` above the first row. See all is a plain text link (`text-sm text-muted-foreground hover:text-foreground`, 44px tap target through the `hit-area` utilities) with a lucide `ChevronRight` icon. +- **AC-2**: Each row and its list page read exactly this TMDB source, in TMDB's order: + + | Media | Slug | Row title | TMDB source | + |---|---|---|---| + | TV | `trending` | Trending this week | `/trending/tv/week` | + | TV | `popular` | Popular | `/discover/tv` (today's `discoverTvShows`, unchanged) | + | TV | `airing-today` | Airing today | `/tv/airing_today?timezone=Europe/Warsaw` | + | TV | `on-the-air` | On the air | `/tv/on_the_air?timezone=Europe/Warsaw` | + | TV | `top-rated` | Top rated | `/tv/top_rated` | + | Movie | `trending` | Trending this week | `/trending/movie/week` | + | Movie | `popular` | Popular | `/discover/movie` (today's `discoverMovies`, unchanged) | + | Movie | `now-playing` | Now playing | `/movie/now_playing?region=PL` | + | Movie | `upcoming` | Upcoming | `/movie/upcoming?region=PL` | + | Movie | `top-rated` | Top rated | `/movie/top_rated` | + + Every request also sends `en-US` and `page` as the module already does. No other row sends `region` or `timezone`. +- **AC-3**: A row holds the first 12 titles of TMDB page 1 of its source (fewer when TMDB returns fewer, or after AC-8 drops some). Page 1 of a row's list page is the very same cached read with the same arguments, so opening See all costs no extra TMDB request while the entry is fresh. +- **AC-4**: Each card in a row is the full `PosterCard` the grid uses today: the TMDB badge replaced by the personal state (`ShowCardBadge` or `MovieCardBadge`), the TV meta line (`ShowCardMeta`), and the controls (`ShowCardControls` or `MovieCardControls`), each in its own Suspense boundary with today's fallbacks. The grid ids passed to the slots are that row's ids, so a card shows the title exactly as its library page does (prompts/catalog-card-status.md). `returnPath` is `/shows` or `/movies` on a landing, and the current page's URL on a list page. A missing poster shows today's fallback tile. +- **AC-5**: A row's cards sit in one line that scrolls sideways inside the row, with scroll snap on each card's start edge, `gap-3` between cards (`md:gap-4`, as the cast row), and the scrollbar hidden visually while the row stays scrollable. Cards are 140px wide, 180px from `md`, and their poster `sizes` is `(min-width: 768px) 180px, 140px`. The row never widens the page: at 375px there is no horizontal page scroll. The scroll area is a focusable region (`tabIndex=0`, labelled `{row title}`) with a visible focus ring, so a keyboard user can tab to it and scroll it with the arrow keys; touch and trackpad scroll natively. +- **AC-6**: From `md` up, each row header shows two round icon buttons beside See all, `Scroll {row title} back` and `Scroll {row title} forward`, round icon `Button`s with lucide `ChevronLeft` and `ChevronRight`, each with a 44px tap target. A press scrolls the row by 90% of its visible width, smoothly unless `prefers-reduced-motion: reduce` is set (then instantly). A button is disabled when the row is within 1px of that end (`scrollLeft <= 1`, `scrollLeft + clientWidth >= scrollWidth - 1`). Both start disabled until the first measurement after hydration, and both stay disabled when the row does not overflow, including the AC-11 error and AC-12 empty states. Below `md` the buttons are not shown. Before hydration (or with the client bundle failing) the row still scrolls natively and the buttons are inert. +- **AC-7**: The h1, every row header (h2, See all link, arrow buttons) and the list page headings are part of the prerendered shell. Each row's cards stream in their own Suspense boundary whose fallback is 12 card skeletons at the row's card width, so the page does not shift when a row arrives. Rows read TMDB at request time (`await connection()` before the read), so `pnpm build` makes no TMDB request and passes with no `TMDB_READ_ACCESS_TOKEN` set, as it does in CI today. On both landings the first 6 cards of row 0 load their posters eagerly (`priority`); every other row card is lazy. List pages keep today's first 4 grid cards eager. +- **AC-8**: The trending, airing today, on the air, now playing, upcoming and top rated reads drop every result TMDB flags `adult: true` before it reaches a page, keeping the promise discover keeps with `include_adult=false`. `totalPages` and `totalResults` are passed through unchanged. +- **AC-9**: Each of the ten slugs has a public list page at `/shows/{slug}` or `/movies/{slug}`. It shows a back link `← Shows` or `← Movies` to the landing above an h1, then a one line source note, then a `PosterGrid` of up to 20 full cards (AC-4) and `PaginationLinks` exactly as today's landing grid (`Page N of M`, `M = lastReachablePage(totalPages)`, page 1 is the bare path, page N is `?page=N`). The h1 and note per list: + + | List | h1 | Note | `pageNoun` | + |---|---|---|---| + | TV trending | Trending shows this week | TMDB's most watched shows over the past week. | trending shows | + | TV popular | Popular shows | Ordered by current popularity on TMDB. | popular shows | + | TV airing today | Shows airing today | Shows with an episode airing today, Polish time, per TMDB. | shows airing today | + | TV on the air | Shows on the air | Shows with an episode airing in the next 7 days, per TMDB. | shows on the air | + | TV top rated | Top rated shows | Ordered by TMDB community rating. | top rated shows | + | Movie trending | Trending movies this week | TMDB's most watched movies over the past week. | trending movies | + | Movie popular | Popular movies | Ordered by current popularity on TMDB. | popular movies | + | Movie now playing | Movies now playing | In Polish cinemas now, per TMDB. | movies now playing | + | Movie upcoming | Upcoming movies | Coming soon to Polish cinemas, per TMDB. | upcoming movies | + | Movie top rated | Top rated movies | Ordered by TMDB community rating. | top rated movies | +- **AC-10**: On a list page, a `page` that `parsePageParam` refuses, or one past the last reachable page, shows today's `That page doesn't exist` empty panel (`There are no {pageNoun} at this page number.`, `pageNoun` from the registry, AC-9 table) with `Back to page 1` linking the bare list path. A failed TMDB read shows today's `Couldn't reach TMDB` error panel with `Try again` (`RetryLink`) to the current page URL. A page with zero results on page 1 shows an empty panel titled `This list is empty` with `TMDB lists nothing here right now.` with the back link still above it. While loading, the grid shows 20 card skeletons and the pagination skeleton, as today. +- **AC-11**: When one row's TMDB read fails with a `TmdbError`, only that row changes: in place of its cards it shows a compact error state, `Couldn't load {row title}` with `TMDB didn't respond.` and a `Try again` `RetryLink` to the landing path, reserving the row skeleton's height so nothing shifts. Its header, See all link and every other row render normally. Any error that is not a `TmdbError` is rethrown, as everywhere else. +- **AC-12**: When a row's read succeeds with no titles, its header stays and a single muted line `Nothing here right now.` shows in place of the cards, reserving the row skeleton's height; See all stays. +- **AC-13**: The proxy lets `/shows/{slug}` through for the five TV slugs and `/movies/{slug}` for the five movie slugs, before the numeric id check, and still answers a real 404 for every other non numeric segment: `/shows/upcoming`, `/shows/now-playing`, `/movies/airing-today`, `/movies/on-the-air`, `/shows/Popular`, `/shows/popular.jpg`, and `/shows/popular/season/1`. Numeric ids behave exactly as today. +- **AC-14**: A request to `/shows` or `/movies` carrying a `page` query parameter (any value) answers `308` to `/shows/popular` or `/movies/popular` with the original query string kept (`/shows?page=3` → `/shows/popular?page=3`). Declared in `redirects()` in `next.config.ts` with `permanent: true`, so nothing renders. A browser caches a 308 indefinitely; that is accepted here because `/shows?page=N` has no future meaning (unlike `/`, which stays temporary). A unit test asserts both redirect entries. `/shows` with no `page` renders the landing. +- **AC-15**: Metadata. A landing's title is `Shows` or `Movies`, its canonical is the bare path, and its description is, for `/shows`, `Browse trending, popular, airing and top rated TV shows on BeStats, and keep track of what you watch.` and, for `/movies`, `Browse trending, popular, now playing, upcoming and top rated movies on BeStats, and keep track of what you watch.` A list page's description is its `note`. A list page's title is its h1; page 1's canonical is the bare list path and page N's is `?page=N`. An invalid page, a page past the last, or a failed read is `noindex` with no canonical. List metadata reuses the body's cached read, so it costs no extra TMDB request (the existing `landingPageMetadata` flow, given a thin adapter `({ page }) => readCatalogList(list, page)` as `discover` and `listPageHref` as `pageHref`). +- **AC-16**: The sitemap lists, after `/movies` and `/shows`, the ten list paths at page 1: the five movie lists first, then the five TV lists, each group in row order (AC-1). `sitemapEntries` gains a `listPaths: string[]` argument built from the registry, then the legal pages, then the titles from discover pages 1 to 10 exactly as today. Only bare list paths are listed, never `?page=N`. +- **AC-17**: No new read puts user data in a shared cache: the TMDB list reads are `use cache` scopes with no cookie, header or search param inside them, and the only session reads on these pages are the existing card slots. The new reads live for `hours` on success and take `failureProfile` on failure. The landings and list pages are covered by the request scope test the grid pages have today. + +## Decision + +**Chosen option**: Option 2: one list registry, two new TMDB reads, and static routes per slug sharing one list page component. + +A pure registry (`lib/catalog/lists.ts`) names every slug, its media type, row title, h1, note and paths; the TMDB module gains `getTvList(kind, { page })` and `getMovieList(kind, { page })` for the eight non popular lists while Popular keeps today's discover reads; each slug gets its own small static route that renders a shared list page, and the landings render one shared carousel row per registry entry. + +**Implementation skills**: `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) for keeping the landing's headings in the prerendered shell while each row streams · `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) for checking the rows, arrows, redirect and 404s in the running app. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +**Data model sketch**: no tables, no migration. Nothing about a list is stored. App level additions: + +- `lib/catalog/lists.ts` (pure, no `server-only`, so the proxy and the sitemap can import it): + - `TV_LIST_SLUGS = ["trending", "popular", "airing-today", "on-the-air", "top-rated"] as const`, `MOVIE_LIST_SLUGS = ["trending", "popular", "now-playing", "upcoming", "top-rated"] as const`, in row order. + - `CatalogList = { mediaType: "tv" | "movie"; slug; rowTitle; heading; note; pageNoun }` and `CATALOG_LISTS`, one entry per row, text from AC-1 and AC-9. The AC-12 line is the same on every row, so it lives in `CarouselRowEmpty`, not the registry. + - `listPath(list)` → `/shows/{slug}` or `/movies/{slug}`; `listPageHref(list, page)` → bare path for page 1, `?page=N` beyond. + - `isCatalogListSlug(mediaType, segment)`, an exact, case sensitive match, used by the proxy on the undecoded segment: Next routes `/shows/top%2Drated` to `[id]`, not to the list folder, so an encoded slug is a 404. +- `lib/tmdb/types.ts`: `TvListKind = "trending" | "airing_today" | "on_the_air" | "top_rated"`, `MovieListKind = "trending" | "now_playing" | "upcoming" | "top_rated"`. TMDB's own names, so the module never learns the app's slugs. +- `lib/tmdb/constants.ts`: `TV_LIST_TIMEZONE = "Europe/Warsaw"`, `MOVIE_LIST_REGION = "PL"`. +- `lib/tmdb/lists.ts` (new, server only): `fetchTvList(kind, { page })` and `fetchMovieList(kind, { page })`, the inner halves. Each maps the kind to its endpoint (trending uses `/trending/{tv|movie}/week`), adds `timezone` only for `airing_today` and `on_the_air` and `region` only for `now_playing` and `upcoming`, validates with `pagedSchema(tvListItemSchema)` or `pagedSchema(movieListItemSchema)`, new schemas that extend the summary schemas with `adult` (the summaries do not read it), drops `adult === true` results, normalizes with the existing summary normalizers, and returns `Paged<…>` with TMDB's `page`, `totalPages`, `totalResults` unchanged. +- `lib/tmdb/reads.ts`: `getTvList` and `getMovieList`, the `use cache` wrappers, `cacheLife("hours")` on success, `cacheFailure` on failure. Exported from `lib/tmdb/index.ts` with the two kind types. +- `lib/catalog-list-read.ts` (server side, beside `lib/landing-metadata.ts` for the same reason: it touches TMDB at runtime): `readCatalogList(list, page)` returns `Paged` or `Paged` by dispatching `popular` to `discoverTvShows` / `discoverMovies` and every other slug to `getTvList` / `getMovieList` with the mapped kind. The one place a slug becomes a TMDB call. + +**State transitions**: none. + +**API surface** (pages only, no mutations, no Route Handlers, no Server Actions; all public, no auth): + +| Route | Inputs | Output | Errors | +|---|---|---|---| +| `GET /shows`, `GET /movies` | none (a `page` param never reaches it, AC-14) | h1 + five carousel rows | per row error state (AC-11) | +| `GET /shows?page=…`, `GET /movies?page=…` | `page` | `308` to `/{shows,movies}/popular?page=…` | none | +| `GET /shows/{slug}` ×5, `GET /movies/{slug}` ×5 | `page` (`parsePageParam`) | list page, 20 cards + pagination | no such page panel, TMDB error panel (AC-10) | +| `GET /shows/{other non numeric}`, `GET /movies/{…}` | path | `404` from the proxy | n/a | + +**Routes**: `app/shows/{trending,popular,airing-today,on-the-air,top-rated}/page.tsx` and `app/movies/{trending,popular,now-playing,upcoming,top-rated}/page.tsx`. Each file is a few lines: it picks its registry entry and renders `CatalogListPage` and exports `generateMetadata` through the shared helper. Static segments win over `app/shows/[id]` and `app/movies/[id]`, so no dynamic route has to branch on a slug. `app/shows/page.tsx` and `app/movies/page.tsx` become the landings (no `searchParams`, static metadata). + +**Components** (new unless noted): + +- `components/catalog/carousel-row.tsx` (server): the `
    `, the header (h2, See all `Link`, the arrow buttons), and a Suspense boundary around `CarouselRowItems`, whose fallback is `CarouselRowSkeleton`. +- `CarouselRowItems` (server, same file or a sibling): `await connection()`, `readCatalogList(list, 1)`, catch `TmdbError` into the AC-11 state, the AC-12 empty line, else a `
      ` of up to 12 `PosterCard`s with the slots, keyed by title id. Takes `eagerCount` (6 for the first row, 0 for the rest). +- `components/catalog/carousel-scroller.tsx` (client, `"use client"`): wraps the server rendered `
        ` (passed as children) in the focusable scroll region; owns the two arrow buttons, a scroll listener and a `ResizeObserver` for their disabled state, and `scrollBy` with `behavior` from `matchMedia("(prefers-reduced-motion: reduce)")`. Because the buttons sit in the header and the region in the body, the row renders the scroller around both (header slot plus children) so one client component owns both ends; it holds no data and reads no session. +- `components/catalog/catalog-list-page.tsx` (server): back link, h1, note, then the Suspense wrapped grid that today's `PopularShows` / `PopularMovies` render, generalized over the registry entry (no such page, error, empty, pagination, skeleton). The two media specific card renderers (`ShowListCard`, `MovieListCard`) are extracted from today's landing pages and shared by rows and list pages. +- `components/poster-card.tsx` (changed): a new optional `sizes` prop, defaulting to `POSTER_SIZES`, so the carousel can pass its fixed string. `PosterCardSkeleton` already accepts `className`, which the row uses for the fixed width. + +**Existing tests to rewrite** (not delete): `app/movies/page.test.ts` and `app/movies/request-scope.test.ts` cover the old paged landing; they move to the landing and the list pages (`/movies` and `/movies/popular` at least), and `lib/seo/sitemap` tests gain the list paths. + +**Value sourcing**: + +| Action | Value produced / displayed | Source | +|---|---|---| +| Landing render | Which rows, their order, titles, See all hrefs | `CATALOG_LISTS` filtered by media type, in slug order; `listPath` | +| Row render | Titles in the row | `readCatalogList(list, 1)`, first 12 of `results` | +| Row render | `region=PL` / `timezone=Europe/Warsaw` | `MOVIE_LIST_REGION`, `TV_LIST_TIMEZONE` in `lib/tmdb/constants.ts`, applied by kind in `fetchMovieList` / `fetchTvList` | +| Row render | Card badge, meta, controls | existing slots, `gridShowIds` / `gridMovieIds` = the row's ids | +| Row render | `returnPath` | `/shows` or `/movies` (the landing) | +| Row render | Which cards are `priority` | row index 0 and card index < 6, on both landings | +| Row render | Error and empty text | AC-11 template with `rowTitle`; one AC-12 line in `CarouselRowEmpty` | +| Arrow buttons | Disabled state, scroll distance | the region's `scrollLeft`, `scrollWidth`, `clientWidth` in the browser; 90% of `clientWidth` | +| Arrow buttons | Smooth or instant | `prefers-reduced-motion` media query | +| List page | Page number | `searchParams.page` through `parsePageParam` | +| List page | Titles, last page | `readCatalogList(list, page)`; `lastReachablePage(totalPages)` | +| List page | h1, note, back link | registry `heading`, `note`; `/shows` or `/movies` | +| List page | `returnPath`, Retry href, canonical | `listPageHref(list, page)` | +| Adult filter | Which results are dropped | TMDB's `adult` field on each list result | +| Proxy | Is a segment a known slug | `isCatalogListSlug` from the registry | +| Redirect | Destination and kept query | `next.config.ts` rule with `has: [{ type: "query", key: "page" }]`; Next carries the source query to the destination | +| Sitemap | List URLs | `CATALOG_LISTS` → `listPath`, prefixed with `siteUrl()` | +| Metadata | Title, description, canonical, `noindex` | registry `heading` and `note`; the AC-15 landing strings; `landingPageMetadata` with the `readCatalogList` adapter and `listPageHref` | +| List page | No such page text | registry `pageNoun` | + +**Key invariants**: + +- A slug is defined once, in `lib/catalog/lists.ts`. The routes, proxy, sitemap and landings all read it from there; a test fails if a `app/shows/*` or `app/movies/*` static folder exists with no registry entry, or the reverse. +- The row and page 1 of its list call `readCatalogList(list, 1)` with identical arguments, so they share one cache entry (AC-3). +- Popular reads `discoverTvShows({ page })` / `discoverMovies({ page })` with no other option, the exact calls the sitemap makes, so the sitemap keeps sharing those entries. +- No cached TMDB scope reads a cookie, header or search param; `connection()` is called outside the cached read, in the row component. +- A row never throws a `TmdbError` past its own boundary; any other error is rethrown. +- Region and timezone are applied only to the four kinds named in AC-2. + +**Security model**: everything here is public catalog data with no new write path. The TMDB token stays in `lib/tmdb/env.ts`; pages import only `lib/tmdb/index.ts`. The only user specific reads are the existing card slots in their own Suspense boundaries, never inside a cached scope. The proxy change only widens which public paths render; it adds no private path. `page` is validated by `parsePageParam` before any TMDB call; slugs are matched exactly against a fixed list. + +**Configuration required**: none. No new environment variable, no Vercel or Supabase change. + +**Critical test scenarios**: + +- Happy path: `/shows` renders five rows in AC-1 order with up to 12 cards each, See all on `Airing today` opens `/shows/airing-today` with 20 cards and pagination, and its page 1 matches the row's first 12, verifies **AC-1**, **AC-2**, **AC-3**, **AC-9**. +- Request shape: `fetchMovieList("now_playing")` sends `region=PL` and no timezone, `fetchTvList("airing_today")` sends `timezone=Europe/Warsaw` and no region, `fetchTvList("trending")` hits `/trending/tv/week` with neither; an `adult: true` fixture result is dropped and totals pass through, verifies **AC-2**, **AC-8**. +- One row fails: a fixture where only `top_rated` returns 503 renders four rows and one `Couldn't load Top rated` state with Try again to `/shows`, verifies **AC-11**. +- Empty row: an `airing_today` fixture with no results shows `Nothing here right now.` and See all, verifies **AC-12**. +- Build without a token: `pnpm build` with `TMDB_READ_ACCESS_TOKEN` unset succeeds, verifies **AC-7**. +- Proxy: known slugs pass, the AC-13 list of wrong slugs and casing answers 404, numeric ids unchanged, verifies **AC-13**. +- Redirect: `/movies?page=7` answers 308 to `/movies/popular?page=7`; `/movies?page=abc` answers 308 to `/movies/popular?page=abc`, which shows `That page doesn't exist`, verifies **AC-14**, **AC-10**. +- Arrows: at 1280px the back button starts disabled, forward scrolls the row and enables back, at the end forward disables; at 375px no button shows and the page has no horizontal scroll; with reduced motion the scroll is instant, verifies **AC-5**, **AC-6**. +- Card state: signed in, a tracked show in the `Popular` row shows the same badge and controls as on its library page, and planning a movie from the `Upcoming` row follows the release gate, verifies **AC-4**. +- Metadata and sitemap: `/shows/top-rated?page=2` has canonical `/shows/top-rated?page=2`; `/shows/top-rated?page=999` is `noindex`; the sitemap lists the ten bare list paths after the landings, verifies **AC-15**, **AC-16**. +- Request scope: the landings and list pages read no cookie outside the card slots, verifies **AC-17**. + +## Build plan + +Tracer Bullet: the first slice runs one list (TV `top-rated`) through every layer (TMDB read, registry, proxy, list page, landing row with real cards), then the remaining lists and the polish thicken it. + +1. Add `TvListKind`, `MovieListKind`, the region and timezone constants, `lib/tmdb/lists.ts` (`fetchTvList`, `fetchMovieList` with the adult filter), the cached `getTvList` / `getMovieList` wrappers and their exports, with fixture tests for every kind's endpoint and params and the adult drop, satisfies **AC-2**, **AC-8**, **AC-17**. +2. Add `lib/catalog/lists.ts` (all ten entries, paths, `isCatalogListSlug`) and `lib/catalog-list-read.ts` (`readCatalogList`), with unit tests, satisfies **AC-2**, **AC-3**. +3. Teach the proxy the slugs: check `isCatalogListSlug` before the numeric parser for the `/shows/{x}` and `/movies/{x}` patterns, with `proxy.test.ts` cases for every AC-13 path, satisfies **AC-13**. +4. Extract `ShowListCard` / `MovieListCard` from today's landing pages, add the `sizes` prop to `PosterCard`, and build `CatalogListPage`; add `app/shows/top-rated/page.tsx` with `generateMetadata` through `landingPageMetadata`. Verify `/shows/top-rated` pages, errors and 404s in the running app, satisfies **AC-4**, **AC-9**, **AC-10**, **AC-15**. +5. Build `CarouselRow`, `CarouselRowItems`, the skeleton and the native scroll region (no arrows yet); turn `/shows` into the landing with just the `Top rated` row, h1 `Shows`, static metadata, `connection()` before the read. Run `pnpm build` with no TMDB token. This closes the tracer bullet, satisfies **AC-1**, **AC-4**, **AC-5**, **AC-7**, **AC-11**, **AC-12**. +6. Add the `308` page redirects to `next.config.ts` for both landings, satisfies **AC-14**. +7. Add the remaining four TV routes and rows, then the five movie routes and turn `/movies` into its landing; add the registry and routes consistency test, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-9**. +8. Add `CarouselScroller` (arrows, disabled state, reduced motion, hidden below `md`), with component tests, satisfies **AC-6**. +9. Update `sitemapEntries` and its test for the ten list paths, satisfies **AC-16**. +10. Extend the request scope tests to the landings and the list pages, and add the landing and list pages to the existing `/showcase` only if a carousel row state needs a reference there (error, empty, skeleton), satisfies **AC-17**, **AC-11**, **AC-12**. +11. Verify in the running app at 375px and 1280px, signed out and signed in: every row and list, arrows and keyboard scroll, a forced row failure, the redirect, the 404s, and the sitemap, satisfies **AC-1** to **AC-17**. + +## Consequences + +**Positive**: + +- A visitor sees five kinds of list on arrival instead of one, and every card keeps full tracking controls. +- Each list has a stable, shareable, indexable URL, and old `?page=N` links keep working with a permanent redirect. +- The TMDB module keeps its rule that slugs are app vocabulary and kinds are TMDB vocabulary; one dispatcher joins them. +- Rows fail and load independently, so one slow or failing TMDB list never blanks the landing. + +**Negative / tradeoffs**: + +- A landing now makes five TMDB list reads (cached for hours, Popular for minutes) instead of one, plus up to five card status reads, one per row, where today there is one. For a signed in user that is up to five `cache()`d Supabase reads and TMDB detail reads for tracked shows that appear in several rows (those detail reads are cached and shared). +- The landing loses its fully prerendered grid for a shell plus five request time rows; that matches today's behavior (the grid already streamed per request) but the static shell is smaller than it could be. +- Region `PL` and timezone `Europe/Warsaw` are app wide constants, not per visitor. A visitor elsewhere sees Polish cinema dates and Polish "today". +- The `Upcoming` movie row shares a word with the `/upcoming` library page. They are different things (TMDB's release calendar vs your own planned movies); the row's note and h1 (`Upcoming movies`, `Coming soon to Polish cinemas`) carry the difference. +- Ten static route folders are more files than one dynamic route; the registry consistency test is what keeps them honest. +- A row's Try again reloads the whole landing; the other rows come from cache, so it is cheap, but it is not a per row retry. + +**Neutral**: + +- Amends spec 0006 AC-1 and AC-2 and spec 0009 AC-1 and AC-2 (the landings were one paged grid; the paged grid now lives at `/…/popular`), spec 0016 AC-6 and AC-17 (sitemap entries and landing canonicals), and spec 0004 AC-17 is unchanged (`/` still redirects to `/shows`). +- `/search` browse mode (spec 0010, AC-11) keeps its own discover popular grid; nothing there changes. +- The navbar's media type follows the path prefix, so every list page already gets the right SHOWS | MOVIES tab. +- No migration, no environment variable, no deploy runbook change. + +## Follow-up + +- [ ] Add amendment notes to specs 0006, 0009 and 0016 pointing at this spec when it is accepted (an /architect edit, after the build). +- [ ] `/sync` after the build: `AGENTS.md` "Commands and repo facts" should record the list registry as the one place slugs live, the proxy slug rule beside the malformed id rule, and the `?page` redirect beside the `/` redirect. +- [ ] If a visitor base outside Poland matters later, region and timezone could follow the visitor; that needs its own decision (a header or a setting), not a constant change. diff --git a/docs/specs/0021-home-carousels/rationale.md b/docs/specs/0021-home-carousels/rationale.md new file mode 100644 index 0000000..8b66ce5 --- /dev/null +++ b/docs/specs/0021-home-carousels/rationale.md @@ -0,0 +1,81 @@ +# 0021. Home carousels: rationale + +The decision record behind [index.md](index.md). `/develop` builds from the index; this file explains why. + +## Context + +`/shows` and `/movies` are each one paged grid of 20 titles from TMDB's discover endpoint, sorted by popularity (spec 0006, AC-1 and AC-2; spec 0009, AC-1 and AC-2). A visitor who wants something new this week, something airing tonight, or something well rated has to leave for `/search` or page through popularity. A grilling session on 2026-10-12 settled the shape: five sideways rows per landing, each with a See all page at `/shows/{slug}` or `/movies/{slug}`, the full card on every row, rows that load and fail on their own, old `?page=N` links redirected, and the list pages in the sitemap. + +Several forces shape how to build it. TMDB exposes most of these lists as fixed endpoints (`/trending`, `/tv/airing_today`, `/movie/now_playing` and so on) that take a `page` and, for some, a `region` or `timezone`, but no `include_adult`, unlike discover. The TMDB module (spec 0002) keeps every read as an inner uncached function plus a `use cache` wrapper that returns failures as plain objects, and it never learns app vocabulary such as URL slugs. `app/shows/[id]` and `app/movies/[id]` already own the second path segment, and the proxy answers a non numeric segment there with a real 404 before the page streams. Cache Components is on: a route with no request data is prerendered at build, and CI builds with no TMDB token, where a missing token is a plain `Error` that would fail any prerender that reads TMDB. + +The cards carry per user state. Since prompts/catalog-card-status.md, each grid makes one request scoped read of the user's state for all its ids, keyed by the grid's id list, so a card shows a title as its library page does. Five rows mean five such grids per landing. + +Not deciding leaves the landings as a single popularity list, and leaves the slug, proxy and sitemap rules to be invented route by route. + +## Options considered + +### Option 1: one dynamic segment that branches on slug or id + +Keep `app/shows/[id]` and `app/movies/[id]` as the only second level routes and have each detail page first check whether the segment is a known list slug, rendering the list page if so. + +**Pros**: +- No new route folders; one file per media type. +- The proxy rule stays a single pattern per media type. + +**Cons**: +- The detail page, its `generateMetadata`, its not found handling and its JSON-LD all gain a second mode; two unrelated pages share one file. +- The segment parser stops being "a TMDB id", which the proxy and `parseTmdbId` rely on. +- Static params and prefetch behavior differ for the two modes in one route. + +### Option 2: a list registry, two new TMDB reads, and one static route per slug (chosen) + +A pure registry of the ten lists; `getTvList` / `getMovieList` in the TMDB module keyed by TMDB's own list names; Popular stays on discover; ten tiny static routes rendering one shared list page; the landings render one carousel row per registry entry. + +**Pros**: +- Static segments beat `[id]` in Next's router, so detail pages are untouched. +- One registry feeds routes, proxy, sitemap and landings, so a slug cannot drift between them. +- The TMDB module keeps TMDB vocabulary; the slug to read mapping is one small server function. +- Popular stays the exact discover call the sitemap makes, so old `?page=N` links show the same titles and cache entries stay shared. + +**Cons**: +- Ten route files and a consistency test to keep them matched to the registry. +- Two read shapes (discover for Popular, list endpoints for the rest) behind one dispatcher. + +### Option 3: one `[list]` route under a prefix such as `/shows/lists/{slug}` + +A single dynamic route under a new prefix, with the slug validated in the page. + +**Pros**: +- One route file per media type, no clash with `[id]`. + +**Cons**: +- The URLs the grilling session settled (`/shows/{slug}`) change. +- A dynamic route streams before it can decide the slug is unknown, so an unknown slug is a soft 404 unless the proxy learns it too, which brings back the registry anyway. + +### Option 4: all lists through discover + +Approximate every row with discover parameters (date ranges for airing and now playing, `sort_by=vote_average.desc` for top rated, popularity for trending). + +**Pros**: +- One read function, `include_adult=false` everywhere, no new endpoint code. + +**Cons**: +- Trending has no discover equivalent; it would silently become popularity. +- Airing today and now playing need date windows the app would compute, duplicating TMDB's own definitions and drifting from them. +- Top rated by discover needs a vote count floor the app invents; TMDB's list already applies one. + +## Rationale + +Option 2 keeps every existing contract intact: the detail routes, `parseTmdbId`, the proxy's real 404 rule, the TMDB module's split and vocabulary, and the sitemap's shared discover reads. The cost is ten very small route files, and a registry test makes that cost safe. Option 1 would muddle the most important pages in the app to save those files, and Option 3 gives up the URLs already agreed. Option 4 fakes lists TMDB already defines, and trending cannot be faked at all. + +Popular stays on discover because the old `?page=N` links redirect to `/…/popular?page=N`: keeping the same read means the redirected page shows the same titles the bookmark showed, and the sitemap's ten discover pages per type stay one cache entry each instead of becoming a second parallel set. `/movie/popular` and `/tv/popular` order nearly identically, so nothing visible is lost. + +Region `PL` goes only on now playing and upcoming because those two lists are defined by local release dates; popular, top rated and trending are global by nature, and a region on them would make Popular stop matching discover. `Europe/Warsaw` on airing today and on the air matches that choice, so "today" on the TV rows is the same day the movie rows assume. Both are constants in the TMDB module, where every other request constant lives. + +The list endpoints take no `include_adult`, so the inner reads drop results flagged adult. Discover already promises no adult titles on public grids; the new lists should not quietly break that promise, and the detail page's adult check would otherwise show a card that leads to a 404. + +Rows read at request time (`connection()` before the read) because the alternative, prerendering rows at build, makes every build depend on a TMDB token. CI builds without one today, and a missing token is a plain `Error` that would fail the prerender. Request time rows also match what the grid does today (it streamed per request because of `searchParams`), and the h1 and every row header still land in the prerendered shell. The new reads use `hours` because these lists change about once a day and a landing now makes five reads; Popular keeps discover's `minutes`, shared with `/search` browse mode and the sitemap. + +Twelve cards per row keeps the row short enough that See all does the deep browsing, while still being the first 12 of TMDB page 1, so the row and page 1 of its list share one cache entry. Fixed 140px and 180px cards echo the cast row's 120px and 140px, show about two and a half cards on a phone (the cut card tells you the row scrolls), and give `next/image` one simple `sizes` string. Native scroll with snap is the base layer, so touch, trackpad and keyboard work with no JavaScript; the arrow buttons from `md` up are a small Client Component on top, because a mouse user otherwise has no comfortable way to scroll sideways. An empty row keeps its header and says so, so the landing keeps a stable layout and never implies a failure that did not happen. + +Per row card status reads (one `cache()`d read per row's id list) were kept over one merged read for the whole landing: the rows stream independently, a merged key would need every row's ids before any card could resolve, and the existing slot components already take a grid id list. A title in two rows costs a second small Supabase read, not a second TMDB detail read, since TMDB details are cached. diff --git a/docs/specs/0021-home-carousels/verify.md b/docs/specs/0021-home-carousels/verify.md new file mode 100644 index 0000000..dfe6964 --- /dev/null +++ b/docs/specs/0021-home-carousels/verify.md @@ -0,0 +1,40 @@ +# Verify: Home carousels · spec 0021 · updated 2026-10-12 +_Steps derived from spec 0021 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +## UI / manual +- [x] Open `/shows` at 1280px. You should see the h1 `Shows`, then rows in this order: Trending this week, Popular, Airing today, On the air, Top rated. Each has See all and two arrow buttons → AC-1 +- [x] Open `/movies` at 1280px. You should see the rows Trending this week, Popular, Now playing, Upcoming, Top rated → AC-1 +- [x] Count the cards in any row. There should be at most 12, in the same order as the first 12 on that row's See all page → AC-3 +- [x] Watch the network panel while you open See all on a row you just loaded. No new TMDB request should be made for page 1 → AC-3 +- [x] On a row, check that back starts disabled. Press forward: the row scrolls about 90% of its width, back turns on, and at the end forward turns off → AC-6 +- [x] Turn on reduced motion in the OS or DevTools and press an arrow. The row should jump with no smooth scroll → AC-6 +- [x] At 375px, check that no arrow buttons show and the page has no sideways scroll. The rows still swipe → AC-5, AC-6 +- [x] Tab to a row's card area. A focus ring should show, and the arrow keys should scroll it → AC-5 +- [x] Throttle the network and reload `/shows`. The h1 and every row header should show at once, with 12 skeleton cards per row and no shift when cards arrive → AC-7 +- [x] Check that the first 6 posters of the first row have no `loading="lazy"`, and every other row poster does → AC-7 +- [x] Block one list request, for example `api.themoviedb.org/3/tv/top_rated`, and reload. Only that row should show `Couldn't load Top rated` with Try again to `/shows`. The other rows are normal and nothing shifts → AC-11 +- [x] Use a fixture or a blocked response with empty results for one row. It should show `Nothing here right now.` and keep See all → AC-12 +- [x] Open every See all page (`/shows/trending`, `/shows/popular`, `/shows/airing-today`, `/shows/on-the-air`, `/shows/top-rated`, `/movies/trending`, `/movies/popular`, `/movies/now-playing`, `/movies/upcoming`, `/movies/top-rated`). Each shows `← Shows` or `← Movies`, its h1 and note from AC-9, 20 cards, and `Page 1 of N` → AC-9 +- [x] Open `/shows/top-rated?page=abc` and `/shows/top-rated?page=600`. Both should show `That page doesn't exist` with `Back to page 1` linking `/shows/top-rated` → AC-10 +- [x] Sign in (run `pnpm dev:docker`, seed user `user-a@example.test`). Track a show from the Popular row, then check its badge and controls match its library page → AC-4 +- [x] Signed in, plan an unreleased movie from the Upcoming row. It should follow the release gate (plan only, no watched mark) → AC-4 +- [x] Find a movie with no poster in any row. It should show the fallback tile at the same size → AC-4 + +## Commands +- [x] `curl -sI localhost:3000/shows?page=3` → `308`, `location: /shows/popular?page=3` → AC-14 +- [x] `curl -sI "localhost:3000/movies?page=abc"` → `308` to `/movies/popular?page=abc`, which then shows `That page doesn't exist` → AC-14, AC-10 +- [x] `curl -s -o /dev/null -w "%{http_code}" localhost:3000/shows/upcoming` → `404`; the same for `/shows/now-playing`, `/movies/airing-today`, `/movies/on-the-air`, `/shows/Popular`, `/shows/popular.jpg`, `/shows/popular/season/1` → AC-13 +- [x] `curl -s localhost:3000/shows/top-rated?page=2 | grep canonical` → canonical `/shows/top-rated?page=2`; `?page=999` → `noindex` → AC-15 +- [x] `curl -s localhost:3000/shows | grep -o '[^<]*\|canonical[^>]*'` → title `Shows`, canonical `/shows` → AC-15 +- [x] With `VERCEL_ENV=production` and `NEXT_PUBLIC_SITE_URL` set, read `/sitemap.xml`. After `/movies` and `/shows` come the five movie list paths, then the five TV list paths, with no `?page=` → AC-16 +- [x] `TMDB_READ_ACCESS_TOKEN= pnpm build` → passes, every list route shows as Partial Prerender → AC-7 +- [x] `pnpm test` → `lib/tmdb/lists.test.ts` (endpoints, region, timezone, adult drop), `lib/catalog/lists.test.ts`, `lib/catalog-list-read.test.ts`, `app/catalog-lists.test.ts` and `app/movies/request-scope.test.ts` pass → AC-2, AC-8, AC-17 + +## Value sourcing +- [x] `region=PL`: in the network log, `/movie/now_playing` and `/movie/upcoming` carry `region=PL`; `/movie/top_rated` and `/trending/movie/week` carry none → AC-2 +- [ ] `timezone=Europe/Warsaw`: `/tv/airing_today` and `/tv/on_the_air` carry it; no other TV list does. Near midnight UTC the Airing today row should follow Warsaw's date, not UTC's → AC-2 +- [ ] `returnPath`: signed out, press a row card's bookmark. Sign in should send you back to `/shows` (a landing) or to the exact list page URL with its `?page=N` → AC-4 +- [x] Popular shares the sitemap's cache: `/shows/popular` page 1 and the sitemap's discover page 1 make one TMDB request between them while fresh → AC-3 + +## Acceptance-criteria coverage +- AC-1 … landing steps · AC-2 … `lists.test.ts`, value sourcing · AC-3 … row count and network · AC-4 … signed in steps · AC-5 … 375px, focus · AC-6 … arrows, reduced motion · AC-7 … skeletons, eager posters, tokenless build · AC-8 … `lists.test.ts` · AC-9 … list pages · AC-10 … bad page · AC-11 … blocked row · AC-12 … empty row · AC-13 … curl 404s · AC-14 … curl 308s · AC-15 … metadata curls · AC-16 … sitemap · AC-17 … request scope test diff --git a/lib/catalog-list-read.test.ts b/lib/catalog-list-read.test.ts new file mode 100644 index 0000000..c540786 --- /dev/null +++ b/lib/catalog-list-read.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0021, AC-2, AC-3 + * + * Popular stays on discover with only a page, the call the sitemap shares; + * every other slug reads its TMDB list kind. The reads are replaced, so this + * checks only which call each slug makes. + */ +const tmdb = vi.hoisted(() => ({ + discoverTvShows: vi.fn(), + discoverMovies: vi.fn(), + getTvList: vi.fn(), + getMovieList: vi.fn(), +})); + +vi.mock("@/lib/tmdb", () => tmdb); + +const { catalogList } = await import("@/lib/catalog/lists"); +const { readCatalogList } = await import("./catalog-list-read"); + +afterEach(() => { + for (const fn of Object.values(tmdb)) fn.mockReset(); +}); + +describe("readCatalogList", () => { + it("reads TV popular through discover with only the page", async () => { + await readCatalogList(catalogList("tv", "popular"), 3); + + expect(tmdb.discoverTvShows).toHaveBeenCalledWith({ page: 3 }); + expect(tmdb.getTvList).not.toHaveBeenCalled(); + }); + + it("reads movie popular through discover with only the page", async () => { + await readCatalogList(catalogList("movie", "popular"), 1); + + expect(tmdb.discoverMovies).toHaveBeenCalledWith({ page: 1 }); + }); + + it.each([ + ["trending", "trending"], + ["airing-today", "airing_today"], + ["on-the-air", "on_the_air"], + ["top-rated", "top_rated"], + ] as const)("maps the TV slug %s to the kind %s", async (slug, kind) => { + await readCatalogList(catalogList("tv", slug), 2); + + expect(tmdb.getTvList).toHaveBeenCalledWith(kind, { page: 2 }); + expect(tmdb.discoverTvShows).not.toHaveBeenCalled(); + }); + + it.each([ + ["trending", "trending"], + ["now-playing", "now_playing"], + ["upcoming", "upcoming"], + ["top-rated", "top_rated"], + ] as const)("maps the movie slug %s to the kind %s", async (slug, kind) => { + await readCatalogList(catalogList("movie", slug), 1); + + expect(tmdb.getMovieList).toHaveBeenCalledWith(kind, { page: 1 }); + }); +}); diff --git a/lib/catalog-list-read.ts b/lib/catalog-list-read.ts new file mode 100644 index 0000000..90a213e --- /dev/null +++ b/lib/catalog-list-read.ts @@ -0,0 +1,94 @@ +import type { + CatalogList, + MovieCatalogList, + MovieListSlug, + TvCatalogList, + TvListSlug, +} from "@/lib/catalog/lists"; +import { + discoverMovies, + discoverTvShows, + getMovieList, + getTvList, + type MovieListKind, + type MovieSummary, + type Paged, + type TvListKind, + type TvShowSummary, +} from "@/lib/tmdb"; + +const TV_KINDS: Record<Exclude<TvListSlug, "popular">, TvListKind> = { + trending: "trending", + "airing-today": "airing_today", + "on-the-air": "on_the_air", + "top-rated": "top_rated", +}; + +const MOVIE_KINDS: Record<Exclude<MovieListSlug, "popular">, MovieListKind> = { + trending: "trending", + "now-playing": "now_playing", + upcoming: "upcoming", + "top-rated": "top_rated", +}; + +/** + * One page of a list, the one place an app slug becomes a TMDB call + * (spec 0021, the dispatcher). + * + * Popular reads `discoverTvShows({ page })` or `discoverMovies({ page })` with + * no other option, the exact calls the sitemap makes, so the three share + * cache entries. Every other slug maps to its TMDB list kind. A landing row + * and page 1 of its list page both call this with page 1, so they share one + * cache entry and See all costs no extra request (AC-3). + * + * It lives beside `lib/landing-metadata.ts`, outside `lib/catalog/`, because + * it touches TMDB at runtime and `lib/catalog/` stays pure for the proxy. + * + * @throws {TmdbError} When TMDB fails; callers catch it into their own state. + */ +export function readCatalogList( + list: TvCatalogList, + page: number, +): Promise<Paged<TvShowSummary>>; +export function readCatalogList( + list: MovieCatalogList, + page: number, +): Promise<Paged<MovieSummary>>; +export function readCatalogList( + list: CatalogList, + page: number, +): Promise<Paged<TvShowSummary> | Paged<MovieSummary>>; +export function readCatalogList( + list: CatalogList, + page: number, +): Promise<Paged<TvShowSummary> | Paged<MovieSummary>> { + if (list.mediaType === "tv") { + return list.slug === "popular" + ? discoverTvShows({ page }) + : getTvList(TV_KINDS[list.slug], { page }); + } + return list.slug === "popular" + ? discoverMovies({ page }) + : getMovieList(MOVIE_KINDS[list.slug], { page }); +} + +/** One page of a list, tagged with its media type so a renderer can narrow it. */ +export type TaggedCatalogPage = + | { mediaType: "tv"; paged: Paged<TvShowSummary> } + | { mediaType: "movie"; paged: Paged<MovieSummary> }; + +/** + * `readCatalogList` with its result tagged, so a component rendering any list + * picks the show or movie card by narrowing on `mediaType` rather than by a + * cast. The very same call underneath, so it shares the cache entry. + * + * @throws {TmdbError} When TMDB fails. + */ +export async function readTaggedCatalogList( + list: CatalogList, + page: number, +): Promise<TaggedCatalogPage> { + return list.mediaType === "tv" + ? { mediaType: "tv", paged: await readCatalogList(list, page) } + : { mediaType: "movie", paged: await readCatalogList(list, page) }; +} diff --git a/lib/catalog/lists.test.ts b/lib/catalog/lists.test.ts new file mode 100644 index 0000000..6b03182 --- /dev/null +++ b/lib/catalog/lists.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + CATALOG_LISTS, + catalogList, + catalogListsFor, + isCatalogListSlug, + listPageHref, + listPath, + MOVIE_LIST_SLUGS, + TV_LIST_SLUGS, +} from "./lists"; + +/** covers: spec 0021, AC-1, AC-9, AC-13 */ +describe("the list registry", () => { + it("holds the five TV rows in AC-1 order", () => { + expect(catalogListsFor("tv").map((list) => list.rowTitle)).toEqual([ + "Trending this week", + "Popular", + "Airing today", + "On the air", + "Top rated", + ]); + expect(catalogListsFor("tv").map((list) => list.slug)).toEqual([ + ...TV_LIST_SLUGS, + ]); + }); + + it("holds the five movie rows in AC-1 order", () => { + expect(catalogListsFor("movie").map((list) => list.rowTitle)).toEqual([ + "Trending this week", + "Popular", + "Now playing", + "Upcoming", + "Top rated", + ]); + expect(catalogListsFor("movie").map((list) => list.slug)).toEqual([ + ...MOVIE_LIST_SLUGS, + ]); + }); + + it("names every list once", () => { + const keys = CATALOG_LISTS.map((list) => `${list.mediaType}/${list.slug}`); + expect(new Set(keys).size).toBe(10); + }); + + it("finds an entry by media type and slug", () => { + expect(catalogList("movie", "now-playing").heading).toBe( + "Movies now playing", + ); + expect(catalogList("tv", "airing-today").pageNoun).toBe( + "shows airing today", + ); + }); +}); + +describe("list URLs (AC-9)", () => { + it("puts a list under its catalog", () => { + expect(listPath(catalogList("tv", "top-rated"))).toBe("/shows/top-rated"); + expect(listPath(catalogList("movie", "upcoming"))).toBe("/movies/upcoming"); + }); + + it("writes page 1 as the bare path and page N as ?page=N", () => { + expect(listPageHref(catalogList("tv", "popular"), 1)).toBe( + "/shows/popular", + ); + expect(listPageHref(catalogList("movie", "trending"), 4)).toBe( + "/movies/trending?page=4", + ); + }); +}); + +describe("isCatalogListSlug (AC-13)", () => { + it.each([ + ["tv", "trending"], + ["tv", "airing-today"], + ["tv", "on-the-air"], + ["movie", "now-playing"], + ["movie", "upcoming"], + ] as const)("accepts %s %s", (mediaType, slug) => { + expect(isCatalogListSlug(mediaType, slug)).toBe(true); + }); + + it.each([ + ["tv", "upcoming"], + ["tv", "now-playing"], + ["movie", "airing-today"], + ["movie", "on-the-air"], + ["tv", "Popular"], + ["tv", "popular.jpg"], + ["tv", "1396"], + ["tv", ""], + ] as const)("refuses %s %s", (mediaType, segment) => { + expect(isCatalogListSlug(mediaType, segment)).toBe(false); + }); +}); diff --git a/lib/catalog/lists.ts b/lib/catalog/lists.ts new file mode 100644 index 0000000..6bbace9 --- /dev/null +++ b/lib/catalog/lists.ts @@ -0,0 +1,193 @@ +import { CATALOG_PATHS, type MediaType } from "./media-type"; + +/** + * The TV lists a landing row and its list page show, in row order (spec 0021, + * AC-1). Each is a static route folder under `app/shows/`; a test fails if a + * folder and this list disagree. + */ +export const TV_LIST_SLUGS = [ + "trending", + "popular", + "airing-today", + "on-the-air", + "top-rated", +] as const; + +/** The movie twin of `TV_LIST_SLUGS`, under `app/movies/`. */ +export const MOVIE_LIST_SLUGS = [ + "trending", + "popular", + "now-playing", + "upcoming", + "top-rated", +] as const; + +export type TvListSlug = (typeof TV_LIST_SLUGS)[number]; +export type MovieListSlug = (typeof MOVIE_LIST_SLUGS)[number]; + +/** The words every list shows, wherever it is rendered (spec 0021, AC-1, AC-9). */ +type ListText = { + /** The row's h2 on a landing. */ + rowTitle: string; + /** The list page's h1, and its title. */ + heading: string; + /** The one line under the h1, and the page's description. */ + note: string; + /** Completes "There are no {pageNoun} at this page number." */ + pageNoun: string; +}; + +export type TvCatalogList = ListText & { mediaType: "tv"; slug: TvListSlug }; +export type MovieCatalogList = ListText & { + mediaType: "movie"; + slug: MovieListSlug; +}; +export type CatalogList = TvCatalogList | MovieCatalogList; + +/** + * Every list, once (spec 0021, the registry). The routes, the proxy, the + * sitemap and the landings all read slugs and words from here, so a slug is + * written in exactly one place. Pure and free of `server-only`, so the proxy + * and the sitemap can import it. + */ +export const CATALOG_LISTS: readonly CatalogList[] = [ + { + mediaType: "tv", + slug: "trending", + rowTitle: "Trending this week", + heading: "Trending shows this week", + note: "TMDB's most watched shows over the past week.", + pageNoun: "trending shows", + }, + { + mediaType: "tv", + slug: "popular", + rowTitle: "Popular", + heading: "Popular shows", + note: "Ordered by current popularity on TMDB.", + pageNoun: "popular shows", + }, + { + mediaType: "tv", + slug: "airing-today", + rowTitle: "Airing today", + heading: "Shows airing today", + note: "Shows with an episode airing today, Polish time, per TMDB.", + pageNoun: "shows airing today", + }, + { + mediaType: "tv", + slug: "on-the-air", + rowTitle: "On the air", + heading: "Shows on the air", + note: "Shows with an episode airing in the next 7 days, per TMDB.", + pageNoun: "shows on the air", + }, + { + mediaType: "tv", + slug: "top-rated", + rowTitle: "Top rated", + heading: "Top rated shows", + note: "Ordered by TMDB community rating.", + pageNoun: "top rated shows", + }, + { + mediaType: "movie", + slug: "trending", + rowTitle: "Trending this week", + heading: "Trending movies this week", + note: "TMDB's most watched movies over the past week.", + pageNoun: "trending movies", + }, + { + mediaType: "movie", + slug: "popular", + rowTitle: "Popular", + heading: "Popular movies", + note: "Ordered by current popularity on TMDB.", + pageNoun: "popular movies", + }, + { + mediaType: "movie", + slug: "now-playing", + rowTitle: "Now playing", + heading: "Movies now playing", + note: "In Polish cinemas now, per TMDB.", + pageNoun: "movies now playing", + }, + { + mediaType: "movie", + slug: "upcoming", + rowTitle: "Upcoming", + heading: "Upcoming movies", + note: "Coming soon to Polish cinemas, per TMDB.", + pageNoun: "upcoming movies", + }, + { + mediaType: "movie", + slug: "top-rated", + rowTitle: "Top rated", + heading: "Top rated movies", + note: "Ordered by TMDB community rating.", + pageNoun: "top rated movies", + }, +]; + +/** One media type's lists, in row order (spec 0021, AC-1). */ +export function catalogListsFor(mediaType: "tv"): TvCatalogList[]; +export function catalogListsFor(mediaType: "movie"): MovieCatalogList[]; +export function catalogListsFor(mediaType: MediaType): CatalogList[]; +export function catalogListsFor(mediaType: MediaType): CatalogList[] { + return CATALOG_LISTS.filter((list) => list.mediaType === mediaType); +} + +/** + * The registry entry a route renders. Typed so a route can only name a slug + * its media type has; the throw is unreachable from typed code. + */ +export function catalogList(mediaType: "tv", slug: TvListSlug): TvCatalogList; +export function catalogList( + mediaType: "movie", + slug: MovieListSlug, +): MovieCatalogList; +export function catalogList(mediaType: MediaType, slug: string): CatalogList { + const list = CATALOG_LISTS.find( + (entry) => entry.mediaType === mediaType && entry.slug === slug, + ); + if (!list) throw new Error(`No ${mediaType} list named ${slug}`); + return list; +} + +/** + * A list's page: `/shows/{slug}` or `/movies/{slug}` (spec 0021, AC-9). Takes + * the registry entry, not a media type and slug, so a caller cannot pair a + * slug with the wrong catalog. + */ +export function listPath(list: CatalogList): string { + return `${CATALOG_PATHS[list.mediaType]}/${list.slug}`; +} + +/** + * One URL per page of a list: page 1 is the bare path, page N is `?page=N`, + * as the old landing grid was (spec 0021, AC-9, AC-15). The pagination links, + * the canonical, the Retry link and each card's `returnPath` all use it. + */ +export function listPageHref(list: CatalogList, page: number): string { + const path = listPath(list); + return page === 1 ? path : `${path}?page=${page}`; +} + +/** + * True for a path segment that names one of this media type's lists, matched + * exactly and case sensitively, so `/shows/Popular` and `/shows/upcoming` + * stay 404s (spec 0021, AC-13). The proxy checks it before the numeric id + * parser. + */ +export function isCatalogListSlug( + mediaType: MediaType, + segment: string, +): boolean { + return CATALOG_LISTS.some( + (list) => list.mediaType === mediaType && list.slug === segment, + ); +} diff --git a/lib/landing-metadata.ts b/lib/landing-metadata.ts index bc163f9..b4e1431 100644 --- a/lib/landing-metadata.ts +++ b/lib/landing-metadata.ts @@ -1,13 +1,15 @@ import type { Metadata } from "next"; - +import { type CatalogList, listPageHref } from "@/lib/catalog/lists"; import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages"; +import { readCatalogList } from "@/lib/catalog-list-read"; import { landingMetadata } from "@/lib/seo/metadata"; import { TmdbError } from "@/lib/tmdb"; /** - * The shared `generateMetadata` flow of the `/movies` and `/shows` landings - * (spec 0016, AC-17): parse `page`, read the same cached discover call the - * page body makes, and index the page only when it shows results. + * The shared `generateMetadata` flow of a paged catalog list (spec 0016, + * AC-17; since spec 0021 the list pages, through `catalogListMetadata` + * below): parse `page`, read the same cached call the page body makes, and + * index the page only when it shows results. * * It lives outside `lib/seo/` because it touches TMDB at runtime (it catches * `TmdbError`), and `lib/seo/AGENTS.md` keeps that folder free of TMDB @@ -46,3 +48,22 @@ export async function landingPageMetadata({ return noindex(); } } + +/** + * A list page's metadata (spec 0021, AC-15): its h1 as the title, its note as + * the description, and page N's own canonical. The `landingPageMetadata` flow + * with `readCatalogList` as the read, the very call the page body makes, so + * metadata costs no extra TMDB request. + */ +export async function catalogListMetadata( + list: CatalogList, + searchParams: Promise<Record<string, string | string[] | undefined>>, +): Promise<Metadata> { + return landingPageMetadata({ + pageParam: (await searchParams).page, + discover: ({ page }) => readCatalogList(list, page), + title: list.heading, + description: list.note, + pageHref: (page) => listPageHref(list, page), + }); +} diff --git a/lib/seo/AGENTS.md b/lib/seo/AGENTS.md index 095f587..6183e6a 100644 --- a/lib/seo/AGENTS.md +++ b/lib/seo/AGENTS.md @@ -5,7 +5,7 @@ How BeStats presents itself to search engines and link previews. Governed by [sp ## Files - `site.ts`: `siteUrl()` (the http(s) origin from `NEXT_PUBLIC_SITE_URL`, or null), `absoluteUrl()` (that origin plus a path, or null) and `isIndexableDeployment()` (true only with `VERCEL_ENV=production` and a valid site URL). -- `metadata.ts`: `catalogMetadata()` for every public catalog page, `landingMetadata()` for `/movies` and `/shows` (called through `lib/landing-metadata.ts`, which sits outside this folder because it catches `TmdbError`), `shareMetadata()` for the Open Graph and X fields every page shares, `SITE_NAME` and `siteCardUrl()`. +- `metadata.ts`: `catalogMetadata()` for every public catalog page, `landingMetadata()` for `/movies`, `/shows` and their list pages (called through `lib/landing-metadata.ts`, which sits outside this folder because it catches `TmdbError`), `shareMetadata()` for the Open Graph and X fields every page shares, `SITE_NAME` and `siteCardUrl()`. - `json-ld.ts`: `movieJsonLd()`, `showJsonLd()` and `jsonLdScript()`. Rendered by `components/catalog/json-ld.tsx`. - `sitemap.ts`: `sitemapEntries()` and `DISCOVER_PAGES_PER_TYPE`, the pure half of `app/sitemap.ts`. - `theme-tokens.ts`: `rootTokens()`, which reads colours from the `:root` block of `app/globals.css` for `app/opengraph-image.tsx`. @@ -18,11 +18,11 @@ How BeStats presents itself to search engines and link previews. Governed by [sp - `siteUrl()` reads only its one variable, not `getPublicEnv()`, so a missing Supabase key never switches SEO off. It never throws. - Build every URL (canonical, `og:url`, share image, sitemap entry, JSON-LD `url`) as an absolute string from `siteUrl()`. When it is null, leave the field out. Never rely on `metadataBase` to resolve a relative path, because that fails the build without a site URL. - A new public page builds its metadata through `catalogMetadata()`, or through `shareMetadata()` when it has no canonical (as `/search` does). Both set `openGraph.images` explicitly every time, because Next merges metadata shallowly and a page that sets `openGraph` otherwise loses the root site card. -- Not found and failed branches are `noindex` with no canonical and no Open Graph. Canonicals are built from the id, never from the request URL, so a query string never reaches them. The only query a canonical may carry is a landing's `?page=N`. +- Not found and failed branches are `noindex` with no canonical and no Open Graph. Canonicals are built from the id, never from the request URL, so a query string never reaches them. The only query a canonical may carry is a list page's `?page=N` (spec 0021); the `/movies` and `/shows` landings are not paged. - Leave out any field whose TMDB value is null, empty or zero. Never emit a rating of any kind in metadata or JSON-LD. - Serialize JSON-LD only through `jsonLdScript()`. It escapes `<`, U+2028 and U+2029, so TMDB text cannot close the script tag. Render it only in a page's found branch. - `/robots.txt`, `/sitemap.xml` and `/opengraph-image` are prerendered, so `VERCEL_ENV` and `NEXT_PUBLIC_SITE_URL` are read when `pnpm build` runs, not per request. To check the indexable branch locally, build with `VERCEL_ENV=production` and a site URL set. -- The sitemap reads discover pages with the exact calls the landings make, so they share cache entries and every listed title is linked from a public page. Its `cacheLife` is chosen once, after all reads settle: `days` when all succeed, `minutes` when any fail. +- The sitemap reads discover pages with the exact calls `/movies/popular` and `/shows/popular` make (page 1 is also each landing's Popular row), so they share cache entries and every listed title is linked from a public page. Its `cacheLife` is chosen once, after all reads settle: `days` when all succeed, `minutes` when any fail. - `app/opengraph-image.tsx` holds no colour literal (`design-tokens-boundary.test.ts`). It reads tokens from `globals.css` inside a cached scope, and a missing token fails the build. _Drafted by /sync from the introducing change, worth a quick human pass._ diff --git a/lib/seo/metadata.ts b/lib/seo/metadata.ts index a3e917c..f9c0db0 100644 --- a/lib/seo/metadata.ts +++ b/lib/seo/metadata.ts @@ -17,7 +17,7 @@ export type CatalogMetadataInput = { title: string; /** Left undefined when TMDB has none: nothing is invented. */ description: string | undefined; - /** The page path, never with a query string except a landing `?page=N`. */ + /** The page path, never with a query string except a list page's `?page=N`. */ path: string; /** An absolute TMDB image URL, or null to use the site card. */ image: string | null; diff --git a/lib/seo/sitemap.ts b/lib/seo/sitemap.ts index 2e5866c..b49eb24 100644 --- a/lib/seo/sitemap.ts +++ b/lib/seo/sitemap.ts @@ -5,8 +5,9 @@ export const DISCOVER_PAGES_PER_TYPE = 10; /** * The sitemap entries, in the order spec 0016, AC-6 fixes, amended by spec - * 0017, AC-13: the two landings, the two legal pages, then every movie, then - * every show, each in page order then result order. + * 0017, AC-13 and spec 0021, AC-16: the two landings, the ten list pages at + * page 1, the two legal pages, then every movie, then every show, each in + * page order then result order. * * Ids are deduplicated within each media type, first occurrence winning, * because popularity can shift a title across a page boundary between two @@ -16,17 +17,21 @@ export const DISCOVER_PAGES_PER_TYPE = 10; * for `metadataBase` to resolve (`lib/seo/AGENTS.md`, the absolute URL rule). * * @param origin The site origin from `siteUrl()`. + * @param listPaths The list pages' bare paths from the registry, movie lists + * first, each group in row order; never a `?page=N` variant (AC-16). * @param moviePages The ids of each discover page that loaded, in page order. * @param showPages As `moviePages`, for TV shows. */ export function sitemapEntries( origin: string, + listPaths: readonly string[], moviePages: number[][], showPages: number[][], ): MetadataRoute.Sitemap { return [ `${origin}/movies`, `${origin}/shows`, + ...listPaths.map((path) => `${origin}${path}`), `${origin}/privacy`, `${origin}/terms`, ...uniqueIds(moviePages).map((id) => `${origin}/movies/${id}`), diff --git a/lib/tmdb/AGENTS.md b/lib/tmdb/AGENTS.md index 214950f..2ac9e3c 100644 --- a/lib/tmdb/AGENTS.md +++ b/lib/tmdb/AGENTS.md @@ -18,7 +18,8 @@ feature 4. Runtime steps live in that spec's `verify.md`. - `schemas.ts` (Zod), `normalize.ts` (TMDB shape to app shape), `types.ts` (the app's own types), `validation.ts` (input guards), `errors.ts`, `result.ts`, `images.ts`, `concurrency.ts`, then the reads themselves in `movies.ts`, - `tv.ts`, `search.ts`, `genres.ts`, `batch.ts` and `show-episodes.ts`. + `tv.ts`, `search.ts`, `genres.ts`, `batch.ts`, `show-episodes.ts` and + `lists.ts` (TMDB's trending, dated and top rated lists, spec 0021). ## Conventions this module holds to @@ -31,8 +32,8 @@ feature 4. Runtime steps live in that spec's `verify.md`. bare `Error` with no `kind`. Cached functions return a `TmdbResult` (`result.ts`) and the wrapper rebuilds the error outside the scope. Never throw a `TmdbError` from inside a cached scope. -- **Lifetimes are explicit**: `days` for detail, `hours` for a season, `minutes` - for query shaped reads, `max` for genre lists. A cached failure takes +- **Lifetimes are explicit**: `days` for detail, `hours` for a season and for + the ready made lists (spec 0021, AC-17), `minutes` for query shaped reads, `max` for genre lists. A cached failure takes `failureProfile(kind)` from `result.ts`: `seconds` for `timeout`, `rate_limited` and `upstream` (so a retry really retries), `minutes` for the rest. diff --git a/lib/tmdb/constants.ts b/lib/tmdb/constants.ts index 871d250..10ba116 100644 --- a/lib/tmdb/constants.ts +++ b/lib/tmdb/constants.ts @@ -61,3 +61,16 @@ export const SHOW_CAST_LIMIT = 12; */ export const TMDB_ATTRIBUTION = "This website uses TMDB and the TMDB APIs but is not endorsed, certified, or otherwise approved by TMDB."; + +/** + * The timezone TMDB uses to decide "today" for the Airing today and On the air + * TV lists (spec 0021, AC-2). Fixed app wide rather than per visitor: following + * the visitor is a deferred decision in the scope, not a constant change. + */ +export const TV_LIST_TIMEZONE = "Europe/Warsaw"; + +/** + * The release region of the Now playing and Upcoming movie lists (spec 0021, + * AC-2), fixed app wide for the same reason as `TV_LIST_TIMEZONE`. + */ +export const MOVIE_LIST_REGION = "PL"; diff --git a/lib/tmdb/failure-cache.test.ts b/lib/tmdb/failure-cache.test.ts index 009f8de..9510f85 100644 --- a/lib/tmdb/failure-cache.test.ts +++ b/lib/tmdb/failure-cache.test.ts @@ -20,6 +20,8 @@ const fetchDiscoverMovies = vi.fn(); const fetchTvShow = vi.fn(); const fetchShowCast = vi.fn(); const fetchSeason = vi.fn(); +const fetchTvList = vi.fn(); +const fetchMovieList = vi.fn(); vi.mock("next/cache", () => ({ cacheLife: (profile: string) => cacheLife(profile), @@ -32,6 +34,11 @@ vi.mock("./tv", () => ({ fetchShowCast: (id: number) => fetchShowCast(id), fetchSeason: (id: number, n: number) => fetchSeason(id, n), })); +vi.mock("./lists", () => ({ + fetchTvList: (kind: string, options: unknown) => fetchTvList(kind, options), + fetchMovieList: (kind: string, options: unknown) => + fetchMovieList(kind, options), +})); vi.mock("./search", () => ({ fetchDiscoverMovies: (options: unknown) => fetchDiscoverMovies(options), fetchDiscoverTvShows: vi.fn(), @@ -39,8 +46,15 @@ vi.mock("./search", () => ({ fetchSearchTvShows: vi.fn(), })); -const { discoverMovies, getMovie, getSeason, getShowCast, getTvShow } = - await import("./reads"); +const { + discoverMovies, + getMovie, + getMovieList, + getSeason, + getShowCast, + getTvList, + getTvShow, +} = await import("./reads"); afterEach(() => { cacheLife.mockReset(); @@ -49,6 +63,8 @@ afterEach(() => { fetchTvShow.mockReset(); fetchShowCast.mockReset(); fetchSeason.mockReset(); + fetchTvList.mockReset(); + fetchMovieList.mockReset(); }); const TRANSIENT: TmdbErrorKind[] = ["timeout", "rate_limited", "upstream"]; @@ -171,3 +187,66 @@ describe("the show, cast and season cache lifetimes", () => { expect(isTmdbNotFound(error)).toBe(true); }); }); + +/** + * covers: spec 0021, AC-3, AC-17 + * + * The landing rows' list reads live for `hours`, and a TMDB outage on one is + * pinned for seconds only, so a row's Try again really retries. Each failure + * comes back out of the scope as a `TmdbError`, which is what keeps it inside + * its own row (AC-11). + */ +describe("the catalog list cache lifetimes", () => { + it.each([ + ["getTvList", () => getTvList("trending", { page: 1 }), fetchTvList], + [ + "getMovieList", + () => getMovieList("now_playing", { page: 1 }), + fetchMovieList, + ], + ] as const)("%s caches a page for hours", async (_, read, fetcher) => { + fetcher.mockResolvedValue({ page: 1, results: [] }); + + await read(); + + expect(cacheLife).toHaveBeenCalledExactlyOnceWith("hours"); + }); + + it("passes the kind and page through to the fetcher", async () => { + fetchTvList.mockResolvedValue({ page: 4, results: [] }); + fetchMovieList.mockResolvedValue({ page: 1, results: [] }); + + await getTvList("airing_today", { page: 4 }); + await getMovieList("upcoming"); + + expect(fetchTvList).toHaveBeenCalledWith("airing_today", { page: 4 }); + expect(fetchMovieList).toHaveBeenCalledWith("upcoming", {}); + }); + + it.each(TRANSIENT)( + "caches a %s list failure for seconds and rethrows it", + async (kind) => { + fetchTvList.mockRejectedValue(new TmdbError(kind, "/tv/top_rated", "x")); + + await expect(getTvList("top_rated", { page: 1 })).rejects.toMatchObject({ + kind, + }); + + expect(cacheLife).toHaveBeenCalledExactlyOnceWith("seconds"); + }, + ); + + it.each(SETTLED)("caches a %s list failure for minutes", async (kind) => { + fetchMovieList.mockRejectedValue( + new TmdbError(kind, "/movie/top_rated", "x"), + ); + + const error = await getMovieList("top_rated", { page: 1 }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(TmdbError); + expect(error).toMatchObject({ kind, endpoint: "/movie/top_rated" }); + expect(cacheLife).toHaveBeenCalledExactlyOnceWith("minutes"); + }); +}); diff --git a/lib/tmdb/index.ts b/lib/tmdb/index.ts index a34dec1..e6b279d 100644 --- a/lib/tmdb/index.ts +++ b/lib/tmdb/index.ts @@ -24,6 +24,7 @@ export { discoverTvShows, getMovie, getMovieGenres, + getMovieList, getMovieSummaries, getMoviesByIds, getMoviesSettled, @@ -31,6 +32,7 @@ export { getShowCast, getShowEpisodes, getTvGenres, + getTvList, getTvShow, getTvShowsByIds, getTvShowsSettled, @@ -44,7 +46,9 @@ export type { Episode, EpisodeToAir, Genre, + ListOptions, Movie, + MovieListKind, MovieSummary, Paged, SearchOptions, @@ -53,6 +57,7 @@ export type { ShowCastMember, ShowEpisodes, TmdbShowStatus, + TvListKind, TvShow, TvShowSummary, } from "./types"; diff --git a/lib/tmdb/lists.test.ts b/lib/tmdb/lists.test.ts new file mode 100644 index 0000000..7f8a1b1 --- /dev/null +++ b/lib/tmdb/lists.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { installFetchMock, restoreFetchMock } from "./__fixtures__/helpers"; +import { fetchMovieList, fetchTvList } from "./lists"; +import type { MovieListKind, TvListKind } from "./types"; + +/** + * covers: spec 0021, AC-2, AC-8 + * + * Each list kind reads its own endpoint, and only the date based lists carry + * a place: `timezone` on two TV lists, `region` on two movie lists. An adult + * result is dropped while TMDB's totals pass through unchanged. + */ + +afterEach(restoreFetchMock); + +function movie(id: number, adult?: boolean) { + return { + id, + title: `Movie ${id}`, + poster_path: null, + release_date: "2026-10-01", + overview: null, + vote_average: 7, + vote_count: 100, + genre_ids: [18], + ...(adult === undefined ? {} : { adult }), + }; +} + +function show(id: number, adult?: boolean) { + return { + id, + name: `Show ${id}`, + poster_path: null, + first_air_date: "2026-01-01", + overview: null, + vote_average: 8, + vote_count: 50, + genre_ids: [18], + ...(adult === undefined ? {} : { adult }), + }; +} + +function paged<T>(results: T[]) { + return { page: 1, results, total_pages: 42, total_results: 830 }; +} + +describe("fetchTvList (AC-2)", () => { + it.each<[TvListKind, string, string | null]>([ + ["trending", "/3/trending/tv/week", null], + ["airing_today", "/3/tv/airing_today", "Europe/Warsaw"], + ["on_the_air", "/3/tv/on_the_air", "Europe/Warsaw"], + ["top_rated", "/3/tv/top_rated", null], + ])( + "reads %s from %s with timezone %s and no region", + async (kind, path, timezone) => { + const fetchMock = installFetchMock([{ body: paged([show(1)]) }]); + + await fetchTvList(kind, { page: 3 }); + + const params = fetchMock.url().searchParams; + expect(fetchMock.url().pathname).toBe(path); + expect(params.get("timezone")).toBe(timezone); + expect(params.has("region")).toBe(false); + expect(params.get("page")).toBe("3"); + expect(params.get("language")).toBe("en-US"); + }, + ); + + it("asks for page 1 when no page is given", async () => { + const fetchMock = installFetchMock([{ body: paged([]) }]); + + await fetchTvList("top_rated"); + + expect(fetchMock.url().searchParams.get("page")).toBe("1"); + }); + + it("drops a result flagged adult and keeps TMDB's totals (AC-8)", async () => { + installFetchMock([ + { body: paged([show(1), show(2, true), show(3, false)]) }, + ]); + + const list = await fetchTvList("trending"); + + expect(list.results.map((item) => item.id)).toEqual([1, 3]); + expect(list.results[0]?.name).toBe("Show 1"); + expect(list.totalPages).toBe(42); + expect(list.totalResults).toBe(830); + }); + + it("refuses a page that is not a positive integer before any request", async () => { + const fetchMock = installFetchMock([{ body: paged([]) }]); + + const error = await fetchTvList("trending", { page: 0 }).catch((e) => e); + + expect(error.kind).toBe("bad_response"); + expect(fetchMock.attempts).toBe(0); + }); +}); + +describe("fetchMovieList (AC-2)", () => { + it.each<[MovieListKind, string, string | null]>([ + ["trending", "/3/trending/movie/week", null], + ["now_playing", "/3/movie/now_playing", "PL"], + ["upcoming", "/3/movie/upcoming", "PL"], + ["top_rated", "/3/movie/top_rated", null], + ])( + "reads %s from %s with region %s and no timezone", + async (kind, path, region) => { + const fetchMock = installFetchMock([{ body: paged([movie(1)]) }]); + + await fetchMovieList(kind, { page: 2 }); + + const params = fetchMock.url().searchParams; + expect(fetchMock.url().pathname).toBe(path); + expect(params.get("region")).toBe(region); + expect(params.has("timezone")).toBe(false); + expect(params.get("page")).toBe("2"); + }, + ); + + it("drops a result flagged adult and keeps TMDB's totals (AC-8)", async () => { + installFetchMock([{ body: paged([movie(1, true), movie(2)]) }]); + + const list = await fetchMovieList("now_playing"); + + expect(list.results.map((item) => item.id)).toEqual([2]); + expect(list.results[0]?.releaseDate).toBe("2026-10-01"); + expect(list.totalPages).toBe(42); + expect(list.totalResults).toBe(830); + }); +}); diff --git a/lib/tmdb/lists.ts b/lib/tmdb/lists.ts new file mode 100644 index 0000000..2427ac0 --- /dev/null +++ b/lib/tmdb/lists.ts @@ -0,0 +1,114 @@ +import "server-only"; +import type { z } from "zod"; +import { type TmdbQuery, tmdbRequest } from "./client"; +import { MOVIE_LIST_REGION, TV_LIST_TIMEZONE } from "./constants"; +import { normalizeMovieSummary, normalizeTvSummary } from "./normalize"; +import { movieListItemSchema, pagedSchema, tvListItemSchema } from "./schemas"; +import type { + ListOptions, + MovieListKind, + MovieSummary, + Paged, + TvListKind, + TvShowSummary, +} from "./types"; +import { assertPage } from "./validation"; + +/** + * TMDB's ready made lists, the rows of the `/shows` and `/movies` landings + * (spec 0021, AC-2). Popular is not here: it stays on discover, the call the + * sitemap shares (`discoverTvShows`, `discoverMovies`). + * + * Only the date based lists carry a place: `timezone` for Airing today and On + * the air, `region` for Now playing and Upcoming. Trending and Top rated send + * neither. Unlike discover, these endpoints take no `include_adult`, so a + * result TMDB flags `adult` is dropped here, before any page sees it (AC-8). + * TMDB's `page`, `total_pages` and `total_results` pass through unchanged, as + * on every paged read (spec 0002, AC-18). + */ + +const pagedMovieListSchema = pagedSchema(movieListItemSchema); +const pagedTvListSchema = pagedSchema(tvListItemSchema); + +const TV_ENDPOINTS: Record<TvListKind, string> = { + trending: "/trending/tv/week", + airing_today: "/tv/airing_today", + on_the_air: "/tv/on_the_air", + top_rated: "/tv/top_rated", +}; + +const MOVIE_ENDPOINTS: Record<MovieListKind, string> = { + trending: "/trending/movie/week", + now_playing: "/movie/now_playing", + upcoming: "/movie/upcoming", + top_rated: "/movie/top_rated", +}; + +/** + * Reads one page of a TV list, uncached. + * + * @param kind Which list, in TMDB's own naming. + * @param options The page, 1 when left out. + * @throws {TmdbError} `bad_response` for a page that is not a positive integer. + */ +export function fetchTvList( + kind: TvListKind, + options: ListOptions = {}, +): Promise<Paged<TvShowSummary>> { + const dated = kind === "airing_today" || kind === "on_the_air"; + return fetchList( + TV_ENDPOINTS[kind], + options, + { timezone: dated ? TV_LIST_TIMEZONE : undefined }, + pagedTvListSchema, + normalizeTvSummary, + ); +} + +/** + * Reads one page of a movie list, uncached. + * + * @param kind Which list, in TMDB's own naming. + * @param options The page, 1 when left out. + * @throws {TmdbError} `bad_response` for a page that is not a positive integer. + */ +export function fetchMovieList( + kind: MovieListKind, + options: ListOptions = {}, +): Promise<Paged<MovieSummary>> { + const dated = kind === "now_playing" || kind === "upcoming"; + return fetchList( + MOVIE_ENDPOINTS[kind], + options, + { region: dated ? MOVIE_LIST_REGION : undefined }, + pagedMovieListSchema, + normalizeMovieSummary, + ); +} + +/** + * The request both media types share: only the endpoint, the place param, + * the schema and the normalizer differ, so the adult drop and the totals + * pass through are written once (AC-8). + */ +async function fetchList<Raw extends { adult?: boolean | null }, Summary>( + endpoint: string, + options: ListOptions, + place: TmdbQuery, + schema: z.ZodType<{ + page: number; + results: Raw[]; + total_pages: number; + total_results: number; + }>, + normalize: (raw: Raw) => Summary, +): Promise<Paged<Summary>> { + const page = assertPage(options.page ?? 1, endpoint); + const raw = await tmdbRequest(endpoint, { page, ...place }, schema); + return { + page: raw.page, + results: raw.results.filter((item) => item.adult !== true).map(normalize), + totalPages: raw.total_pages, + totalResults: raw.total_results, + }; +} diff --git a/lib/tmdb/reads.ts b/lib/tmdb/reads.ts index 87e7b3d..211df2a 100644 --- a/lib/tmdb/reads.ts +++ b/lib/tmdb/reads.ts @@ -7,6 +7,7 @@ import { type SettledBatch, } from "./batch"; import { fetchMovieGenres, fetchTvGenres } from "./genres"; +import { fetchMovieList, fetchTvList } from "./lists"; import { fetchMovie } from "./movies"; import { failureProfile, @@ -27,13 +28,16 @@ import type { BatchResult, DiscoverOptions, Genre, + ListOptions, Movie, + MovieListKind, MovieSummary, Paged, SearchOptions, SeasonDetail, ShowCastMember, ShowEpisodes, + TvListKind, TvShow, TvShowSummary, } from "./types"; @@ -280,6 +284,71 @@ export async function discoverTvShows( return unwrap(await discoverTvShowsCached(options)); } +/** + * A landing row and page 1 of its list page make this same call, so they + * share one entry (spec 0021, AC-3). `hours`: trending and the date based + * lists move daily at most, and a row an hour stale is still truthful + * (AC-17). + */ +async function getTvListCached( + kind: TvListKind, + options: ListOptions, +): Promise<TmdbResult<Paged<TvShowSummary>>> { + "use cache"; + try { + const value = await fetchTvList(kind, options); + cacheLife("hours"); + return { ok: true, value }; + } catch (error) { + const failure = toFailure(error); + cacheFailure(failure); + return { ok: false, failure }; + } +} + +/** + * One page of a TMDB TV list, cached (spec 0021, AC-2). Read through + * `readCatalogList`, never by a page directly, so a row, its list page and + * their metadata share one entry (AC-3). + * + * @throws {TmdbError} Rebuilt outside the cached scope from the stored failure. + */ +export async function getTvList( + kind: TvListKind, + options: ListOptions = {}, +): Promise<Paged<TvShowSummary>> { + return unwrap(await getTvListCached(kind, options)); +} + +/** The movie twin of `getTvListCached`, with the same lifetime. */ +async function getMovieListCached( + kind: MovieListKind, + options: ListOptions, +): Promise<TmdbResult<Paged<MovieSummary>>> { + "use cache"; + try { + const value = await fetchMovieList(kind, options); + cacheLife("hours"); + return { ok: true, value }; + } catch (error) { + const failure = toFailure(error); + cacheFailure(failure); + return { ok: false, failure }; + } +} + +/** + * The movie twin of `getTvList`, read through `readCatalogList` the same way. + * + * @throws {TmdbError} Rebuilt outside the cached scope from the stored failure. + */ +export async function getMovieList( + kind: MovieListKind, + options: ListOptions = {}, +): Promise<Paged<MovieSummary>> { + return unwrap(await getMovieListCached(kind, options)); +} + async function getMovieGenresCached(): Promise<TmdbResult<Genre[]>> { "use cache"; try { diff --git a/lib/tmdb/schemas.ts b/lib/tmdb/schemas.ts index 50c501e..783538c 100644 --- a/lib/tmdb/schemas.ts +++ b/lib/tmdb/schemas.ts @@ -100,6 +100,21 @@ export const tvSummarySchema = z.object({ genre_ids: z.array(z.number()).nullish(), }); +/** + * A result of one of TMDB's list endpoints (trending, now playing, top rated + * and so on). The summary plus `adult`, because unlike discover these + * endpoints take no `include_adult` and the module drops adult titles itself + * (spec 0021, AC-8). + */ +export const movieListItemSchema = movieSummarySchema.extend({ + adult: z.boolean().nullish(), +}); + +/** The TV twin of `movieListItemSchema` (spec 0021, AC-8). */ +export const tvListItemSchema = tvSummarySchema.extend({ + adult: z.boolean().nullish(), +}); + export const seasonSummarySchema = z.object({ season_number: z.number(), name: z.string().nullish(), diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts index fff2cad..f9fee79 100644 --- a/lib/tmdb/types.ts +++ b/lib/tmdb/types.ts @@ -243,3 +243,27 @@ export type SearchOptions = { page?: number; year?: number; }; + +/** + * The TV lists a landing row reads besides Popular, named as TMDB names them + * (spec 0021, AC-2). The app's URL slugs live in `lib/catalog/lists.ts`; this + * module never learns them, so one dispatcher (`lib/catalog-list-read.ts`) + * is the only place the two vocabularies meet. + */ +export type TvListKind = + | "trending" + | "airing_today" + | "on_the_air" + | "top_rated"; + +/** The movie twin of `TvListKind` (spec 0021, AC-2). */ +export type MovieListKind = + | "trending" + | "now_playing" + | "upcoming" + | "top_rated"; + +/** The one option a list read takes: TMDB's page, 1 when left out. */ +export type ListOptions = { + page?: number; +}; diff --git a/next.config.test.ts b/next.config.test.ts index 7e4367b..a8c0e0c 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -23,9 +23,37 @@ describe("next.config", () => { vi.stubEnv("NEXT_PUBLIC_AUTH_EMAIL_DELIVERY", "on"); const redirects = await nextConfig.redirects?.(); - expect(redirects).toEqual([ + expect(redirects).toContainEqual( expect.objectContaining({ source: "/", destination: "/shows" }), - ]); + ); + expect(redirects).not.toContainEqual( + expect.objectContaining({ source: "/check-email" }), + ); + }); + + /** covers: spec 0021, AC-14 */ + it.each([ + ["/shows", "/shows/popular"], + ["/movies", "/movies/popular"], + ])("sends %s with a page to %s, permanently", async (source, destination) => { + const redirects = await nextConfig.redirects?.(); + + expect(redirects).toContainEqual({ + source, + has: [{ type: "query", key: "page" }], + destination, + permanent: true, + }); + }); + + it("redirects a landing only when it carries a page (spec 0021, AC-14)", async () => { + const redirects = await nextConfig.redirects?.(); + + for (const redirect of redirects ?? []) { + if (redirect.source === "/shows" || redirect.source === "/movies") { + expect(redirect.has).toEqual([{ type: "query", key: "page" }]); + } + } }); /** covers: spec 0018, AC-13 */ @@ -40,10 +68,10 @@ describe("next.config", () => { }); }); - it("makes that redirect temporary, so no browser caches it forever", async () => { + it("makes the / redirect temporary, so no browser caches it forever", async () => { const redirects = await nextConfig.redirects?.(); - expect(redirects?.[0].permanent).toBe(false); + expect(redirects?.find((r) => r.source === "/")?.permanent).toBe(false); }); it("allows TMDB images and nothing else", () => { diff --git a/next.config.ts b/next.config.ts index 49babac..de9abcd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -27,10 +27,28 @@ const nextConfig: NextConfig = { * because there is nothing to check for (spec 0018, AC-13). Decided here at * build time, so the page never streams a redirect after a 200. Temporary, * because turning email on brings the page back. + * + * `/shows?page=N` and `/movies?page=N` go to the Popular list page that now + * holds that paged grid (spec 0021, AC-14), any value of `page`, with the + * query string carried over by Next. Permanent on purpose: unlike `/`, a + * landing with `?page` has no future meaning, so a browser caching the 308 + * forever is what an old bookmark should get. */ async redirects() { return [ { source: "/", destination: "/shows", permanent: false }, + { + source: "/shows", + has: [{ type: "query", key: "page" }], + destination: "/shows/popular", + permanent: true, + }, + { + source: "/movies", + has: [{ type: "query", key: "page" }], + destination: "/movies/popular", + permanent: true, + }, ...(getAuthEmailDelivery() === "off" ? [ { diff --git a/prompts/home-carousels-review-fixes.md b/prompts/home-carousels-review-fixes.md new file mode 100644 index 0000000..c7e39f7 --- /dev/null +++ b/prompts/home-carousels-review-fixes.md @@ -0,0 +1,46 @@ +# Home carousels review fixes + +## Goal + +Act on the two axis code review of `feat/home-carousels` (spec 0021) before it merges: close a soft 404 in the proxy, bring two AGENTS.md files and the spec text up to date, and remove the duplication the review flagged. One small commit per fix. + +## Inspected + +- `proxy.ts`, `proxy.test.ts` +- `lib/catalog/lists.ts` (+ test), `lib/catalog-list-read.ts`, `lib/landing-metadata.ts`, `app/sitemap.ts` +- `lib/tmdb/lists.ts`, `lib/tmdb/reads.ts`, `lib/tmdb/AGENTS.md`, `lib/seo/AGENTS.md` +- `components/catalog/catalog-list-page.tsx`, `carousel-row.tsx`, `carousel-row-states.tsx`, `list-cards.tsx`, `app/showcase/page.tsx` +- `docs/specs/0021-home-carousels/index.md` +- The running dev server: `/shows/top%2Drated` answers **200** with "Show not found" (`noindex`). The proxy decodes the segment, matches the slug and lets it through, but Next routes the encoded path to `[id]`, not to the static `top-rated` folder. + +## Decisions (one commit each, in this order) + +1. **Proxy: match list slugs on the raw segment** (spec AC-13, "exact, case sensitive"). `isCatalogListSlug` gets `match[1]` undecoded, so `/shows/top%2Drated` falls through to the id parser and gets a real 404. Rename the field `lists` to `listMediaType`. Add the encoded paths to the 404 cases in `proxy.test.ts`. +2. **Registry entry in, path out.** `listPath(list)` and `listPageHref(list, page)` take the `CatalogList` instead of a `(mediaType, slug)` pair; the five callers and `lists.test.ts` follow. `isCatalogListSlug` keeps a plain `string` segment because it validates untrusted input. +3. **Showcase See all** uses `listPath(catalogList("tv", "top-rated"))` instead of a hard coded href. +4. **One empty row text.** Drop `emptyRowText` from the registry; `CarouselRowEmpty` renders the one constant. +5. **One list fetcher.** `fetchTvList` and `fetchMovieList` keep their signatures but share one private helper (endpoint, place param, schema, normalizer). +6. **One card mapper.** `ListGridCards` and `RowCards` become one `CatalogListCards` in `list-cards.tsx` (result, return path, eager count, optional `sizes`, optional `itemClassName`); the row passes its first 12. +7. **JSDoc** on the exported `getTvList` and `getMovieList` (`AGENTS.md`, Code conventions). +8. **Docs.** `lib/tmdb/AGENTS.md`: add `lists.ts`, and `hours` for the list reads. `lib/seo/AGENTS.md`: canonicals carry `?page=N` on list pages only, and the sitemap shares Popular's discover calls with `/shows/popular` and `/movies/popular`. Spec 0021: the reads use new list item schemas that add `adult`, and the empty list page panel is titled "This list is empty". + +Kept as is: `readTaggedCatalogList` (it avoids a cast, the review calls it justified), the `/showcase` row (allowed by build step 10), the media type branch in `readCatalogList` (it is the dispatcher's job) and `LANDING_LABELS` (one use). + +## Security + +Fix 1 tightens the proxy: an encoded list slug now gets a real 404 instead of a soft one. Nothing else touches auth, caching or data access. + +## Acceptance criteria + +- `/shows/top%2Drated` and `/movies/now%2Dplaying` answer 404; every AC-13 path still behaves as before. +- Landings, rows, list pages, pagination, sitemap and `/showcase` render exactly as before. + +## Checks + +`pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`. + +## Manual test + +1. With `pnpm dev` running, `curl -o /dev/null -w '%{http_code}' localhost:3000/shows/top%2Drated` prints 404, and `/shows/top-rated` prints 200. +2. Open `/shows` and `/movies`: five rows each with cards; See all opens the list page; page 2 works. +3. Open `/showcase`: the carousel row section still shows its four states, and its See all goes to `/shows/top-rated`. diff --git a/proxy.test.ts b/proxy.test.ts index 7e10af9..db70b3f 100644 --- a/proxy.test.ts +++ b/proxy.test.ts @@ -189,6 +189,20 @@ describe("the proxy's catalog id rule", () => { "/shows/1396/season/1.0", "/shows/1396/season/10000", "/shows/1396/season/x", + // spec 0021, AC-13: a list slug of the other media type, the wrong + // case, an image suffix, or a season under a list is still a 404. + "/shows/upcoming", + "/shows/now-playing", + "/movies/airing-today", + "/movies/on-the-air", + "/shows/Popular", + "/shows/popular.jpg", + "/shows/popular/season/1", + // An encoded slug is not the slug: Next routes it to `[id]`, not to the + // list folder, so it must be a real 404 here, not a soft one there. + "/shows/top%2Drated", + "/shows/top%2drated", + "/movies/now%2Dplaying", ])("answers %s with a 404 rewrite", async (path) => { const response = await run(path); @@ -246,6 +260,17 @@ describe("the proxy's catalog id rule", () => { "/shows/1396/season/9999", "/shows/1396/season", "/shows/1396/season/2/extra", + // spec 0021, AC-13: every list page of its own catalog. + "/shows/trending", + "/shows/popular", + "/shows/airing-today", + "/shows/on-the-air", + "/shows/top-rated", + "/movies/trending", + "/movies/popular", + "/movies/now-playing", + "/movies/upcoming", + "/movies/top-rated", ])("lets %s through untouched", async (path) => { const response = await run(path); diff --git a/proxy.ts b/proxy.ts index 5c41976..728db9e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -3,6 +3,8 @@ import { type NextRequest, NextResponse } from "next/server"; import { isPrivatePath } from "@/lib/auth/private-paths"; import { parseSeasonNumber, parseTmdbId } from "@/lib/catalog/ids"; +import { isCatalogListSlug } from "@/lib/catalog/lists"; +import type { MediaType } from "@/lib/catalog/media-type"; import { getPublicEnv, publicEnvProblems } from "@/lib/env"; import { sessionCookieOptions } from "@/lib/supabase/cookie-options"; @@ -164,13 +166,27 @@ function signInRedirect(request: NextRequest): NextResponse { * The catalog routes whose segments the proxy checks, each with the parser * its page uses, one per captured segment. Deeper or other paths already * match no route, so they are not listed. + * + * `listMediaType` marks the two paths that also hold a catalog's list pages + * (`/shows/top-rated`, spec 0021, AC-13): a segment naming one of that media + * type's lists passes before the id parser judges it. Any other non numeric + * segment is still a 404. */ const CATALOG_PATHS: { pattern: RegExp; parsers: ((segment: string) => number | null)[]; + listMediaType?: MediaType; }[] = [ - { pattern: /^\/movies\/([^/]+)$/, parsers: [parseTmdbId] }, - { pattern: /^\/shows\/([^/]+)$/, parsers: [parseTmdbId] }, + { + pattern: /^\/movies\/([^/]+)$/, + parsers: [parseTmdbId], + listMediaType: "movie", + }, + { + pattern: /^\/shows\/([^/]+)$/, + parsers: [parseTmdbId], + listMediaType: "tv", + }, { pattern: /^\/shows\/([^/]+)\/season\/([^/]+)$/, parsers: [parseTmdbId, parseSeasonNumber], @@ -188,17 +204,24 @@ const NOT_FOUND_PATH = "/_catalog-not-found"; * The 404 for a catalog URL with a segment that is not canonical, or null for * every other request. Shares its parsers with the pages, so the proxy and * the routes cannot disagree about what a movie, show or season URL looks - * like (spec 0006, AC-8; spec 0009, AC-13). + * like (spec 0006, AC-8; spec 0009, AC-13). A list slug is checked against + * the same registry the list routes are built from (spec 0021, AC-13). * * The status is set explicitly rather than left to the rewrite, so the answer * is a 404 even if the rendered page would otherwise report 200. */ function malformedCatalogResponse(request: NextRequest): NextResponse | null { const { pathname } = request.nextUrl; - for (const { pattern, parsers } of CATALOG_PATHS) { + for (const { pattern, parsers, listMediaType } of CATALOG_PATHS) { const match = pattern.exec(pathname); if (!match) continue; + // Undecoded on purpose: Next matches a static folder only on the literal + // path, so `/shows/top%2Drated` reaches `[id]`, and must be a 404 here. + if (listMediaType && isCatalogListSlug(listMediaType, match[1])) { + return null; + } + const valid = parsers.every( (parse, index) => parse(decodeSegment(match[index + 1])) !== null, );