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
5 changes: 4 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ shell, typed local API client, catalog readiness state, explicit development
CORS policy, frontend tests, and CI build validation. The second increment adds
API-driven region and region-aware provider selection, validates restored guest
choices against the current catalog, and persists valid preferences locally in
the browser.
the browser. The third increment adds the shared type, genre, runtime,
release-year, rating, and language filter contract that every discovery rail
will consume, including scoped reference data, safe query serialization, reset,
validation, and local persistence.

Scope:

Expand Down
2 changes: 2 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Open <http://127.0.0.1:5173>. The initial application shell displays the API
and catalog status to prove the browser-to-backend path is working. Regions and
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.

Vite reads `VITE_API_BASE_URL`; copy `.env.example` to `.env.local` only when
the backend is not available at the default `http://127.0.0.1:8000` address.
Expand Down
29 changes: 26 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { type CatalogStatus, getCatalogStatus } from "./api/catalog";
import { DiscoverySetup } from "./components/DiscoverySetup";
import { GlobalFilters } from "./components/GlobalFilters";
import { type CatalogScope, type GlobalFilters as FilterValue } from "./discovery";
import { loadPreferences } from "./preferences";
import "./styles.css";

type LoadState =
Expand All @@ -15,6 +18,20 @@ const formatRefresh = (value: string) =>

export default function App() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [scope, setScope] = useState<CatalogScope | null>(null);
const [filters, setFilters] = useState<FilterValue>(() => {
const saved = loadPreferences();
return {
contentType: saved.contentType,
genreIds: saved.genreIds,
runtimeMax: saved.runtimeMax,
releaseYearFrom: saved.releaseYearFrom,
releaseYearTo: saved.releaseYearTo,
ratingMin: saved.ratingMin,
language: saved.language,
};
});
const handleScopeChange = useCallback((nextScope: CatalogScope | null) => setScope(nextScope), []);

useEffect(() => {
const controller = new AbortController();
Expand Down Expand Up @@ -59,8 +76,14 @@ export default function App() {
</div>
)}
</section>
{state.kind === "ready" && <DiscoverySetup />}
<p className="coming-next">Global filters and discovery rails arrive in the next increment.</p>
{state.kind === "ready" && <DiscoverySetup onScopeChange={handleScopeChange} />}
{scope && scope.providers.length > 0 && (
<GlobalFilters scope={scope} value={filters} onChange={setFilters} />
)}
{scope && scope.providers.length === 0 && (
<p className="selection-prompt">Select at least one streaming service to refine your catalog.</p>
)}
<p className="coming-next">Discovery rails arrive in the next increment.</p>
</main>
);
}
33 changes: 33 additions & 0 deletions frontend/src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { catalogScopeParams, type CatalogScope, type ContentType } from "../discovery";

const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000").replace(
/\/$/,
"",
Expand All @@ -15,6 +17,14 @@ export type CatalogFreshness = {
export type CatalogStatus = { api: "online"; freshness: CatalogFreshness };
export type RegionOption = { code: string };
export type ProviderOption = { key: string; name: string };
export type GenreOption = { content_type: string; id: number; name: string };
export type FilterOptions = {
content_types: string[];
languages: string[];
runtime_minutes: { minimum: number | null; maximum: number | null };
release_year: { minimum: number | null; maximum: number | null };
rating: { minimum: number | null; maximum: number | null };
};

async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
Expand Down Expand Up @@ -52,3 +62,26 @@ export async function getProviders(
);
return response.providers;
}

export async function getGenres(
scope: CatalogScope,
contentType: ContentType | null,
signal?: AbortSignal,
): Promise<GenreOption[]> {
const response = await getJson<{ genres: GenreOption[] }>(
`/api/v1/catalog/genres?${catalogScopeParams(scope, contentType)}`,
signal,
);
return [...new Map(response.genres.map((genre) => [genre.id, genre])).values()];
}

export async function getFilterOptions(
scope: CatalogScope,
contentType: ContentType | null,
signal?: AbortSignal,
): Promise<FilterOptions> {
return getJson<FilterOptions>(
`/api/v1/catalog/filter-options?${catalogScopeParams(scope, contentType)}`,
signal,
);
}
10 changes: 5 additions & 5 deletions frontend/src/components/DiscoverySetup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test("loads region-aware providers and persists the guest selection", async () =
{ key: "disney_plus", name: "Disney+" },
] }));

render(<DiscoverySetup />);
render(<DiscoverySetup onScopeChange={vi.fn()} />);

