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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ python -m ingestion.run_streaming_availability --country GR
This costs at most two requests with the default configuration. The client and
event model still support `removed`, `updated`, and `expiring`, but those types
and the Leaving Soon section are deferred beyond the first public release.
WatchPulse cards and search results link explicitly to TMDB for richer title
details instead of duplicating a full internal details page.

Full pagination is opt-in and remains protected by the request ceiling:

Expand Down
22 changes: 22 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,28 @@ labels this as a new season without claiming a season number absent upstream.
Browser requests never trigger enrichment, and each retained response type is
skipped independently on subsequent runs.

## ADR-021: Use TMDB as the title-details destination

Status: Accepted (2026-09-01)

WatchPulse owns the decision experience: region/provider availability, shared
filters, discovery sections, and concise comparison metadata. For the first
public release it does not reproduce a complete internal title-details page.
Poster links and catalog-search results instead identify TMDB explicitly and
open the canonical TMDB movie or TV page in a new tab.

This keeps the product focused, gives users a clear destination for richer
cast, crew, production, and related-title information, and sends appropriate
traffic back to the free metadata service supporting WatchPulse. Cards may
retain a short overview, year, genres, rating, and runtime/series totals when
available because those fields directly support the discovery decision.

Provider actions remain separate and continue to require verified HTTPS watch
links under ADR-019. TMDB attribution remains visible in the site footer, and
outbound labels must not imply that WatchPulse is endorsed or certified by
TMDB. An internal details route may be reconsidered only if it creates distinct
WatchPulse value rather than duplicating TMDB.

## Open decisions

These remain unresolved and should receive new ADRs when decided:
Expand Down
15 changes: 12 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ the [v0.4 release notes](releases/v0.4.md).

Goal: deliver the complete deterministic user experience locally.

Current status: **in progress (2026-08-23)**. React, TypeScript, and Vite are
Current status: **release candidate (2026-09-01)**. React, TypeScript, and Vite are
selected in ADR-018. The first increment establishes the responsive application
shell, typed local API client, catalog readiness state, explicit development
CORS policy, frontend tests, and CI build validation. The second increment adds
Expand Down Expand Up @@ -185,14 +185,18 @@ desktop filter sidebar, denser rails, and a responsive stacked mobile layout.
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.
Title cards and catalog search now identify TMDB as the canonical destination
for richer title details. WatchPulse intentionally retains only the concise
metadata needed to make a discovery decision. Leaving Soon remains deferred
until reliable expiration ingestion is enabled rather than inferred.

Scope:

- choose and document the frontend framework;
- region selector and region-aware provider selection;
- type, genre, runtime, release-year, and rating filters;
- responsive rails/cards for all five sections;
- title details;
- responsive rails/cards for Top 10, New Releases, Recently Added, and Upcoming;
- explicit outbound TMDB title-details navigation;
- browser-local guest preferences;
- loading, empty, stale-data, and error states.

Expand All @@ -203,6 +207,11 @@ Exit criteria:
- filter changes only query the WatchPulse backend;
- mobile and desktop layouts are credible for a portfolio demonstration.

Release exception: Leaving Soon was part of the original five-section target,
but is intentionally excluded from v0.5 because expiration ingestion is not
enabled. The existing backend endpoint remains evidence-only and returns no
guessed departures.

## v0.6 — Automation and deployment

Goal: make WatchPulse reproducible, observable, and publicly accessible.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test("shows catalog readiness returned by the local API", async () => {
"href",
"https://www.justwatch.com",
);
expect(screen.queryByText(/title details arrive next/i)).not.toBeInTheDocument();
});

