MovieDice is a mobile-first web app that helps friend groups collaboratively build a shared movie watchlist and — when nobody can agree on what to watch — randomly select one using an animated "Roll the Dice" mechanic. Groups join via a short invite code, add movies from a live TMDB search, and mark films as watched together. The app removes the friction of the "what should we watch?" problem by making both curation and random selection fast and fun.
A public landing page lets visitors try the dice mechanic against the TMDB database before signing up, lowering the barrier to entry. The landing page features a slot-machine-style reel animation on roll — distinct from the in-app scatter/eliminate animation — to create a visually striking first impression.
Primary: Friend groups, couples, roommates, or families who watch movies together and maintain an informal "we should watch this" list that currently lives in text threads or notes apps.
Secondary: Remote groups (long-distance friends, online communities) who watch movies synchronously or asynchronously and want a shared queue.
Key traits:
One shared list. One button to decide. No arguments.
MovieDice solves group decision paralysis by combining collaborative curation (everyone adds what they want to watch) with a delightful randomizer that removes the burden of choosing. The invite-code group model means zero signup friction while still keeping lists private to the group.
| Feature | Description | Priority |
|---|---|---|
| Landing page | Centered logo, splash text, slot-machine reel animation on Roll the Dice (3 reels spinning through ~20 automatically fetched posters from TMDB popular/top-rated, replaced on each periodic refresh), Genre Roll against TMDB (no login required), Login button, scrolling About section, 3-step how-it-works demo with alternating left-right-left alignment, TMDB attribution footer, privacy policy link | Must Have |
| Anonymous auth via Supabase | User picks a display name and optional avatar color; account created via supabase.auth.signInAnonymously() which issues a JWT for RLS-compatible sessions; persisted on device |
Must Have |
| Recovery code | A 24-character alphanumeric code (128-bit entropy) shown once after account creation that lets users reclaim their identity on a new device; hashed with Argon2id before storage; single-use (invalidated after successful claim); claim endpoint rate-limited (5 failed attempts per IP per 15-minute window) | Must Have |
| Group creation with invite code | Creator gets a short human-readable code in WORD-WORD format (e.g., WOLF-MOON) to share; creator becomes List Admin | Must Have |
| Group join via invite code | Enter code to join a group and access its shared list; regular member role assigned; join endpoint rate-limited (5-10 failed attempts per IP per window) | Must Have |
| List Admin permissions | Creator can rename the list, initiate list deletion or ownership transfer (on self-removal), remove members, and regenerate the invite code | Must Have |
| Regular user permissions | Members can add/remove movies, mark movies as watched, and leave the list | Must Have |
| Movie search (TMDB integration) | Search bar queries TMDB via server-side API proxy (/api/tmdb/*) with debounce (~300ms); results show below a separator from in-list results |
Must Have |
| Add/remove movie | Tap a TMDB result to add it; poster, genres, title, year, and trailer URL auto-populate (trailer URL stored in DB and refreshed periodically); added-by attribution stored | Must Have |
| Poster-forward grid view | 2-column evenly-scaling grid; each card shows movie poster (full bleed, using TMDB native sized URLs) with title below; added-by avatar overlaid top-right; binoculars emoji overlaid top-left when watched; infinite scroll loading 12 movies initially | Must Have |
| Expanded movie card (inline panel) | Tapping a poster expands a full-page-width panel downward, inserted below that row in the grid — not a modal or popup. Panel order (top to bottom): full-size poster → title → "Added by [username]" → genre tags → Watched It + Trailer (side by side) → Delete (centered below). Delete uses two-tap shake-and-confirm. Watched It toggles watched state. Trailer opens in new tab. Panel collapses on tap outside. | Must Have |
| Genre filter | Tapping a genre tag in the expanded panel filters the grid to that genre | Must Have |
| Roll the Dice | Large button pinned above the list; triggers an animated randomizer that lands on one unwatched movie from the group list | Must Have |
| Re-roll | Tapping Roll again re-rolls from the same eligible pool | Must Have |
| Genre + Emotion Roll | Secondary button accepting comma-separated genres and/or emotion keywords; maps emotions to genre IDs, filters pool, then rolls | Must Have |
| Watched state (per group, toggle) | Marking a movie watched moves it to a collapsed "Watched" section; marking again moves it back. Binoculars overlay and button color update in real time across all members. | Must Have |
| Real-time list sync | Add, remove, and watched-status changes appear live on all connected group members' screens (Supabase real-time); subscribe only to the currently-viewed list, unsubscribe on navigation away | Must Have |
| Logged-in home page | Upon login or return visit (stored user ID detected), user lands on a home page that mirrors the landing page layout but shows their lists as cards and replaces Login with Create List; Roll the Dice and Genre Roll roll across all user lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list) | Must Have |
| Multi-group support | A user can belong to more than one group; all their lists appear as cards on the home page | Should Have |
| Invite code rotation | List Admin can regenerate the invite code to revoke access for anyone with the old code | Should Have |
| Trailer URL periodic refresh | Background job via Supabase pg_cron on a bi-weekly cadence refreshes stored trailer URLs only for movies where trailer_url is currently null. Note: this behavior should be reassessed post-launch to also refresh stale URLs after a certain age. | Should Have |
| Master Admin panel | Site-owner-only admin page with TOTP 2FA; can search and delete any list or user; credentials set via environment variables; session managed via iron-session (HttpOnly, Secure, SameSite=Strict cookie, 8-hour expiry) | Must Have |
1. Visitor lands on the root URL — no login required
2. Centered "Movie Dice" header/logo displayed
3. Splash text describes the site briefly (1-2 sentences)
4. "Roll the Dice" button is visible — tapping it triggers the slot-machine reel animation:
a. Three side-by-side reels spin through ~20 movie poster images pulled automatically
from TMDB popular/top-rated endpoints (no manual curation; replaced on each periodic refresh)
b. Reels decelerate and land on a single movie result
(the final result can be any TMDB movie, not constrained to the reel poster set)
c. Result is displayed as a static teaser card showing the movie poster, title, and genres.
No link, no tap action.
5. "Genre Roll" button visible — accepts comma-separated genres/emotions, no reel animation;
result displayed as a static teaser card (poster, title, genres). No link, no tap action.
6. "Login / Get Started" button below the roll buttons
7. User scrolls down to reveal:
a. About section — fuller description of how MovieDice works
b. 3-step how-it-works demo (Create a list → Add movies → Roll the dice)
- Step 1: left-aligned
- Step 2: right-aligned
- Step 3: left-aligned (zigzag visual rhythm)
8. Footer on all pages: TMDB attribution (logo + link + disclaimer), privacy policy link
1. User taps "Login / Get Started" on landing page
2. User enters display name, optionally picks an avatar color
3. Account created via Supabase Anonymous Sign-In (supabase.auth.signInAnonymously());
JWT issued and managed by Supabase GoTrue; session persisted automatically
4. Recovery code (24 alphanumeric characters, 128-bit entropy) shown once — user prompted to save it
5. User selects: "Create a Group" or "Join with a Code"
A. Create → enter group name → group created, invite code shown (e.g., WOLF-MOON)
→ creator assigned List Admin role → lands on home page (with their new list card shown)
B. Join → enter invite code → validated (rate-limited) → member role assigned → lands on home page
(with the joined list card shown)
1. App checks for valid Supabase Auth session
2. If valid session found → navigate directly to the home page (skip landing page)
3. If no session → show landing page (pre-login)
4. Home page layout mirrors the landing page layout, with these differences:
a. The Login button is replaced by a "Create List" button
b. The About / how-it-works section is replaced by the user's list cards
5. Each list card shows:
- List name (left-aligned)
- Number of movies with a film emoji (right-aligned)
- Below: "Created by: [username]"
6. Tapping a list card navigates into that list view
7. "Roll the Dice" and "Genre Roll" buttons at the top roll across ALL unwatched movies
from ALL of the user's lists combined (cross-list roll)
8. The roll result is displayed as a standalone teaser card directly on the home page.
The result does NOT navigate the user into any specific list.
9. If no session (new device) → prompt for recovery code or start fresh
1. User taps search bar at top of list view
2. User types a movie title; TMDB is queried via server-side proxy (/api/tmdb/search)
with ~300ms debounce
3. Results appear in two sections:
- Top: movies already in the group's list (labeled "In Your List")
- Below separator: TMDB search results
4. User taps a TMDB result → movie inserted into DB with poster, genres, title, year,
trailer URL (fetched from TMDB via server proxy and stored at add-time;
validated against allowlist: youtube.com, themoviedb.org, imdb.com),
and added-by attribution
5. All group members see the new movie appear in real time
1. Default view: 2-column evenly-scaling poster grid (3-4 columns on tablet/desktop)
2. Each card shows:
- Movie poster (full bleed, using TMDB native sized URL: w342 for mobile grid)
- Movie title below the poster
- Added-by user avatar overlaid top-right corner
- Binoculars emoji overlaid top-left corner — only when movie is watched
3. Grid loads 12 movies initially; additional movies load automatically on scroll to bottom
4. No action buttons on collapsed grid cards — cards are tap-only
5. All poster images use native loading="lazy" attribute
1. User taps any movie poster in the grid
2. A full-page-width panel expands downward, inserted inline below that row in the grid
(mirrors Google Image Search inline expansion — not a modal, popup, or slide-up sheet)
3. Panel contents, top to bottom:
a. Full-size movie poster (TMDB native sized URL: w500)
b. Movie title
c. "Added by [username]"
d. Genre tags — tappable; each filters the grid to that genre
e. "Watched It" + "Trailer" buttons — displayed side by side
f. Delete button — centered below, separated to prevent accidental taps
4. Delete behavior:
- Tap 1: button shakes, text changes to "Click to confirm delete"
- Tap 2: movie removed from list for all group members in real time
- Tapping elsewhere after Tap 1 resets button to default state
5. Watched It (toggle):
- If unwatched: button color changes to watched state, binoculars overlay appears on grid
poster (top-left), movie moves to "Watched" section
- If watched: button color reverts, binoculars overlay removed, movie returns to main list
- Both indicators update simultaneously on all group members' screens
6. Trailer: opens stored trailer URL in a new tab (with rel="noopener noreferrer")
7. Tapping outside the panel or a close affordance collapses it
1. User taps "Roll the Dice!" (pinned above the movie grid or on the home page)
2. Animated randomizer plays — scatter/flip/spin elimination sequence, 2-3 seconds
(this is distinct from the landing page slot-machine reel animation)
Respects prefers-reduced-motion: if enabled, use a simple fade-in on the winner instead
3. Animation settles on one random unwatched movie from the eligible pool
- On a list page: pool is that list's unwatched movies
- On the home page: pool is all unwatched movies across all of the user's lists combined
4. On the home page, the result is shown as a standalone teaser card in place on the home page.
The user is NOT navigated into any specific list.
5. Tapping Roll again re-rolls from the same eligible pool
6. Tapping "Genre Roll!" opens a text input
7. User enters genres and/or emotions (e.g., "action, excited")
8. App normalizes input, maps emotion keywords to TMDB genre IDs, filters the unwatched pool
9. Same scatter/eliminate animation plays on filtered results
10. If no matches found: "No matches — showing full list" shown, roll proceeds unfiltered
11. On a list page, the result movie is displayed prominently; tapping it opens the inline
expanded panel
1. List Admin opens group settings (gear icon or settings menu)
2. Available actions:
- Rename the list
- Delete the list (see deletion flow below)
- View member list with option to remove individual members
- Regenerate invite code (revokes old code)
- Display current invite code with copy-to-clipboard
3. Regular members see a settings menu with only: "Leave this list" option
Admin Self-Removal / Ownership Transfer flow:
a. List Admin taps "Leave this list" (or equivalent self-removal action)
b. If other members exist:
- "Transfer Ownership" popup appears before the admin can leave
- Popup shows the current member list; admin selects one member to become the new admin
- "Cancel" button at the bottom dismisses the popup with no changes
- Once a new admin is selected and confirmed, ownership transfers and the original admin
is removed from the list (they leave; the list is NOT deleted)
c. If the admin is the last remaining member (no one else to transfer to):
- The list is deleted automatically (with a standard confirmation prompt)
- Confirmed deletion removes the list and all its movies permanently
List Deletion flow (separate from self-removal):
a. List Admin taps "Delete the list" in settings
b. Standard delete confirmation prompt shown
c. Confirmed deletion removes the list and all its movies permanently
d. This action does NOT trigger the Transfer Ownership popup
1. Master Admin navigates to /admin
2. Login prompt: username + TOTP authenticator code (no password-only fallback)
3. Credentials (username and TOTP secret) are set via environment variables — no first-run UI
4. On successful auth → iron-session issues encrypted HttpOnly cookie (8-hour expiry)
→ Master Admin dashboard
5. Available tools:
- Search any list by name or ID → view details → delete (with confirmation)
- Search any user by display name or ID → view details → delete (with confirmation)
6. Master Admin session is separate from regular user sessions
7. All /admin routes redirect to login if no valid admin session
8. TOTP secret rotation requires redeployment (documented operational constraint)
aria-expanded on trigger and role="region" on panel.prefers-reduced-motion. Landing page reel: instant reveal or simple fade-in. In-app roll: fade to winner instead of scatter animation. Full animation remains the default./api/tmdb/*). Cache recent search results via TanStack Query with explicit staleTime configuration.| Layer | Choice | Notes |
|---|---|---|
| Frontend | Next.js (React, App Router) | PWA support; output: 'standalone' for Docker |
| Styling | Tailwind CSS | Mobile-first, fast iteration |
| Backend / Database | Supabase (self-hosted) | Postgres + real-time subscriptions + GoTrue auth; full Docker stack |
| Auth | Supabase Anonymous Sign-In | signInAnonymously() — no email, instant account, JWT for RLS |
| Movie Data | TMDB API | Posters, genres, metadata, trailer URLs; all calls via server-side proxy |
| State Management | TanStack Query (React Query) | Server state sync, caching with explicit staleTime, loading states |
| Admin Sessions | iron-session | Encrypted HttpOnly cookie for Master Admin TOTP sessions |
| 2FA (Master Admin) | TOTP via otplib (or equivalent) | Authenticator-app compatible; TOTP secret never exposed client-side |
| PWA | @serwist/next | App Router compatible, Workbox 7, actively maintained |
| Image Optimization | sharp (local assets only) | TMDB posters use native sized URLs from TMDB CDN directly (not next/image) |
| Background Jobs | Supabase pg_cron + Edge Functions | Runs on self-hosted Postgres; replaces Vercel Cron |
| Reverse Proxy | Caddy | HTTPS termination (required for PWA, wss://, secure cookies) |
| Linting / Formatting | ESLint + Prettier + TypeScript strict | next/core-web-vitals + next/typescript presets; husky + lint-staged pre-commit |
| Testing | Vitest (unit) + Playwright (E2E) | Unit tests for pure logic; E2E for critical paths |
| Env Validation | zod (or t3-env) | Validate all required env vars at startup |
| Runtime | Node.js 22 LTS | node:22-slim Docker base image |
TMDB posters are served directly from TMDB's CDN using native sized URLs — not processed through next/image optimization (which would run sharp in-container and create CPU/memory pressure in Docker). Use these TMDB size variants:
w342 — grid thumbnails (mobile)w185 — reel animation postersw500 — expanded inline panelloading="lazy" attribute on all poster <img> tagsnext/image for locally-served assets only (logo, icons)sharp as an explicit production dependency for local asset optimizationTMDB Terms of Service require visible attribution on every page: the TMDB logo, a link to themoviedb.org, and the disclaimer "This product uses the TMDB API but is not endorsed or certified by TMDB." Implement as a site-wide footer component. Non-compliance risks API key revocation.
All TMDB API calls must be routed through Next.js API Route Handlers (/api/tmdb/*). The TMDB_API_KEY environment variable must NEVER use the NEXT_PUBLIC_ prefix — it must remain server-side only. This is a TMDB Terms of Service requirement. The proxy also enables server-side response caching via Cache-Control headers.
users
signInAnonymously())groups
group_members
movies
landing_reel_posters
admin_sessions
RLS must be enabled on ALL tables with explicit policies — no permissive catch-all. Supabase's anon key is public by design; RLS is the authorization mechanism. Policies use auth.uid() from the JWT issued by Supabase Anonymous Sign-In. Define policies alongside the schema in Phase 1.2. Key rules:
Supabase Realtime also respects RLS — subscriptions are authorized by the same policies.
Use supabase migration new via the Supabase CLI. All migrations stored in version control. Migrations must be the sole mechanism for schema changes — no ad-hoc SQL in production.
Configure HTTP security headers in next.config.js or at the Caddy reverse proxy level:
Content-Security-Policy — restrict img-src to image.tmdb.org, connect-src to Supabase wss://, etc.X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originStrict-Transport-Security (HSTS)Permissions-PolicyContent-Security-Policy-Report-Only during development to identify violations without blockingnode:22-slim, output: 'standalone', non-root user, tini for PID 1 signal handling, .dockerignorewss:// Supabase Realtime, and secure cookies)/api/health endpoint checking Supabase connectivity; used by Docker HEALTHCHECKTMDB_API_KEY (server-side only — never NEXT_PUBLIC_)SUPABASE_URLSUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY (for server-side admin operations)MASTER_ADMIN_USERNAME — the master admin login usernameMASTER_ADMIN_TOTP_SECRET — the TOTP secret (base32); configure this in your authenticator app (e.g., Google Authenticator, Authy) before first useIRON_SESSION_SECRET — 32+ character secret for iron-session cookie encryptionMVP Deadline: April 26, 2026 Full Feature Complete: July 5, 2026
output: 'standalone' in next.config.ts; configure TypeScript strict mode, ESLint (next/core-web-vitals + next/typescript), Prettier; set up husky + lint-staged (pre-commit: lint/format, pre-push: lint + typecheck + build); install sharp as production dependency; add Vitest for unit testingsupabase migration new/api/tmdb/*) to keep API key server-sidesupabase.auth.signInAnonymously(); display name input and optional avatar color picker; session managed by Supabase GoTrue (JWT issued automatically)/api/tmdb/*): search endpoint, poster URL construction using TMDB native sizes (w342 grid, w185 reel, w500 expanded), trailer URL fetch at add-time; implement TanStack Query caching with explicit staleTime configuration; document API routes in markdownloading="lazy", title below, added-by avatar overlaid top-right, binoculars emoji overlaid top-left (watched only), no action buttons on cards, tap-only interactionprefers-reduced-motion (use simple fade-in on winner when enabled)prefers-reduced-motion (instant reveal or simple fade-in when enabled)@serwist/next for home screen installationpersistQueryClient (IndexedDB), disable write actions with message; do not queue offline writesaria-expanded on trigger, role="region" on panel)@tanstack/react-virtual) if grid performance degrades with large listsprefers-reduced-motion preferenceStatic mapping used by Genre Roll to translate emotion keywords into TMDB genre IDs. Implemented as a static TypeScript constant. Normalize input to lowercase and tokenize on spaces and commas before matching.
| Emotion Keywords | Primary Genres | Secondary Genres |
|---|---|---|
| happy, cheerful, upbeat, fun | Comedy, Animation, Family | Adventure, Musical |
| sad, emotional, cry, tearjerker | Drama, Romance | War, Biography |
| excited, hyped, energetic, pumped | Action, Adventure | Science Fiction, Thriller |
| scared, tense, nervous, creepy | Horror, Thriller | Mystery |
| calm, relaxed, chill, cozy | Documentary, Drama | Animation |
| romantic, lovey, date night | Romance, Comedy | Drama |
| thoughtful, reflective, deep | Documentary, Drama | History, Biography |
| funny, silly, goofy, laugh | Comedy, Animation | Family |
| dark, gritty, intense, serious | Crime, Thriller | Drama, War |
| nostalgic, classic, retro | (any genre, filtered to release year < 2000) |
Union all matched genre IDs from primary and secondary columns and filter the movie pool. If no tokens match any keyword, notify user with "No matches — showing full list" and proceed with the unfiltered pool.
Features added after initial scope. Complete current Implementation Plan progress before starting these.
| Feature | Description | Added On | Rationale |
|---|---|---|---|
| Compact list/grid toggle | Toggle between poster grid and a compact list layout (title, year, metadata per row) | 2026-04-05 | Deferred from MVP — user unfamiliar with it at scoping; low priority relative to core flows |
| Self-service account deletion | User-facing account deletion flow with cascading deletes, ownership transfer for administered groups, and anonymization of added_by references in movies | 2026-04-06 | GDPR Article 17 (Right to Erasure); MVP relies on Master Admin deletion on request as interim |