PopChoice

An AI movie picker for solo, duo, and same-device group decisions, combining a guided quiz, hybrid catalog retrieval, and feedback-aware recommendations.

Draft case study

The product and system story is here. The first controlled live run exposed a weak compromise; the ranking policy and broader outcome evidence are still being improved.

Role
Product design & engineering
Ownership
Solo, end to end
Release
v0.2.0
Product status
Live, in active development
  • Solo and duo matching
  • Same-device group mode
  • Bounded recommendations
  • Persistent result links
  • Feedback-aware memory
The current PopChoice home screen in English, with its movie-poster field and primary Find My Movie action.
The live product entry point. Visitors start with one action, then choose match depth and whether the film is for one person, a duo, or a same-device group.

One decision, two contexts

The project began as a technical exercise. Its product direction came from a recurring problem: too many plausible films and no confident choice.

PopChoice began as my project for the Embeddings and Vector Databases module of Scrimba's AI Engineer Path. The course version proved a narrow loop: answer a quiz, embed those answers, search a catalog, and ask a model for the final pick. The technical goal was to turn that exercise into a product-shaped system; the product goal became helping someone stop searching.

Solo and duo are the primary use cases: one person faces endless browsing; two people face a loop of vetoes. Feedback from two frequent film viewers also made repeat avoidance important, which shaped Movie Memory. Version 0.2.0 lets visitors choose Fast Pick or Normal Match, then solo, duo, or a same-device group. A planned QR-room experience remains explicitly unfinished.

From entry to explicit constraints

  1. PopChoice screen offering Fast Pick and Normal Match as two recommendation depths.

    Choose the depth

    Fast Pick asks three quick signals. Normal Match collects a fuller brief for a more deliberate search.

  2. PopChoice Fast Pick audience screen offering Just me, Two people, and Group.

    Choose the audience

    The same entry supports one viewer, a duo, or a same-device group of three to six people.

  3. PopChoice hard-avoids screen with Long runtime and Too obvious selected.

    Set the boundaries

    Hard avoids make dealbreakers explicit before catalog retrieval and model ranking begin.

Why it matters. The product no longer hides every decision inside one long questionnaire. People choose how much guidance they want, who the recommendation must represent, and which dealbreakers the system must respect before the expensive work begins.

The contexts behind one product

  • Solo

    Reduce an open-ended catalog to one useful decision for tonight, without requiring an account or a long setup.

  • Duo

    Keep two taste profiles separate long enough to find acceptable overlap instead of letting one person win the search.

  • Same-device group

    The current experimental group path collects three to six profiles on one device. Independent QR room participation remains planned work.

  • One result contract

    Every path returns a lead pick, alternatives, an explanation, a shareable URL, and feedback actions for signed-in users.

Why it matters. The product is not three disconnected recommenders. It keeps one result contract while changing how many taste profiles must be represented and which compromises are acceptable.

A bounded recommendation

The model may judge and explain candidates, but it cannot invent the set from which it chooses.

The course seed contained twelve films. I expanded it by roughly three hundred titles, including Soviet films for family testing, but that still could not support broad discovery. PopChoice now combines local PostgreSQL and pgvector retrieval with bounded TMDB discovery. Useful external candidates can enter the local index after identity and metadata checks, so the database becomes a reusable cache rather than the whole movie universe.

Constraints come before generation. Referenced, watched, rejected, and recently recommended films leave the pool before OpenAI ranks it. If the model returns an out-of-set title, deterministic code replaces it with the strongest valid candidate and a safe explanation.

The bounded recommendation path

  1. Taste signals

    Quiz answers, audience mode, hard avoids, and account movie memory become one recommendation request.

  2. Persisted job

    The API creates a stable result record before expensive work begins, then workers report progress by stage.

  3. Candidate pool

    pgvector retrieval, curated titles, and bounded TMDB discovery produce a set that already respects constraints.

  4. Guarded ranking

    OpenAI ranks and explains candidates, but an out-of-set title is replaced with the strongest valid match.

  5. Result and memory

    The chosen film, alternatives, evidence, and feedback are stored behind a shareable result URL.

Why it matters. Retrieval owns what may be recommended. The model contributes judgment and language inside that boundary, while deterministic guards control the fallback when its output crosses it.

Reliable through the wait

AI latency became a state-management problem, not a loading-animation problem.

Visitors can start a recommendation without creating an account. That keeps the first use lightweight, while a Redis-backed rate limit protects paid OpenAI work at the public boundary.

Submission first creates a result record and stable URL. BullMQ then reports real stages to that record, so reloads and sharing resolve the same lifecycle instead of restarting work. Failure is terminal in the browser; a later worker retry may require reload, while a fresh quiz creates a new recommendation. If the queue is unavailable, the server can process the persisted job inline without BullMQ retry.

