diff --git a/README.md b/README.md index 6bac55f..f1304df 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,9 @@ make frontend-dev ``` Open `http://127.0.0.1:5173`. The v0.5 frontend reads API health, catalog -freshness, reference filters, Top 10, and New Releases from FastAPI, proving the -complete local serving path. The browser never calls either upstream data -provider. See the +freshness, reference filters, Top 10, New Releases, and Recently Added from +FastAPI, proving the complete local serving path. The browser never calls either +upstream data provider. See the [frontend guide](frontend/README.md) for configuration and quality commands. ## Dependency management diff --git a/docs/roadmap.md b/docs/roadmap.md index 8179cec..2671eac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -173,6 +173,9 @@ Section loading, empty, failure, and retry behavior is explicit. The fifth increment adds the filter-reactive New Releases rail using content release dates, reuses the cinematic cards without ranking badges, and preserves independent loading, empty, failure, and retry behavior. +The sixth increment adds Recently Added based on provider addition time rather +than content release date, and extracts a reusable lifecycle rail for consistent +loading, empty, failure, retry, and card behavior across the remaining sections. Verified provider deep links are retained from historical lifecycle payloads and exposed as optional safe card actions; TMDB-only rows remain unlinked rather than receiving guessed destinations. diff --git a/frontend/README.md b/frontend/README.md index 7a80286..0e8768e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -41,8 +41,9 @@ streaming services are loaded from the local API, and valid guest selections are remembered in browser-local storage without requiring an account. Type, genre, runtime, release-year, rating, and language filters use one typed state and the same query-parameter contract as every FastAPI discovery section. -The Top 10 and New Releases rails query only FastAPI and render reusable poster -cards; ranking badges remain specific to Top 10. +The Top 10, New Releases, and Recently Added rails query only FastAPI and render +reusable poster cards; ranking badges remain specific to Top 10. Recently Added +uses provider addition time and remains distinct from content release recency. Poster artwork is loaded from TMDB's public image CDN using the ingested `poster_path`; this is an image asset request, not a TMDB API lookup, and it uses no API key or discovery quota. Missing or failed images have a local diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01b0c44..241f286 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { type CatalogStatus, getCatalogStatus } from "./api/catalog"; import { DiscoverySetup } from "./components/DiscoverySetup"; import { GlobalFilters } from "./components/GlobalFilters"; import { NewReleasesRail } from "./components/NewReleasesRail"; +import { RecentlyAddedRail } from "./components/RecentlyAddedRail"; import { TopTenRail } from "./components/TopTenRail"; import { type CatalogScope, type GlobalFilters as FilterValue } from "./discovery"; import { loadPreferences } from "./preferences"; @@ -70,12 +71,13 @@ export default function App() { + )} {scope && scope.providers.length === 0 && (

Select at least one streaming service to refine your catalog.

)} -

Recently Added, Upcoming, and Leaving Soon arrive next.

+