const netflix = await screen.findByRole("button", { name: /Netflix/ });
fireEvent.click(netflix);
Expand All @@ -39,13 +39,13 @@ test("changing region reloads providers and removes an invalid selection", async
.mockImplementationOnce(() => jsonResponse({ region: "GR", providers: [{ key: "netflix", name: "Netflix" }] }))
.mockImplementationOnce(() => jsonResponse({ region: "US", providers: [{ key: "hulu", name: "Hulu" }] }));

render(<DiscoverySetup />);
render(<DiscoverySetup onScopeChange={vi.fn()} />);
expect(await screen.findByRole("button", { name: /Netflix/ })).toHaveAttribute("aria-pressed", "true");
fireEvent.change(screen.getByLabelText("Region"), { target: { value: "US" } });

expect(await screen.findByRole("button", { name: /Hulu/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Netflix/ })).not.toBeInTheDocument();
await waitFor(() => expect(localStorage.getItem("watchpulse.discovery-preferences.v1")).toBe(
JSON.stringify({ region: "US", providers: [] }),
));
await waitFor(() => expect(JSON.parse(
localStorage.getItem("watchpulse.discovery-preferences.v1") ?? "{}",
)).toMatchObject({ region: "US", providers: [] }));
});
16 changes: 13 additions & 3 deletions frontend/src/components/DiscoverySetup.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { getProviders, getRegions, type ProviderOption } from "../api/catalog";
import { loadPreferences, savePreferences } from "../preferences";
import type { CatalogScope } from "../discovery";

type SetupState =
| { kind: "loading-regions" }
Expand All @@ -10,7 +11,11 @@ type SetupState =

const regionNames = new Intl.DisplayNames([navigator.language], { type: "region" });

export function DiscoverySetup() {
type DiscoverySetupProps = {
onScopeChange: (scope: CatalogScope | null) => void;
};

export function DiscoverySetup({ onScopeChange }: DiscoverySetupProps) {
const saved = loadPreferences();
const [regions, setRegions] = useState<string[]>([]);
const [region, setRegion] = useState(saved.region ?? "");
Expand Down Expand Up @@ -55,8 +60,13 @@ export function DiscoverySetup() {
}, [region]);

useEffect(() => {
if (state.kind === "ready") savePreferences({ region, providers: selectedProviders });
}, [region, selectedProviders, state.kind]);
if (state.kind === "ready") {
savePreferences({ region, providers: selectedProviders });
onScopeChange({ region, providers: selectedProviders });
} else {
onScopeChange(null);
}
}, [region, selectedProviders, state.kind, onScopeChange]);

const toggleProvider = (key: string) => {
setSelectedProviders((current) =>
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/GlobalFilters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, expect, test, vi } from "vitest";
import { EMPTY_FILTERS, type GlobalFilters as FilterValue } from "../discovery";
import { GlobalFilters } from "./GlobalFilters";

afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});

const response = (value: unknown) =>
Promise.resolve(new Response(JSON.stringify(value), { status: 200 }));
const SCOPE = { region: "GR", providers: ["netflix"] };

function Harness() {
const [filters, setFilters] = useState<FilterValue>(EMPTY_FILTERS);
return (
<GlobalFilters
scope={SCOPE}
value={filters}
onChange={setFilters}
/>
);
}

test("loads scoped options, applies filters, persists them, and clears them", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockImplementation((input) => String(input).includes("/genres?") ? response({ genres: [
{ content_type: "movie", id: 35, name: "Comedy" },
{ content_type: "movie", id: 18, name: "Drama" },
] }) : response({
content_types: ["movie", "tv"],
languages: ["el", "en"],
runtime_minutes: { minimum: 22, maximum: 180 },
release_year: { minimum: 1990, maximum: 2026 },
rating: { minimum: 4.5, maximum: 9.1 },
}));

render(<Harness />);

const comedy = await screen.findByRole("button", { name: "Comedy" });
fireEvent.click(comedy);
fireEvent.change(screen.getByLabelText("Runtime"), { target: { value: "120" } });
fireEvent.change(screen.getByLabelText("Minimum rating"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("Language"), { target: { value: "el" } });
fireEvent.change(screen.getByLabelText("Release year from"), { target: { value: "2020" } });

expect(comedy).toHaveAttribute("aria-pressed", "true");
expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual(expect.arrayContaining([
expect.stringContaining("region=GR&providers=netflix"),
]));
await waitFor(() => expect(JSON.parse(
localStorage.getItem("watchpulse.discovery-preferences.v1") ?? "{}",
)).toMatchObject({ genreIds: [35], runtimeMax: 120, ratingMin: 7, language: "el", releaseYearFrom: 2020 }));

fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
expect(screen.getByLabelText("Runtime")).toHaveValue("");
expect(comedy).toHaveAttribute("aria-pressed", "false");
});
Loading