The result lifecycle

  1. Create the record

    The request receives a stable id before recommendation work starts, so the route exists independently of the worker.

  2. Report real progress

    Preparing, embedding, local search, TMDB search, ranking, enrichment, and description stages replace a generic spinner.

  3. Keep the route durable

    Reloading or sharing the URL resolves the same queued, completed, or failed recommendation instead of restarting it.

  4. Expose failure honestly

    Worker and network failures become terminal UI states. BullMQ may retry, but a browser that already observed failure can require a reload; starting again creates a new recommendation.

Why it matters. Persisting first makes waiting observable and keeps the result addressable across progress, reloads, sharing, worker retries, and terminal errors. The remaining recovery gap is explicit: a browser that has already observed failure does not automatically resume polling after a later worker retry.

The persisted result states

  1. PopChoice progress screen showing the Choosing the strongest matches stage.

    Show the wait

    A controlled processing record renders the persisted ranking stage instead of a generic indefinite spinner.

  2. PopChoice deterministic Duo result explaining the shared mood and the inputs from Alice and Bob.

    Resolve two profiles

    The deterministic Normal Match path carries two participants through the browser, API, database, and one stable result URL.

  3. PopChoice failed-result screen with a Start a fresh quiz action.

    Stop and explain

    A controlled terminal state stops polling and starts a fresh quiz rather than pretending to retry the same failed request.

  4. The same completed PopChoice Duo result after reloading its persisted URL.

    Return to the same result

    Reloading the persisted URL resolves the same completed recommendation instead of submitting the quiz again.

Why it matters. These captures come from the isolated deterministic development harness. They prove the visible state and persistence contracts—including the same completed result after reload—not live model quality, production reliability, or a real worker incident.

Operations changed in three steps. Supabase let the course version ship, but its free database could sleep. Railway made a service-based setup easy, then grew to about $60 per month. Railway could have been optimized; I chose a Hetzner VPS with Coolify instead. It costs roughly a quarter as much, hosts more projects, and gave me direct deployment responsibility.

That move also added staging, source-controlled observability, and Telegram alerts. It is a practical first implementation, not a claim of mature platform expertise; deeper operational evidence remains a future deep dive.

When more code stopped helping

AI made implementation faster, but it also made weak boundaries more expensive.

I began this project as a frontend developer who had not worked directly with databases. It became my first substantial full-stack system and my first built mainly through AI-directed implementation. As features accumulated, product behavior, infrastructure, and request handling blurred together. I could abandon the project or rebuild its architecture; I chose the second path.

The refactor turned ownership into an explicit contract. The workspace separates the public app, internal tools, workers, services, and shared packages. Routes parse requests, feature modules coordinate product behavior, and integrations isolate external systems. These boundaries also give coding agents a map instead of one undifferentiated repository.

Fallow made the scale of the cleanup concrete. The first focused report for the web app found 103 dead-code findings, 194 duplicate groups, and 169 complexity findings. I treated those numbers as a map, not a deletion list. The later repo-wide pass reached zero actionable complexity and dead-code findings and an 87.9 (A) health score. A blocking new-only audit now gives every contributor—including agents—machine-checkable pressure against adding the same debt back.

Architecture as a maintained contract

Why it matters. Placement rules and recorded friction points make the architecture usable during implementation and review. The same vocabulary helps human contributors and AI agents find the right context without treating the repository as one undifferentiated codebase.

Live, in active development

PopChoice is already operated as a real product, but its current outcome is a platform for improving recommendations—not a claim that recommendation quality is solved.

  • Validate recommendation quality

    The first controlled live run selected the deliberately weaker comparison candidate, and I rejected the compromise. The next step is to make bridge fit explicit in the ranking policy before running the evaluation again.

  • Unify taste signals

    Quiz answers, Movie Memory, and result feedback already influence recommendations. I am working toward one explicit signal model and a lower-friction swipe path.

  • Strengthen the product language

    The interface, light and dark themes, style guide, mascot, and broader brand still need to become one coherent system rather than a set of good individual surfaces.

Technology stackShow

Application

  • Next.js
  • React
  • TypeScript
  • Tailwind CSS
  • XState
  • TanStack Query
  • Motion
  • Radix UI

Data & async

  • PostgreSQL
  • pgvector
  • Redis
  • BullMQ
  • Zod
  • Node.js workers

External services

  • OpenAI API
  • TMDB API
  • Resend

Internal tools

  • Backoffice
  • Bull Board
  • Storybook
  • Fumadocs

Hosting & delivery

  • Vercel
  • Railway
  • Hetzner VPS
  • Docker Compose
  • Coolify
  • GHCR
  • GitHub Actions

Observability

  • Pino
  • OpenTelemetry
  • Prometheus
  • Grafana
  • Loki
  • Tempo
  • Alloy
  • Uptime Kuma

Testing & quality

  • Vitest
  • Playwright
  • axe-core
  • MSW
  • Recommendation evals
  • Fallow
  • Oxc
  • CodeQL

Design & prototyping

  • Figma
  • Figma Make
  • GPT Image 1