Dev Journal

Trailer Vision build log — Monday, April 13, 2026 (updated through April 16, 2026)

The Idea

Starting prompt (yours):TikTok doesn't ask you what you like — it watches how you react. Apply the same idea to movies: an LLM picks a title, predicts your rating, you rate it 0–100, and the error is revealed. Over time the AI gets better. The goal is to minimize error.
Deeper insight (yours, later): The real aim of the app is to surface films you have notseen but will love. Rating movies you've already seen is the training signal — the accuracy chart shows the work the AI is doing to get there. The watchlist is the point; everything else is calibration.
Channels as islands:Each taste channel is its own recommendation context — its own prefetch queue, its own filters sent to the LLM, and its own rows in history (tagged with channelId). The same movie can legitimately carry differentuser and predicted ratings in different channels (separate history entries); the per-channel and “All” lists only show the slice that belongs to that island.
Concept

Core mechanic

Each round: LLM selects a movie/TV title and makes a hidden prediction. User rates with half-star red (seen) or blue (unseen interest) stars on a 1–5 scale (mapped to the model's 0–100 space). Prediction error feeds the chart. A rolling-window accuracy chart (last 5 decisions) tracks improvement over time. No title is ever shown twice — enforced both in the prompt and client-side.

Your Design Decisions

UX

Slider instead of number input

The original number input felt like filling out a form. You asked for a slider, which turned out to feel much more like a judgment — drag to where it feels right, hit Submit. The live large-number display next to the label replaced the need for any typed value.

UX

Remove the Next button — auto-advance after rating

After submitting a rating, the original design paused to show a result screen with a Next button. You pointed out the extra click was friction: show the result info below the chart instead, and immediately load the next movie. This made the loop feel like a card swipe rather than a form submission.

UX

Clear labeling of "seen" vs "not seen" sections

After watching someone use the app, it was clear the two halves of the card were ambiguous. The rating slider is now inside a box labeled "I've seen it — rate it" and the two skip buttons are in a separate box labeled "Haven't seen it". "Want to watch" is green-tinted to signal it's a save action.

Feature

Movie details: poster, cast, plot, director, year

You asked for the main actors, a plot summary, director, release year, and a poster on each card. The LLM returns all metadata as structured JSON; the Serper image search API fetches a poster (year included in the query to avoid getting a different version). The card became a proper movie entry rather than a bare title.

Feature

Rotten Tomatoes score

You wanted a professional rating to show alongside yours after the reveal. The LLM returns the RT Tomatometer from its training knowledge (e.g. "91%"). A 🍅 / 💀 badge renders on the card, giving immediate external context for how your taste compares to critics.

Feature

Two kinds of "haven't seen it"

You identified two distinct signals: films you want to see (LLM got your taste right — a positive outcome, score 85/100) vs films you have no interest in (LLM missed entirely — score 20/100). "Want to watch" adds to the watchlist. "Not interested" is discarded. Both are excluded from future picks.

Feature

Watchlist with streaming info

Titles marked "Want to watch" are saved to a persistent watchlist with full metadata. On save, the selected LLM is asked which streaming services carry the title in the US; results appear as blue pills on the dedicated /watchlist page and in the Watchlist tab on /ratings.

Feature

Shared navigation bar

A sticky nav bar at the top of every page (rendered in app/layout.tsx) links to App, Watchlist (/watchlist — global list), Channels, Settings, and Help. Help links to Dev Journal and Prompt History. The active page is highlighted. Ratings (/ratings) is not in the bar but lists Seen, Watchlist, and Not interested tabs in one place.

Feature

System/user prompt split with Anthropic caching

callLLM() was refactored from a single prompt argument to (systemPrompt, userMessage). The stable instruction block goes in the system prompt with cache_control: { type: 'ephemeral' } and the anthropic-beta: prompt-caching-2024-07-31 header — Anthropic only re-bills the system prompt on cache miss. OpenAI caches prefixes ≥1024 tokens automatically. Gemini uses the systemInstruction field. The split also makes the per-request payload smaller.

Feature

Prefetch queue (capped for freshness)

Each LLM request asks for 5 titles. Up to 3 replenishes run concurrently (daisy-chained). HIGH_WATER_MARK = 6 caps how many cards sit buffered ahead so new ratings influence upcoming picks sooner; tradeoff is a slightly higher chance of a short wait if the queue drains. Yield per batch is tracked for diagnostics; the client does not currently auto-scale request count from yield.

Feature

Smooth transitions + "LLM is thinking" indicator

While the next title loads, the current card dims to 45% opacity so the layout doesn't jump. A fixed pill at the bottom reads "LLM is thinking…" with bouncing dots, visible regardless of scroll position. When the response arrives, the card fades out, content swaps, then fades back in. With the prefetch queue in place, the indicator almost never appears after the first load.

UX

Removed reveal popup

After adding the prefetch queue, advancing to the next card became nearly instant — the modal that showed your score / AI score / error flashed for only a few milliseconds before disappearing, which felt worse than nothing. Removed it. The last result is still shown inline in the chart panel.

UX

Mobile-responsive layout

On small screens the movie card now stacks vertically: the poster becomes a full-width banner image (cropped to a fixed height) above the metadata and rating controls. The nav bar hides the brand name on xs to give the four links room. Long movie titles in the recent ratings list truncate with ellipsis rather than overflowing.

Feature

Media type filter and LLM selector

A segmented control restricts picks to Movies, TV Series, or both. If the current card doesn't match a newly selected type, a fresh fetch fires immediately. A second control lists every LLM whose API key is present in .env (DeepSeek, Claude, GPT-4o, Gemini); the selection is live.

Feature

Poster lightbox

The poster renders at w-72 on the card. Clicking it opens a full-screen lightbox (black overlay, Escape to close). Page max-width is max-w-3xl to give the poster room without making the text too wide.

Feature

Scaling: informative history selection

As ratings accumulate, sending the full history to the LLM overflows the context budget. Instead, the server selects the most informative subset: ratings where |user score − RT score| is largest (divergence from critic consensus reveals the most about taste per token), plus the most recent entries for freshness. The LLM still receives the full exclusion count but not the full title lists.

Feature

RT-divergence taste signals

RatingEntry now stores rtScore. The server curates two extra taste signals beyond raw ratings: want-to-watch items with low RT scores (user liked what critics didn't), and not-interested items with high RT scores (user dismissed what critics loved). Both are stored in localStorage and sent with each request. These are the most informative signals the LLM can receive about how this user's taste differs from the mainstream.

Feature

Taste profile card (second person)

A separate /api/taste-summary endpoint generates a 2–4 sentence taste profile addressed directly to the viewer in second person ('You tend to prefer…'). It runs in the background after the first rating and every 5 thereafter — decoupled from the movie batch so it doesn't slow down recommendations. The profile is stored in localStorage and displayed as a card with a purple left border between the accuracy chart and the movie card.

Feature

Diversity lenses — the key to scaling

Without explicit direction the LLM defaults to the same ~300 culturally visible titles. The fix: each batch request carries a rotating 'diversity lens' — a hard constraint like 'films from the 1970s' or 'South Korean cinema' or 'overlooked gems with low name recognition'. 24 lenses cycle through decades, world regions, and genres. With 3 concurrent batches running, three different corners of cinema are explored simultaneously. This unlocked the full breadth of the LLM's knowledge.

Feature

Daisy-chain replenishment — always filling

The old approach triggered replenishment only when the queue dropped below a threshold. If LLM latency spiked or yield was low, the queue drained to empty and the user waited. The new approach daisy-chains: each completed batch immediately starts another if the queue is below the high-water mark (6 items) and a slot is free. Up to 3 concurrent fetches run continuously. A zero-yield streak counter (resets on any user action) stops the chain after 3 consecutive zero-yield batches to avoid an infinite loop when the LLM is stuck.

Feature

Pre-display exclusion check

A race condition could allow a title to sit in the prefetch queue and then be rated or skipped before it was displayed. The fetchNext pop loop now checks each candidate against the live excluded set (history + skipped + watchlist) and silently discards any stale entry before showing it.

Feature

Re-rate and reconsider from history lists

Any row in the All Ratings list or the Not Interested list is now clickable. Clicking a rated title removes it from history and loads it as the current card so you can change your score. Clicking a not-interested title removes it from the skipped and not-interested lists and loads it as the current card — you can then rate it or add it to the watchlist. Deleting a title from the Watchlist page now also moves it to Not Interested (adds to skipped + not-interested) instead of just discarding it.

Feature

Trailer-based rating — TikTok for movies

When TMDB returns a YouTube trailer key for a title, the card shows the trailer instead of the poster. The trailer autoplays and a progress bar reflects watch time. A Trailers/Posters toggle in the controls lets you switch layouts. Volume is restored between cards (session-only, using a module-level variable).

Feature

Star rating system — compact one-line bar

Red stars (Seen it) submit in one tap when you’ve seen the film. If you haven’t, tap Not yet first, then blue stars (Interest); I have seen it switches back. Trailer and poster layouts share the same controls: one horizontal row with compact StarRow, Not yet, and Next (with horizontal scroll on very narrow screens). Blue 4–5 stars add to the global watchlist; blue 1–3 mark not interested. Trailer embed uses the YouTube IFrame API; no live watch-% auto-rating in the current build.

Feature

User request field — hard constraint

A free-text input below the controls steers every batch: 'Japanese cinema', 'slow-burn thrillers', '90s Hong Kong action'. When a request is set it replaces the diversity lens entirely and is phrased as a hard constraint — every item returned must match it. The prefetch queue is flushed on change (debounced 600ms) so the next card comes from a batch that already knew about the request.

Feature

Channels, per-channel prefetch, and starter pack

Each channel is its own recommendation island: separate prefetch queue, separate activeChannel payload to the LLM, and separate history rows (channelId). The same title can exist more than once in movie-recs-history with different channelIds and different user vs predicted scores — e.g. loving a film in a narrow-genre channel but rating it lower in “All”. Users define channels on /channels; active channel is stored separately; queue key movie-recs-prefetch-queue:{id}. factory-channels.json seeds on first visit; Settings and the home row offer merge of starter channels without wiping data. Channel pills wrap; stronger labels; non-All chips get hover × + confirm to delete.

Feature

Channel history, unseen log, and watchlist promotion

The /channels right panel is Channel history: seen rows match /ratings → Seen; unseen rows come from movie-recs-unseen-interest-log with pills (Added / Not on list vs Not interested). Add to watchlist promotes titles not already on the global watchlist that meet the minimum blue stars — high-interest skips and want rows you removed from the list — then strips skips from skipped + not-interested and runs streaming lookup.

Feature

Ratings views: delta vs AI, no critic badge

The /ratings “Seen” tab and seen rows on /channels no longer show the Rotten Tomatoes badge on each row. Instead they show the user’s red stars plus a signed half-star delta (user rating minus predicted rating), e.g. +1.5 or −2, color-coded vs zero. A sort bar toggles between sorting by the user’s stars vs. by delta (vs predicted). Shared formatting lives in app/lib/ratingDelta.ts. On the “All” channel, seen history includes legacy rows with no channelId as well as channelId === "all".

UX

Poster-only titles link to YouTube

When the card is in poster layout because there is no embedded trailer (no trailerKey), the title links to a YouTube search in a new tab (title + year + “movie trailer” / “TV series trailer”) so users can still find footage quickly.

UX

Star row labels easier to read

The “Rate it” / “Level of interest” labels beside the star rows on the main card use larger, darker type (text-sm font-semibold text-zinc-800) so they are not lost against the background.

Bugs Fixed

Fix

SVG NaN errors in the chart

On first render the chart produced NaN for SVG attributes. Root causes: dividing by zero on a single data point, and stale localStorage entries with no error field. Fixed by filtering invalid entries, centering the single-point case, and rendering a <circle> for one data point.

Fix

Same title shown twice

LLMs occasionally ignore the exclusion list. The client now maintains a definitive excluded set (all rated + all skipped, case-insensitive) and retries up to 8 times, accumulating each duplicate into extraSkip. The card is never set unless a non-duplicate is confirmed.

Fix

AI Predicted blank / Error NaN

The LLM returned predicted_rating (snake_case) but the TypeScript interface expected predictedRating (camelCase). A type assertion silently accepted the wrong shape. Fixed by explicitly mapping the raw field after parsing.

Fix

Poster showed wrong version (e.g. 1930 instead of 1993)

The Serper image search query did not include the year, so for remade titles the wrong version's poster appeared. Fixed by appending the release year to the query.

Fix

No poster on Vercel (works on localhost)

Serper occasionally returns http:// image URLs. Browsers block mixed content on HTTPS pages. Fixed by upgrading all poster URLs to https:// before returning them. A missing SERPER_API_KEY env var on Vercel was also a factor.

Fix

LLM response contained two JSON objects

Claude sometimes thinks out loud and emits reasoning text between two JSON objects. A greedy regex captured the reasoning. Fixed with a brace-depth walker that collects every complete top-level object and takes the last one.

Chart Design

UX

Accuracy, up is good, rolling window

Shows accuracy (100 − error) so up is always better. Rated titles = blue bars. “Want to watch” = green diamonds at y=85. “Not interested” = red diamonds at y=20. Indigo line = rolling average over last 5 decisions (not cumulative). Reference lines mark the two thresholds. Hand-rolled SVG, no library.

Files

  • app/page.tsx main UI — card, chart, per-channel prefetch, channel chips, controls
  • app/layout.tsx root layout — renders shared NavBar above all pages
  • app/components/NavBar.tsx sticky nav — App, Watchlist, Channels, Settings, Help
  • app/channels/page.tsx channel CRUD, per-channel ratings list with delta + sort
  • app/settings/page.tsx settings, export/import backup, merge starter channels, reset
  • app/ratings/page.tsx Seen / Watchlist / Not interested tabs; Seen uses delta + sort
  • app/lib/factoryChannels.ts bootstrap + merge from factory-channels.json
  • app/lib/ratingDelta.ts starDelta + formatStarDelta for ratings UIs
  • app/lib/unseenInterestLog.ts append/load unseen blue-star log keyed by channel
  • app/lib/storageKeys.ts prefetch queue key helpers, export key listing
  • factory-channels.json bundled example channels + prefetch queues for first-run seed
  • app/watchlist/page.tsx watchlist — poster, metadata, streaming pills, remove
  • app/journal/page.tsx this page
  • app/prompt/page.tsx reconstruction prompt with copy button
  • app/api/next-movie/route.ts batch LLM pick + diversity lens + poster fetches
  • app/api/next-movie/llm.ts callLLM(llm, systemPrompt, userMessage) for all providers
  • app/api/next-movie/historySessionStore.ts server-side session cache so history isn't resent every request
  • app/api/taste-summary/route.ts background taste profile generation (2nd person, ~256 tokens)
  • app/api/streaming/route.ts streaming availability lookup
  • app/api/config/route.ts returns which LLM keys are configured

Stack

Framework: Next.js 16 (App Router, Turbopack)
LLMs: DeepSeek, Claude, GPT-4o, Gemini — selectable at runtime
Poster search: Serper Images API
Persistence: localStorage
Styling: Tailwind CSS v4
Chart: Hand-rolled SVG

Reconstruction Prompt

Prompt

Build this app from scratch

A complete specification you can paste into any coding agent to rebuild a near-identical app.

App Prompt History →