Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
908cd84
docs(spec): design home carousels (spec 0021)
kris1027 Oct 12, 2026
5dd488e
feat(tmdb): read TMDB's trending, dated and top rated lists
kris1027 Oct 12, 2026
81b683b
feat(catalog): add the list registry and the slug to TMDB dispatcher
kris1027 Oct 12, 2026
cf3d7da
feat(proxy): let the catalog list slugs through the id rule
kris1027 Oct 12, 2026
b8cc10c
feat(catalog): add the shared list page and /shows/top-rated
kris1027 Oct 12, 2026
d16bf41
feat(catalog): turn /shows into a carousel landing with its first row
kris1027 Oct 12, 2026
1099d61
feat(routing): send old landing ?page links to the popular list, 308
kris1027 Oct 12, 2026
9a8af9a
feat(catalog): add every list page and both five row landings
kris1027 Oct 12, 2026
bcf3b05
test(catalog): cover the row scroller and the row's own states
kris1027 Oct 12, 2026
9ba1015
feat(seo): list the ten catalog list pages in the sitemap
kris1027 Oct 12, 2026
dce6ae6
feat(showcase): show the carousel row and its loading, error and empt…
kris1027 Oct 12, 2026
5e29b9d
docs(scope): tick the home carousels build milestones
kris1027 Oct 12, 2026
343a620
docs(spec): add the home carousels verify steps
kris1027 Oct 12, 2026
9ad7ea0
test(catalog): cover the list page header, grid and its states
kris1027 Oct 12, 2026
139cdc6
test(catalog): cover both landings' rows and the row header
kris1027 Oct 12, 2026
ef511ec
test(catalog): cover the list cards' slots, return path and sizes
kris1027 Oct 12, 2026
239498c
test(tmdb): pin the catalog list reads' cache lifetimes
kris1027 Oct 12, 2026
ce88d3e
docs(spec): tick the home carousels verify steps
kris1027 Oct 12, 2026
d179ba9
docs(scope): mark home carousels and progress based library pages done
kris1027 Oct 12, 2026
2d72ac4
fix(proxy): match list slugs on the undecoded segment
kris1027 Oct 12, 2026
54aaddc
refactor(catalog): build list URLs from the registry entry
kris1027 Oct 12, 2026
3cba96b
refactor(showcase): build the row's See all link from the registry
kris1027 Oct 12, 2026
f5c9c85
refactor(catalog): keep the empty row line in the row, not the registry
kris1027 Oct 12, 2026
e4d6536
refactor(tmdb): share one request between the TV and movie list reads
kris1027 Oct 12, 2026
b7a2aad
refactor(catalog): render the row and the list page grid through one …
kris1027 Oct 12, 2026
18d7185
docs(tmdb): document the exported list reads
kris1027 Oct 12, 2026
cb4cd4e
docs: bring the TMDB and SEO notes and spec 0021 up to date with the …
kris1027 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/catalog-lists.test.ts
Original file line number Diff line number Diff line change
@@ -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("<CatalogListPage");
expect(source).toContain("catalogListMetadata(");
});
});
21 changes: 21 additions & 0 deletions app/movies/now-playing/page.tsx
Original file line number Diff line number Diff line change
@@ -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", "now-playing");

/** The list's title, note and canonical (spec 0021, AC-15). */
export async function generateMetadata({
searchParams,
}: PageProps<"/movies/now-playing">): Promise<Metadata> {
return catalogListMetadata(LIST, searchParams);
}

/** Movies now playing, paged (spec 0021, AC-9). */
export default function MoviesNowPlayingPage({
searchParams,
}: PageProps<"/movies/now-playing">) {
return <CatalogListPage list={LIST} searchParams={searchParams} />;
}
119 changes: 23 additions & 96 deletions app/movies/page.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof generateMetadata>[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` });
});
});
Loading
Loading