Upcoming and Leaving Soon arrive next.

); } diff --git a/frontend/src/api/catalog.ts b/frontend/src/api/catalog.ts index 91964e4..b9ce4be 100644 --- a/frontend/src/api/catalog.ts +++ b/frontend/src/api/catalog.ts @@ -149,3 +149,15 @@ export async function getNewReleases( ); return response.items; } + +export async function getRecentlyAdded( + scope: CatalogScope, + filters: GlobalFilters, + signal?: AbortSignal, +): Promise { + const response = await getJson<{ items: CatalogItem[] }>( + `/api/v1/discovery/recently-added?${discoveryParams(scope, filters)}`, + signal, + ); + return response.items; +} diff --git a/frontend/src/components/CatalogRail.tsx b/frontend/src/components/CatalogRail.tsx new file mode 100644 index 0000000..97073a4 --- /dev/null +++ b/frontend/src/components/CatalogRail.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import type { CatalogItem } from "../api/catalog"; +import type { CatalogScope, GlobalFilters } from "../discovery"; +import { TitleCard } from "./TitleCard"; + +type CatalogLoader = ( + scope: CatalogScope, + filters: GlobalFilters, + signal?: AbortSignal, +) => Promise; + +type CatalogRailProps = { + id: string; + title: string; + subtitle: string; + loadingLabel: string; + emptyTitle: string; + emptyHint: string; + errorFallback: string; + scope: CatalogScope; + filters: GlobalFilters; + load: CatalogLoader; +}; + +type RailState = + | { kind: "loading" } + | { kind: "ready"; items: CatalogItem[] } + | { kind: "error"; message: string }; + +export function CatalogRail({ + id, + title, + subtitle, + loadingLabel, + emptyTitle, + emptyHint, + errorFallback, + scope, + filters, + load, +}: CatalogRailProps) { + const [state, setState] = useState({ kind: "loading" }); + const [retry, setRetry] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setState({ kind: "loading" }); + load(scope, filters, controller.signal) + .then((items) => setState({ kind: "ready", items })) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setState({ + kind: "error", + message: error instanceof Error ? error.message : errorFallback, + }); + } + }); + return () => controller.abort(); + }, [scope, filters, retry, load, errorFallback]); + + return ( +
+
+
+

{subtitle}

+

{title}

+
+ {state.kind === "ready" && state.items.length > 0 && {state.items.length} titles} +
+ + {state.kind === "loading" && ( +
+ {Array.from({ length: 5 }, (_, index) =>
)} +
+ )} + + {state.kind === "error" && ( +
+

{state.message}

+ +
+ )} + + {state.kind === "ready" && state.items.length === 0 && ( +
+

{emptyTitle}

+ {emptyHint} +
+ )} + + {state.kind === "ready" && state.items.length > 0 && ( +
+ {state.items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/NewReleasesRail.tsx b/frontend/src/components/NewReleasesRail.tsx index 91f4257..a18a23e 100644 --- a/frontend/src/components/NewReleasesRail.tsx +++ b/frontend/src/components/NewReleasesRail.tsx @@ -1,79 +1,25 @@ -import { useEffect, useState } from "react"; -import { getNewReleases, type CatalogItem } from "../api/catalog"; +import { getNewReleases } from "../api/catalog"; import type { CatalogScope, GlobalFilters } from "../discovery"; -import { TitleCard } from "./TitleCard"; +import { CatalogRail } from "./CatalogRail"; type NewReleasesRailProps = { scope: CatalogScope; filters: GlobalFilters; }; -type RailState = - | { kind: "loading" } - | { kind: "ready"; items: CatalogItem[] } - | { kind: "error"; message: string }; - export function NewReleasesRail({ scope, filters }: NewReleasesRailProps) { - const [state, setState] = useState({ kind: "loading" }); - const [retry, setRetry] = useState(0); - - useEffect(() => { - const controller = new AbortController(); - setState({ kind: "loading" }); - getNewReleases(scope, filters, controller.signal) - .then((items) => setState({ kind: "ready", items })) - .catch((error: unknown) => { - if (!controller.signal.aborted) { - setState({ - kind: "error", - message: error instanceof Error ? error.message : "Unable to load New Releases", - }); - } - }); - return () => controller.abort(); - }, [scope, filters, retry]); - return ( -
-
-
-

Recently released movies and series

-

New Releases

-
- {state.kind === "ready" && state.items.length > 0 && {state.items.length} titles} -
- - {state.kind === "loading" && ( -
- {Array.from({ length: 5 }, (_, index) =>
)} -
- )} - - {state.kind === "error" && ( -
-

{state.message}

- -
- )} - - {state.kind === "ready" && state.items.length === 0 && ( -
-

No recent releases match these filters.

- Try widening the year, genre, runtime, or rating filters. -
- )} - - {state.kind === "ready" && state.items.length > 0 && ( -
- {state.items.map((item) => ( - - ))} -
- )} -
+ ); } diff --git a/frontend/src/components/RecentlyAddedRail.test.tsx b/frontend/src/components/RecentlyAddedRail.test.tsx new file mode 100644 index 0000000..6fec033 --- /dev/null +++ b/frontend/src/components/RecentlyAddedRail.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { EMPTY_FILTERS } from "../discovery"; +import { RecentlyAddedRail } from "./RecentlyAddedRail"; + +afterEach(() => vi.restoreAllMocks()); + +const item = { + tmdb_id: 597, + content_type: "movie", + title: "An Older Film", + original_title: "An Older Film", + overview: "An established title newly added to a provider.", + release_date: "1997-11-18", + release_year: 1997, + runtime_minutes: 194, + original_language: "en", + genre_ids: [18], + genre_names: ["Drama"], + tmdb_rating: 7.9, + vote_count: 26000, + popularity_score: 90, + poster_path: "/poster.jpg", + backdrop_path: null, + metadata_source: "tmdb", + last_updated_at: "2026-08-23T10:00:00", + availabilities: [{ + provider_key: "netflix", + provider_name: "Netflix", + monetization_type: "flatrate", + available_since: "2026-08-22T10:00:00", + available_from: null, + expires_on: null, + is_available: true, + is_upcoming: false, + source: "streaming_availability", + watch_url: "https://www.netflix.com/title/123", + }], +}; + +test("renders provider arrivals independently of the content release year", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ items: [item] }), { status: 200 }), + ); + render( + , + ); + + expect(screen.getByLabelText("Loading Recently Added")).toBeInTheDocument(); + expect(await screen.findByRole("article", { name: "An Older Film" })).toBeInTheDocument(); + expect(screen.getByText("1997 · Movie · 194 min")).toBeInTheDocument(); + expect(String(fetchMock.mock.calls[0][0])).toContain( + "recently-added?region=GR&providers=netflix&content_type=movie", + ); +}); + +test("shows the Recently Added empty state", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ items: [] }), { status: 200 }), + ); + render( + , + ); + + expect( + await screen.findByText("No recently added titles match these filters."), + ).toBeInTheDocument(); +}); diff --git a/frontend/src/components/RecentlyAddedRail.tsx b/frontend/src/components/RecentlyAddedRail.tsx new file mode 100644 index 0000000..ecc240b --- /dev/null +++ b/frontend/src/components/RecentlyAddedRail.tsx @@ -0,0 +1,25 @@ +import { getRecentlyAdded } from "../api/catalog"; +import type { CatalogScope, GlobalFilters } from "../discovery"; +import { CatalogRail } from "./CatalogRail"; + +type RecentlyAddedRailProps = { + scope: CatalogScope; + filters: GlobalFilters; +}; + +export function RecentlyAddedRail({ scope, filters }: RecentlyAddedRailProps) { + return ( + + ); +}