Skip to content

Add Match Night: swipe-based movie discovery#84

Merged
ianchesal merged 20 commits into
mainfrom
match-night-feature
Jul 23, 2026
Merged

Add Match Night: swipe-based movie discovery#84
ianchesal merged 20 commits into
mainfrom
match-night-feature

Conversation

@ianchesal

Copy link
Copy Markdown
Collaborator

Summary

  • Tinder-style swipe feature ("Match Night"): Ian and Krista independently thumbs-up/down a deck of candidate movies seeded from a bundled Criterion Collection catalog (1,555 films) plus TMDB's popular list; a mutual thumbs-up auto-adds the film to the watchlist with an "It's a match! 🎉" badge.
  • New Prisma models (SwipeCandidate, Swipe) and a Movie.matchedViaSwipe flag; new API routes, page, and components; sidebar/mobile-nav entries.
  • Candidate sourcing shuffles Criterion and TMDB picks together so they're mixed in the deck rather than shown in blocks.
  • Fixes an empty-imdbId edge case that could crash a match, and adds the match badge to the watched view (MovieCard), which the design spec called for but an earlier pass missed.
  • Fixes the Docker build (lockfile sync + a .dockerignore gap that dropped the bundled catalog file from the build context).

Design spec: docs/superpowers/specs/2026-07-22-match-night-design.md
Implementation plan: docs/superpowers/plans/2026-07-22-match-night.md

Test plan

  • npm run test:run — 220/220 passing
  • npx tsc --noEmit — clean
  • npm run lint — clean
  • npm run build — succeeds, /match-night + new API routes present in route manifest
  • Built and deployed to the local Docker container; smoke-tested /watchlist, /match-night, /api/health

🤖 Generated with Claude Code

ianchesal and others added 20 commits July 22, 2026 22:03
Tinder-style swipe feature for discovering new watchlist movies:
independent async swiping, Criterion+TMDB-seeded deck, mutual
thumbs-up auto-adds to watchlist with a match decorator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add explicit concurrency handling (transaction + status re-check for
the up/up race), refill latency/failure/timeout story, drop the
unused upsert semantics, note refill threshold/batch size are
hardcoded for v1, and expand the testing section to cover the race
and stale-swipe cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live testing showed criterion.com sits behind a Cloudflare JS
challenge, so a server-side fetch to either the browse-listing pages
or individual film pages never sees real HTML. Swap the scrape for a
checked-in data/criterion-catalog.json (title/year pairs), resolved
per-entry via the existing TMDB searchByTitle helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 bite-sized TDD tasks covering: schema/types, bundled Criterion
catalog data, TMDB popular helper, deck refill with cursor-tracked
sourcing, swipe/match transaction with concurrency safeguards, API
routes, swipe card + page UI, nav entries, and the watchlist match
badge. Self-review caught and fixed an unused REFILL_THRESHOLD
constant before finalizing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 8 as originally written showed an "It's a match!" banner on the
swipe page itself, which directly contradicted the spec's "no
toast/notification on match" non-goal (surfacing is watchlist-only
via Task 10's badge). Caught during pre-flight plan review; user
confirmed the spec should govern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…atch Night

- Add matchedViaSwipe boolean field to Movie model (default: false)
- Add SwipeCandidate model to track swiped movies with metadata
- Add Swipe model to record individual user swipe votes
- Add TypeScript types: SwipeSource, SwipeStatus, SwipeVote, SwipeCandidateRecord, Swipe interface
- Update Movie interface with matchedViaSwipe field
- Generate migration: 20260723023828_add_match_night

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a static JSON file with 60 classic Criterion Collection films and a
loader function to return them. Used by future deck-refill feature to
resolve real movie data via TMDB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
data/ is gitignored for local runtime data (the mounted SQLite db).
data/criterion-catalog.json is a checked-in static asset, not runtime
data, so it needs an explicit exception rather than a force-add on
every future edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Added FETCH_TIMEOUT_MS (5000ms) constant and fetchWithTimeout wrapper
- Updated fetchDetails to use fetchWithTimeout for request timeout
- Updated searchByTitle's search call to use fetchWithTimeout
- Added new fetchPopularMovies(page) function that:
  * Fetches popular movies from TMDB API
  * Resolves full details for each result via fetchDetails
  * Filters out any results that fail to resolve
  * Returns empty array on fetch error
- Updated test imports to include fetchPopularMovies
- Added 3 new tests for fetchPopularMovies behavior

All 18 tests pass (15 existing + 3 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ards

Adds getNextCandidateForUser() and recordSwipe() to match-night.ts. The
swipe transaction re-checks candidate status inside prisma.$transaction
before recording, so a stale/expired candidate is a safe no-op, and falls
back to reading the existing Movie row on a P2002 race so a mutual
thumbs-up never creates a duplicate.
- GET /api/match-night/next: returns next candidate for a user, validates user and returns 422 for invalid input
- POST /api/match-night/swipe: records a swipe vote, validates user, candidateId, and vote value with 422 responses for invalid input
- All 8 tests passing

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the MatchNightCard presentational component that displays a movie
candidate with poster, title, year, and description. Provides thumbs-up and
thumbs-down buttons for voting, with disabled state during voting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- refillCandidates() now skips any TMDB result with an empty imdbId in
  both the Criterion-resolution loop and the TMDB-popular loop, closing
  a gap where two empty-imdbId candidates could match and crash the
  second movie.create with an uncaught P2025 (the P2002 fallback looks
  up by tmdbId, which won't find a row from a failed create).
- MovieCard (watched view) now renders the "It's a match! 🎉" badge
  when movie.matchedViaSwipe is true, matching MovieRow's existing
  behavior on the watchlist view, per the Match Night design spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Docker build was broken on main for two independent reasons,
both surfaced while building the image for the Match Night release:

- package-lock.json was out of sync with package.json (missing
  @emnapi/core/runtime entries from an earlier dependency bump),
  so `npm ci` failed with EUSAGE. Regenerated inside a node:24-alpine
  container to match the build image's platform-specific optional
  dependency resolution exactly.
- .dockerignore excludes the whole data/ directory (mirroring the
  runtime-data .gitignore convention), which silently dropped
  data/criterion-catalog.json from the build context, breaking the
  Next.js build with a module-not-found in src/lib/criterion-catalog.ts.
  Added the same targeted exception used in .gitignore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Match Night's refill filled the Criterion half of each batch before
the TMDB half, and served candidates oldest-first, so a whole run of
Criterion picks always showed before any TMDB ones instead of being
mixed. Shuffle the batch before insertion so sources interleave, with
an id tiebreaker added to the serving query since same-batch rows can
share a createdAt timestamp.

Also expands the bundled Criterion catalog from 60 to 137 titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
criterion.com blocks server-side scraping (Cloudflare), so the
catalog was hand-compiled from memory (137 titles) — necessarily a
small, imperfect subset. Replaced it with a cleaned, deduped export
from the arrismo/criterioncollection GitHub dataset (spine/year/
title/director for the whole collection), keeping only single-film
entries with a parseable year (1555 films) and dropping box-set/
anthology entries, which lack a year and wouldn't resolve to a
single TMDB movie via searchByTitle anyway.

Source: https://github.com/arrismo/criterioncollection

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ianchesal
ianchesal merged commit 95b771f into main Jul 23, 2026
1 check passed
@ianchesal
ianchesal deleted the match-night-feature branch July 23, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant