Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -70,12 +71,13 @@ export default function App() {
<GlobalFilters scope={scope} value={filters} onChange={setFilters} />
<TopTenRail scope={scope} filters={filters} />
<NewReleasesRail scope={scope} filters={filters} />
<RecentlyAddedRail scope={scope} filters={filters} />
</>
)}
{scope && scope.providers.length === 0 && (
<p className="selection-prompt">Select at least one streaming service to refine your catalog.</p>
)}
<p className="coming-next">Recently Added, Upcoming, and Leaving Soon arrive next.</p>
<p className="coming-next">Upcoming and Leaving Soon arrive next.</p>
</main>
);
}
12 changes: 12 additions & 0 deletions frontend/src/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,15 @@ export async function getNewReleases(
);
return response.items;
}

export async function getRecentlyAdded(
scope: CatalogScope,
filters: GlobalFilters,
signal?: AbortSignal,
): Promise<CatalogItem[]> {
const response = await getJson<{ items: CatalogItem[] }>(
`/api/v1/discovery/recently-added?${discoveryParams(scope, filters)}`,
signal,
);
return response.items;
}
100 changes: 100 additions & 0 deletions frontend/src/components/CatalogRail.tsx
Original file line number Diff line number Diff line change
@@ -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<CatalogItem[]>;

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<RailState>({ 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 (
<section className="discovery-rail" aria-labelledby={id} aria-busy={state.kind === "loading"}>
<div className="rail-heading">
<div>
<p className="status-label">{subtitle}</p>
<h2 id={id}>{title}</h2>
</div>
{state.kind === "ready" && state.items.length > 0 && <span>{state.items.length} titles</span>}
</div>

{state.kind === "loading" && (
<div className="title-row skeleton-row" aria-label={loadingLabel}>
{Array.from({ length: 5 }, (_, index) => <div className="card-skeleton" key={index} />)}
</div>
)}

{state.kind === "error" && (
<div className="rail-message" role="alert">
<p>{state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>Try again</button>
</div>
)}

{state.kind === "ready" && state.items.length === 0 && (
<div className="rail-message">
<p>{emptyTitle}</p>
<span>{emptyHint}</span>
</div>
)}

{state.kind === "ready" && state.items.length > 0 && (
<div className="title-row">
{state.items.map((item) => (
<TitleCard item={item} key={`${item.content_type}-${item.tmdb_id}`} />
))}
</div>
)}
</section>
);
}
82 changes: 14 additions & 68 deletions frontend/src/components/NewReleasesRail.tsx
Original file line number Diff line number Diff line change
@@ -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<RailState>({ 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 (
<section
className="discovery-rail"
aria-labelledby="new-releases-title"
aria-busy={state.kind === "loading"}
>
<div className="rail-heading">
<div>
<p className="status-label">Recently released movies and series</p>
<h2 id="new-releases-title">New Releases</h2>
</div>
{state.kind === "ready" && state.items.length > 0 && <span>{state.items.length} titles</span>}
</div>

{state.kind === "loading" && (
<div className="title-row skeleton-row" aria-label="Loading New Releases">
{Array.from({ length: 5 }, (_, index) => <div className="card-skeleton" key={index} />)}
</div>
)}

{state.kind === "error" && (
<div className="rail-message" role="alert">
<p>{state.message}</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>Try again</button>
</div>
)}

{state.kind === "ready" && state.items.length === 0 && (
<div className="rail-message">
<p>No recent releases match these filters.</p>
<span>Try widening the year, genre, runtime, or rating filters.</span>
</div>
)}

{state.kind === "ready" && state.items.length > 0 && (
<div className="title-row">
{state.items.map((item) => (
<TitleCard item={item} key={`${item.content_type}-${item.tmdb_id}`} />
))}
</div>
)}
</section>
<CatalogRail
id="new-releases-title"
title="New Releases"
subtitle="Recently released movies and series"
loadingLabel="Loading New Releases"
emptyTitle="No recent releases match these filters."
emptyHint="Try widening the year, genre, runtime, or rating filters."
errorFallback="Unable to load New Releases"
scope={scope}
filters={filters}
load={getNewReleases}
/>
);
}
74 changes: 74 additions & 0 deletions frontend/src/components/RecentlyAddedRail.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RecentlyAddedRail
scope={{ region: "GR", providers: ["netflix"] }}
filters={{ ...EMPTY_FILTERS, contentType: "movie" }}
/>,
);

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(
<RecentlyAddedRail
scope={{ region: "GR", providers: ["netflix"] }}
filters={EMPTY_FILTERS}
/>,
);

expect(
await screen.findByText("No recently added titles match these filters."),
).toBeInTheDocument();
});
25 changes: 25 additions & 0 deletions frontend/src/components/RecentlyAddedRail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CatalogRail
id="recently-added-title"
title="Recently Added"
subtitle="Fresh arrivals on your streaming services"
loadingLabel="Loading Recently Added"
emptyTitle="No recently added titles match these filters."
emptyHint="Try another provider or remove one of the active filters."
errorFallback="Unable to load Recently Added"
scope={scope}
filters={filters}
load={getRecentlyAdded}
/>
);
}