test("shows an honest error when the catalog is unavailable", async () => {
Expand Down
1 change: 0 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ export default function App() {
{scope && scope.providers.length === 0 && (
<p className="selection-prompt">Select at least one streaming service to start discovering titles.</p>
)}
<p className="coming-next">Leaving Soon and title details arrive next.</p>
</div>
</div>
<footer className="credits" aria-label="Credits">
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/TitleCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ test("shows total episodes for a currently available series", () => {

expect(screen.getByText("1 season · 9 episodes", { exact: false })).toBeInTheDocument();
expect(screen.getByText("★ 7.5")).toBeInTheDocument();
expect(screen.getByText("View details on TMDB ↗")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "View Current Series on TMDB" })).toHaveAttribute(
"href",
"https://www.themoviedb.org/tv/42",
);
expect(screen.queryByText("50 min", { exact: false })).not.toBeInTheDocument();
expect(screen.getByLabelText("Netflix; direct link unavailable")).toHaveAttribute(
"data-tooltip",
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/components/TitleCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,10 @@ export function TitleCard({ item, rank, lifecycleDate }: TitleCardProps) {
<span>{item.title.slice(0, 1)}</span>
</div>
)}
{item.overview && (
<span className="poster-description" aria-hidden="true">
<span>{item.overview}</span>
</span>
)}
<span className="poster-description" aria-hidden="true">
{item.overview && <span>{item.overview}</span>}
<span className="tmdb-detail-cta">View details on TMDB ↗</span>
</span>
<span className={`rating${hasRating ? "" : " unrated"}`}>
{hasRating ? `★ ${item.tmdb_rating!.toFixed(1)}` : "N/A"}
</span>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/TitleSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ test("searches the selected local catalog after two characters", async () => {
expect(await screen.findByText("Monsters", {}, { timeout: 1500 })).toBeInTheDocument();
expect(screen.getByText("Netflix")).toBeInTheDocument();
expect(screen.getByText("7.2")).toHaveClass("search-rating");
expect(screen.getByRole("option", { name: "View details for Monsters on TMDB" })).toHaveAttribute(
"href",
"https://www.themoviedb.org/movie/42",
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const url = String(fetchMock.mock.calls[0][0]);
expect(url).toContain("query=Mon");
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/TitleSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function TitleSearch({ scope, filters }: Props) {
key={`${item.content_type}-${item.tmdb_id}`}
role="option"
aria-selected="false"
aria-label={`View details for ${item.title} on TMDB`}
href={`https://www.themoviedb.org/${item.content_type}/${item.tmdb_id}`}
target="_blank"
rel="noopener noreferrer"
Expand All @@ -106,6 +107,7 @@ export function TitleSearch({ scope, filters }: Props) {
<strong>{item.title}</strong>
<small>{[item.release_year, item.genre_names.slice(0, 2).join(", ")].filter(Boolean).join(" · ")}</small>
<small>{item.availabilities.map((entry) => entry.provider_name).filter((name, index, names) => names.indexOf(name) === index).join(", ")}</small>
<small className="search-tmdb-link">View details on TMDB ↗</small>
</span>
<span className={item.tmdb_rating && item.tmdb_rating > 0 ? "search-rating" : "search-rating unrated"}>
<span aria-hidden="true">★</span>{item.tmdb_rating && item.tmdb_rating > 0 ? item.tmdb_rating.toFixed(1) : "N/A"}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ h2 { margin: 0; font: 700 22px Manrope, sans-serif; }
.poster-frame::after { position: absolute; inset: auto 0 0; height: 35%; content: ""; background: linear-gradient(transparent, rgb(5 6 9 / 70%)); pointer-events: none; }
.poster-description { position: absolute; z-index: 2; inset: auto 0 0; display: flex; min-height: 42%; padding: 52px 14px 14px; flex-direction: column; justify-content: flex-end; color: #e9e4ef; background: linear-gradient(transparent, rgb(8 7 11 / 94%) 46%); opacity: 0; transform: translateY(8px); transition: opacity 160ms ease 220ms, transform 160ms ease 220ms; pointer-events: none; }
.poster-description > span { display: -webkit-box; overflow: hidden; font-size: 11px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.poster-description .tmdb-detail-cta { display: block; margin-top: 8px; overflow: visible; color: #d5a7ff; font-size: 10px; font-weight: 800; letter-spacing: .02em; -webkit-line-clamp: unset; }
.poster-frame:hover .poster-description, .poster-frame:focus-visible .poster-description { opacity: 1; transform: translateY(0); }
.poster-fallback { display: grid; place-items: center; color: #cdb1f5; background: radial-gradient(circle at 70% 20%, #604085, #21192d 55%, #13151b); }
.poster-fallback span { font: 800 72px Manrope, sans-serif; opacity: .7; }
Expand Down Expand Up @@ -147,6 +148,7 @@ h2 { margin: 0; font: 700 22px Manrope, sans-serif; }
.search-result-copy { display: grid; min-width: 0; gap: 2px; }
.search-result-copy strong { overflow: hidden; font: 700 13px Manrope, sans-serif; text-overflow: ellipsis; white-space: nowrap; }
.search-result-copy small { overflow: hidden; color: #83858e; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.search-result-copy .search-tmdb-link { color: #b984e9; font-weight: 700; }
.search-rating { display: inline-flex; min-width: 48px; align-items: center; justify-content: flex-end; gap: 4px; color: #f4c95d; font-size: 12px; font-weight: 800; }
.search-rating > span { font-size: 13px; }
.search-rating.unrated { color: #8b8d96; font-weight: 700; }
Expand Down