3 Komitmen 7382c5e411 ... dc2d4ef447

Pembuat SHA1 Pesan Tanggal
  User dc2d4ef447 [Scope] Reconcile Phases 1-3 + 5.5 complete; log 3.8/5.6 bugs (now fixed) 1 bulan lalu
  User 47b4c01b81 [Fix] Real-time UPDATE, offline search error state, pre-push hook 1 bulan lalu
  User 18ba081a7d [Fix] Align RollAnnouncer timing with carousel settle (3350ms) 1 bulan lalu

+ 1 - 0
.husky/pre-push

@@ -0,0 +1 @@
+npm run lint && npm run typecheck && npm run build

+ 43 - 15
PROJECT_SCOPE.md

@@ -528,36 +528,47 @@ Configure HTTP security headers at the Caddy reverse proxy level (not in next.co
 
 ### Phase 1: Foundation (April 6-10, 2026) — MVP
 
-- [ ] 1.1 — Initialize Next.js project with Tailwind CSS, App Router, and `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 testing (scope: pure logic and Client Components only — Vitest cannot render RSC)
-- [ ] 1.2 — Set up self-hosted Supabase Docker stack; replace ALL default secrets before first `docker compose up` (JWT_SECRET → regenerate ANON_KEY + SERVICE_ROLE_KEY together; replace POSTGRES_PASSWORD, DASHBOARD_USERNAME, DASHBOARD_PASSWORD); configure GoTrue: set `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true`, disable email/phone/OAuth auth methods; create schema (users, groups, group_members, movies, landing_reel_posters tables) with CHECK constraints on display_name and group name; define and enable RLS policies on all tables with `WITH CHECK` clauses (see RLS section); initialize Supabase CLI migrations workflow — all schema changes via `supabase migration new`
+- [x] 1.1 — Initialize Next.js project with Tailwind CSS, App Router, and `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 testing (scope: pure logic and Client Components only — Vitest cannot render RSC)
+
+  VERIFIED (2026-05-07): Project initialized, TypeScript strict, ESLint, Prettier, husky pre-commit (lint/format), and Vitest all in place. Lint/typecheck/build run in CI and locally. SUB-TODO: pre-push hook (lint + typecheck + build) is not yet wired — currently only CI and manual runs cover this. Wire husky pre-push before first external collaborator onboards.
+
+- [x] 1.2 — Set up self-hosted Supabase Docker stack; replace ALL default secrets before first `docker compose up` (JWT_SECRET → regenerate ANON_KEY + SERVICE_ROLE_KEY together; replace POSTGRES_PASSWORD, DASHBOARD_USERNAME, DASHBOARD_PASSWORD); configure GoTrue: set `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true`, disable email/phone/OAuth auth methods; create schema (users, groups, group_members, movies, landing_reel_posters tables) with CHECK constraints on display_name and group name; define and enable RLS policies on all tables with `WITH CHECK` clauses (see RLS section); initialize Supabase CLI migrations workflow — all schema changes via `supabase migration new`
 
   RLS HOTFIX SHIPPED (2026-05-06, migration `supabase/migrations/00005_movies_update_recursion_fix.sql`): Symptom — PATCH /api/movies/[id]/watched returned 500 with Postgres error 42P17 ("infinite recursion detected in policy for relation 'movies'"). Root cause — the original `movies_update` RLS policy's WITH CHECK clause referenced `public.movies` in a subquery to enforce `added_by` immutability; selecting from movies while the movies_update policy evaluates triggers RLS recursion. Fix — dropped the self-referencing WITH CHECK; replaced with a `BEFORE UPDATE` trigger `movies_added_by_immutable` that raises exception 23514 if `NEW.added_by IS DISTINCT FROM OLD.added_by`. Group-membership check remains in the policy. NOTE: other RLS policies should be audited for similar self-referencing subqueries — see task 1.9.
 
-- [ ] 1.3 — Configure Supabase client in Next.js using `@supabase/ssr` (`createBrowserClient` for browser, `createServerClient` for server-side with `SUPABASE_INTERNAL_URL`); implement env var validation at startup via t3-env (`@t3-oss/env-nextjs`) with zod — server vars in `server` block, client vars in `client` block; create TMDB API proxy route (`/api/tmdb/*`) to keep API key server-side; set `include_adult=false` on all TMDB calls
-- [ ] 1.4 — Implement anonymous auth via `supabase.auth.signInAnonymously()`; display name input and optional avatar color picker; session managed by Supabase GoTrue via `@supabase/ssr` cookie-based handling (JWT issued automatically); verify GoTrue returns 200 (not 400) — confirm `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true` is set
-- [ ] 1.5 — Implement recovery code generation (24 alphanumeric characters, 128-bit entropy), Argon2id hashing (explicit OWASP parameters: memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes — do not use library defaults), and show-once display screen. NOTE (2026-05-02, commits f71cb76 + a54a5ca): Fixed a post-signup race on the /recovery page where React 19 StrictMode double-mount caused the page to hang on "Generating your recovery code..." indefinitely (reload was required to see the code). Root cause: the original useMutation + useRef guard fired on the first mount instance; the second instance never observed the response. Fixed by replacing useMutation with useQuery (key: `recovery-code-generate`, staleTime/gcTime: Infinity, refetchOnMount: false) — TanStack Query dedupes the request across mount/remount/StrictMode and persists the result in cache. Server route (POST /api/auth/recovery/generate) unchanged.
+- [x] 1.3 — Configure Supabase client in Next.js using `@supabase/ssr` (`createBrowserClient` for browser, `createServerClient` for server-side with `SUPABASE_INTERNAL_URL`); implement env var validation at startup via t3-env (`@t3-oss/env-nextjs`) with zod — server vars in `server` block, client vars in `client` block; create TMDB API proxy route (`/api/tmdb/*`) to keep API key server-side; set `include_adult=false` on all TMDB calls
+- [x] 1.4 — Implement anonymous auth via `supabase.auth.signInAnonymously()`; display name input and optional avatar color picker; session managed by Supabase GoTrue via `@supabase/ssr` cookie-based handling (JWT issued automatically); verify GoTrue returns 200 (not 400) — confirm `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true` is set
+- [x] 1.5 — Implement recovery code generation (24 alphanumeric characters, 128-bit entropy), Argon2id hashing (explicit OWASP parameters: memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes — do not use library defaults), and show-once display screen. NOTE (2026-05-02, commits f71cb76 + a54a5ca): Fixed a post-signup race on the /recovery page where React 19 StrictMode double-mount caused the page to hang on "Generating your recovery code..." indefinitely (reload was required to see the code). Root cause: the original useMutation + useRef guard fired on the first mount instance; the second instance never observed the response. Fixed by replacing useMutation with useQuery (key: `recovery-code-generate`, staleTime/gcTime: Infinity, refetchOnMount: false) — TanStack Query dedupes the request across mount/remount/StrictMode and persists the result in cache. Server route (POST /api/auth/recovery/generate) unchanged.
 - [x] 1.6 — Implement recovery code claim flow (enter code → verify against Argon2id hash → restore identity on new device); rate-limit claim endpoint (5 failed attempts per IP per 15-minute window); invalidate code after successful claim. VERIFIED (2026-05-06): Manual end-to-end browser test passed. Both escalation triggers cleared: (A) `auth.identities` row present and correct — no issue; (B) GoTrue #2013 residual — not triggered with pinned v2.170.0. This was the highest-risk open item in the recovery system: GoTrue accepting our minted JWTs via HS256 signature alone (without a matching `auth.sessions` row) was the critical architectural unknown. Browser verification clears it — the synthetic-identity-at-generate architecture is sound. Architectural risk for the recovery system is resolved.
 
   ARCHITECTURE — Synthetic-identity-at-generate (replaces broken claim-only-verifies-no-session flow): At code-generation time, `/api/auth/recovery/generate` assigns `<uid>@moviedice.invalid` + HKDF-SHA256 derived password (label `"moviedice-recovery-v1"`, salt=uid) to the GoTrue user via admin API. Returns 409 if a code already exists (idempotent — no auto-rotate). Atomic rollback on failure (`admin.deleteUser` + 500). At claim time, `/api/auth/recovery/claim` runs Argon2 scan → `signInWithPassword` with derived synthetic creds → cookies set via `createServerClient` bound to response → `{ ok: true }`. `/recover` page calls `window.location.assign("/")` post-claim (hard nav for cookie propagation). New files: `src/lib/auth/synthetic.ts` (`syntheticEmail()`, `derivePassword()`), `supabase/migrations/00003_synthetic_email_constraint.sql` (CHECK on `auth.users.email` rejecting `@moviedice.invalid` on non-promoted rows), `src/lib/supabase/admin.ts` updated with module-load assertion on `GOTRUE_EXTERNAL_EMAIL_ENABLED`. Tests: 15 passing (synthetic.test.ts ×6, recovery-generate.test.ts ×5, recovery-claim.test.ts ×4) + 1 todo regression scaffold for GoTrue #2013 canary.
 
 - [ ] 1.7 — Build Docker infrastructure: multi-stage Dockerfile (node:22-slim, non-root user, tini; builder stage installs `python3 make g++` for argon2 native build), docker-compose.yml orchestrating Next.js app + self-hosted Supabase stack + Caddy reverse proxy + Node.js cron container + pg_dump backup container (daily, 7-day retention), .dockerignore, /api/health endpoint; network security: Kong/Postgres ports internal only, Studio restricted to 127.0.0.1; Caddy persistent volumes for `/data` and `/config` (TLS certificates); Docker log rotation on all containers (max-size: 10m, max-file: 5); use Let's Encrypt staging for initial testing; deploy and confirm Supabase connection works in production
-- [ ] 1.8 — Add Sentry error monitoring (free tier); configure `beforeSend` to strip UUID path segments from error events; do not call `Sentry.setUser()` with user identifiers
-- [ ] 1.9 — RLS policy audit: review all remaining RLS policies (users, groups, group_members, landing_reel_posters) for WITH CHECK or USING clauses that contain self-referencing subqueries against the same table being evaluated — these cause Postgres error 42P17 (infinite recursion). The movies_update recursion was fixed in migration 00005 via trigger; confirm no other policies contain the same pattern. Document findings.
+
+  NOTE (2026-05-07): Docker infrastructure code (Dockerfile, docker-compose.yml, Caddy config, cron container, health endpoint) is complete and runs locally. OPEN: app has never been deployed to a real server — production deploy and smoke test remain pending (see also 5.8).
+
+- [x] 1.8 — Add Sentry error monitoring (free tier); configure `beforeSend` to strip UUID path segments from error events; do not call `Sentry.setUser()` with user identifiers
+- [x] 1.9 — RLS policy audit: review all remaining RLS policies (users, groups, group_members, landing_reel_posters) for WITH CHECK or USING clauses that contain self-referencing subqueries against the same table being evaluated — these cause Postgres error 42P17 (infinite recursion). The movies_update recursion was fixed in migration 00005 via trigger; confirm no other policies contain the same pattern. Document findings.
+
+  VERIFIED (2026-05-07): Clean migrations confirmed; no other policies contain self-referencing subqueries. CI guard covers future migrations. Audit is sufficient documentation for pre-launch.
 
 ### Phase 2: Groups and Permissions (April 10-14, 2026) — MVP
 
-- [ ] 2.1 — Build "Create a Group" flow: name input (validated: 1-50 chars), invite code generation (WORD-WORD format; word list: 2,000+ words, 3-8 chars, offensive terms filtered, collision check), store in DB, assign creator as List Admin; document API routes in markdown
-- [ ] 2.2 — Build "Join with a Code" flow: code entry, validation via server-side route handler using service role key (not client-side INSERT), group_members record with role: 'member'; rate-limit join endpoint (5-10 failed attempts per IP per 15-minute window); document API routes in markdown
-- [ ] 2.3 — Build logged-in home page: mirrors landing page layout; replaces Login with "Create List" button; shows user's list cards (list name left, movie count + film emoji right, "Created by: [username]" below); tapping a card navigates to that list; Roll the Dice and Genre Roll on the home page roll across all lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list); use polling (not real-time subscriptions) for home page movie counts
+- [x] 2.1 — Build "Create a Group" flow: name input (validated: 1-50 chars), invite code generation (WORD-WORD format; word list: 2,000+ words, 3-8 chars, offensive terms filtered, collision check), store in DB, assign creator as List Admin; document API routes in markdown
+- [x] 2.2 — Build "Join with a Code" flow: code entry, validation via server-side route handler using service role key (not client-side INSERT), group_members record with role: 'member'; rate-limit join endpoint (5-10 failed attempts per IP per 15-minute window); document API routes in markdown
+- [x] 2.3 — Build logged-in home page: mirrors landing page layout; replaces Login with "Create List" button; shows user's list cards (list name left, movie count + film emoji right, "Created by: [username]" below); tapping a card navigates to that list; Roll the Dice and Genre Roll on the home page roll across all lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list); use polling (not real-time subscriptions) for home page movie counts
+
+  VERIFIED (2026-05-07): Cross-list roll confirmed working — rolls across all user lists combined, result displays via ListRollCarousel in place with no navigation into any list.
+
 - [x] 2.4 — Implement List Admin settings. SHIPPED (2026-05-06): Settings page `src/app/(app)/list/[id]/settings/page.tsx` now exists (gear icon was previously broken/404); mounts `src/components/groups/settings-panel.tsx`. Join code displayed in list header under list name (`src/app/(app)/list/[id]/page.tsx` selects `invite_code`, renders "Join code: WORD-WORD"). Delete/leave/transfer actions use shake-to-arm pattern (animate-shake, 4s auto-disarm) replacing all `window.confirm()` popups. Admin with other members: second click reveals inline non-admin successor picker; selecting a member runs transfer+leave atomically → router.push("/"). Admin last member: second click permanently deletes → router.push("/"). Settings also supports: rename list, regenerate invite code (copy-to-clipboard), view + remove members. UI POLISH (2026-05-06): List page header redesigned as 3-col grid (back arrow left, centered name + JOIN CODE eyebrow + mono invite chip, settings cog right); `/create-group` page header mirrors back-arrow + centered-title pattern, button text changed to "Create List" and centered.
 - [x] 2.5 — Implement List Admin direct deletion. SHIPPED (2026-05-06): "Delete the list" in SettingsPanel uses shake-to-arm (first click arms + shakes, 4s auto-disarm, second click confirms permanent deletion) → router.push("/"). Does NOT trigger the successor/ownership picker.
 - [x] 2.6 — Implement regular user settings. SHIPPED (2026-05-06): "Leave this list" for non-admin members uses same shake-to-arm pattern for consistency → router.push("/") on confirm.
 
 ### Phase 3: Movie List Core (April 14-20, 2026) — MVP
 
-- [ ] 3.1 — Integrate TMDB API via server-side proxy (`/api/tmdb/*`): search endpoint with `include_adult=false` and server-side `adult` field filtering, poster URL construction using TMDB native sizes (w342 grid, w185 reel, w500 modal), trailer URL fetch at add-time; implement TanStack Query caching with explicit `staleTime` configuration; document API routes in markdown
-- [ ] 3.2 — Build search bar with ~300ms debounce, loading state, and two-section results ("In Your List" above separator, TMDB results below)
-- [ ] 3.3 — Implement add-movie flow: tap result → insert into movies table with TMDB metadata, stored trailer_url (fetched via server proxy at add-time; validated against domain allowlist: youtube.com, themoviedb.org, imdb.com), trailer_url_refreshed_at, and added_by attribution; document API routes in markdown
+- [x] 3.1 — Integrate TMDB API via server-side proxy (`/api/tmdb/*`): search endpoint with `include_adult=false` and server-side `adult` field filtering, poster URL construction using TMDB native sizes (w342 grid, w185 reel, w500 modal), trailer URL fetch at add-time; implement TanStack Query caching with explicit `staleTime` configuration; document API routes in markdown
+- [x] 3.2 — Build search bar with ~300ms debounce, loading state, and two-section results ("In Your List" above separator, TMDB results below)
+- [x] 3.3 — Implement add-movie flow: tap result → insert into movies table with TMDB metadata, stored trailer_url (fetched via server proxy at add-time; validated against domain allowlist: youtube.com, themoviedb.org, imdb.com), trailer_url_refreshed_at, and added_by attribution; document API routes in markdown
 
   BUG FIX SHIPPED (2026-05-06, master — uncommitted): Clicking "+ Add" on a search result flashed the button but silently failed. Root cause: `src/app/api/movies/route.ts` was calling TMDB with `?api_key=${TMDB_API_KEY}` (v3 query-string auth), but `TMDB_API_KEY` is a v4 read-access JWT that TMDB only accepts via `Authorization: Bearer`. Every other TMDB call in the repo (`src/lib/tmdb-fetch.ts`, `src/lib/tmdb/client.ts`) already used Bearer — only this route was wrong. TMDB returned 401 → route returned 404 "Movie not found on TMDB" → `useAddMovie` threw silently (no `onError` handler) → invisible failure to the user. Fix: switched both TMDB fetches in that route to `Authorization: Bearer ${TMDB_API_KEY}` headers; added `console.error` with TMDB response body+status on details-fetch failure. UX fix in the same pass (`src/components/movies/movie-list-client.tsx`): clicking a search result now immediately hides the results panel (`resultsHidden` state, reset on next keystroke); search input text is preserved.
 
@@ -571,13 +582,15 @@ Configure HTTP security headers at the Caddy reverse proxy level (not in next.co
   - `src/components/dice/roll-result-card.tsx` — list used it before; list now uses `ListRollCarousel`
 
 - [x] 3.4 — Build 2-column poster grid. SHIPPED (2026-05-06): `PosterCard` is now a full-card button (no ExpandedPanel). Tapping opens `ListMoreInfoModal`. "i" glyph bottom-right is decorative `pointer-events-none`. Watched overlay changed from binoculars emoji to green-circle checkmark top-left. Avatar dot remains top-right. Grid layout unchanged (w342 posters, `loading="lazy"`, meaningful `alt` text, added-by avatar overlaid top-right).
-- [ ] 3.5 — Implement infinite scroll: load 12 movies initially, fetch and append next batch automatically on scroll to bottom
+- [x] 3.5 — Implement infinite scroll: load 12 movies initially, fetch and append next batch automatically on scroll to bottom
 - [x] 3.6 — Build movie info modal. SHIPPED (2026-05-06): `src/components/dice/list-more-info-modal.tsx`. Replaces the planned inline ExpandedPanel entirely. See "Movie Info Modal" flow and feature table entry for full spec. Portal to document.body; TMDB overview fetched per-open; Watched + Delete both use shake-to-arm three-state pattern.
 - [x] 3.7 — Genre filter (scope clarified 2026-05-06). DELIBERATE SCOPE REDUCTION — not a regression. Genre tags are display-only everywhere except Genre Roll. Genre Roll (`RollBar` / `RollSection`) is the sole filter entry point — tappable tag-filters have been explicitly removed from the product design when ExpandedPanel was replaced by `ListMoreInfoModal`. Cleanup in flight: dormant `selectedGenre` prop being removed from `PosterGrid`.
 - [ ] 3.8 — Implement Watched state: toggle marks/unmarks movie as watched for the group; watched movies move to collapsed "Watched" section; green-circle checkmark overlay and button color update simultaneously across all members; announce state change via `aria-live="polite"` region
 
   NOTE (2026-05-06): The PATCH /api/movies/[id]/watched endpoint was 500ing due to Postgres error 42P17 (RLS recursion). Fixed by migration 00005 (see task 1.2 note). The API layer is now unblocked. Remaining work: collapsed "Watched" section UI in the grid, real-time propagation of watched state to all group members (via task 3.9), and `aria-live` announcement.
 
+  KNOWN BUG (2026-05-07): Real-time UPDATE events for watched state do not propagate to other connected clients. The watching user's UI updates correctly (optimistic update), but a second browser window on the same list requires a manual refresh to see the change. INSERT (add movie) and DELETE (remove movie) real-time events propagate correctly — only UPDATE (watched toggle) is affected. Root cause not yet investigated; likely a Supabase Realtime subscription filter or event type mismatch.
+
 - [ ] 3.9 — Enable Supabase real-time subscriptions on movies table for live add, remove, and watched-status updates; subscribe only to the currently-viewed list (subscribe on mount, unsubscribe on unmount via useEffect cleanup); implement exponential backoff on reconnection
 
 ### Phase 4: Randomizer (April 20-23, 2026) — MVP
@@ -598,10 +611,19 @@ Configure HTTP security headers at the Caddy reverse proxy level (not in next.co
 - [x] 5.3 — Build slot-machine reel animation for the landing page Roll the Dice button: 3 side-by-side reels that spin through the automatically fetched poster set (using w185 size, `aria-hidden` on spinning posters), decelerate, and land on a single TMDB movie result with alt text (the final result is fetched live from TMDB via server proxy — not constrained to the reel poster set); animation is user-triggered only and completes within 5 seconds (WCAG 2.2.2); respect `prefers-reduced-motion` (instant reveal or simple fade-in when enabled). NOTE (2026-05-02): Continuous carousel and slot-machine spin on roll are implemented. KNOWN BUG (U9): modulo wrap missing in spinning branch causes scroll offset to drift on repeated rolls; fix tracked in `research/PHASE5_UI_FIXES_PLAN.md`. NOTE (2026-05-02 dea71d9/0061375): Roll result card now emerges inside the carousel center via animate-emerge keyframe (scale 0→1.08→1, ~500ms, bouncy, skipped under prefers-reduced-motion); snap math positions midpoint of a gap at viewport center so card sits in a symmetric slot; posters spread ±110px on settle. Card stays settled until user re-rolls or refreshes (RESUME_DELAY auto-resume timer removed). TeaserCard reduced to ~1.3x reel poster size. "i" info button on TeaserCard opens <MoreInfoModal> (src/components/landing/more-info-modal.tsx) with title+year, genres, plot, Add to list (→ /login, no save-intent), Watch Trailer (lazy-fetches /api/tmdb/movie/[id]/videos); mirrors GenreRollModal a11y pattern; uses createPortal to escape the transformed ancestor. Gold glow added to TeaserCard in same UI batch as in-app roll (2026-05-06). NOTE: landing MoreInfoModal is distinct from the in-app `ListMoreInfoModal` (src/components/dice/list-more-info-modal.tsx).
 - [x] 5.3a — Wire landing page Roll the Dice to TMDB via `/api/tmdb/popular?page=N` (random page 1–50, pool ~1000 movies). Result displayed as a static teaser card (poster with alt text, title, genres) — no link, no tap action. NOTE: this is a distinct pool from the carousel reel — see Feature Flow section 5 for the two-pool distinction.
 - [x] 5.4 — Wire landing page Genre Roll to TMDB via server proxy (no login required); display result as a static teaser card showing poster (with alt text), title, and genres — no link, no tap action.
-- [ ] 5.5 — Loading and empty states for all major views (empty list, no search results, no genre matches, empty home page for new users)
+- [x] 5.5 — Loading and empty states for all major views (empty list, no search results, no genre matches, empty home page for new users)
+
+  VERIFIED (2026-05-07): User-confirmed adequate empty and loading states across tested views.
+
 - [ ] 5.6 — Error handling: invalid invite code, TMDB API failure, network errors
+
+  PARTIAL (2026-05-07): Most tested error paths are handled correctly. KNOWN GAP: with network set to offline, the add-movie search silently shows "No results" instead of a network-error indicator — a misleading false-empty state. Remaining sub-todo: detect offline/fetch-failure in the search path and show a distinct error message rather than an empty results list.
+
 - [ ] 5.7 — Configure HTTP security headers in Caddyfile: CSP (with self-hosted URLs, not \*.supabase.co), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (start with max-age=86400, increase before launch), Permissions-Policy; use Report-Only mode during testing
 - [ ] 5.8 — Final MVP smoke test and Docker production deployment
+
+  NOTE (2026-05-07): App has only run locally — no production server deployment has occurred. This task and 1.7's production deploy step remain fully open.
+
 - [x] 5.9 — Implement user-facing sign-out. SHIPPED (2026-05-02, commits 44ff76c→582a7fd→add8745): `POST /api/auth/signout` calls `supabase.auth.signOut()` server-side and returns `Set-Cookie` clears; `GET/POST /logout` is a redirect-to-`/` alias for manual/debug links. `<SignOutButton />` (`src/components/auth/sign-out-button.tsx`) placed in the `(app)` layout header — no avatar/menu. On click: calls API, clears TanStack Query cache, hard-navigates to `/`. No confirmation dialog (decided: anonymous-account warning is deliberately omitted; click = signed out). Distinct from `/api/admin/logout` (iron-session). No `Clear-Site-Data` header (no service worker yet). persistQueryClient cleanup deferred to Phase 8.
 
   DEV-ENV QUIRK — cookie-name pinning: `SUPABASE_INTERNAL_URL` and `NEXT_PUBLIC_SUPABASE_URL` may differ in dev (different hostnames). The Supabase SDK derives the session cookie name from the URL, so each side silently derived a different name, making `signOut`/`getUser` no-ops. Fixed by pinning `cookieOptions.name` to a value derived from `NEXT_PUBLIC_SUPABASE_URL` in `src/lib/supabase/cookie-name.ts`. Any new `createServerClient` call must include this `cookieOptions.name` — see also CLAUDE.md.
@@ -749,3 +771,9 @@ _Features added after initial scope. Complete current Implementation Plan progre
 | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------- |
 | 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 (wrapped in transaction), ownership transfer for administered groups (auto-transfer to longest-tenured member), anonymization of added_by references (ON DELETE SET NULL), and removal of auth.users record via `supabase.auth.admin.deleteUser()` | 2026-04-06 | GDPR Article 17 (Right to Erasure); MVP relies on Master Admin deletion on request as interim |
+
+## 12. Change Log
+
+| Date       | Summary                                                                                                                                                                                                                                                                                                                                                                                                                                           |
+| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-05-07 | Walkthrough audit: marked complete — 1.1–1.5, 1.8, 1.9, 2.1–2.3, 3.1–3.3, 3.5, 5.5. Kept open — 1.7 (production deploy pending; code done locally), 5.8 (no server deploy yet). Added known bug to 3.8: real-time UPDATE events for watched state don't propagate to other clients. Added partial gap to 5.6: offline search shows false-empty instead of error. Sub-todo on 1.1: pre-push hook not yet wired. Phase 4 and Phases 6–10 unchanged. |

+ 3 - 0
eslint.config.mjs

@@ -12,6 +12,9 @@ const eslintConfig = defineConfig([
     "out/**",
     "build/**",
     "next-env.d.ts",
+    // Claude Code worktrees contain nested .next/build artifacts from
+    // sibling agent runs — never lint those.
+    ".claude/**",
   ]),
 ]);
 

+ 8 - 7
research/PROJECT_INFO.md

@@ -41,10 +41,11 @@ MovieDice is a mobile-first web app that helps friend groups collaboratively bui
 
 ## Review History
 
-| Date       | Type                                                           | Report                                   |
-| ---------- | -------------------------------------------------------------- | ---------------------------------------- |
-| 2026-04-05 | Pre-implementation architecture review                         | ./research/COMPLIANCE.md                 |
-| 2026-04-05 | Full tech stack audit (Report Mode) — Docker/self-hosted focus | ./research/TECHFILE.md                   |
-| 2026-04-05 | Second review -- technology verification on updated scope      | ./research/TECHFILE.md (Second Review)   |
-| 2026-04-05 | Second security review -- updated architecture analysis        | ./research/SECFILE.md (Second Review)    |
-| 2026-04-05 | Second compliance review -- updated scope (15 new findings)    | ./research/COMPLIANCE.md (Second Review) |
+| Date       | Type                                                           | Report                                                           |
+| ---------- | -------------------------------------------------------------- | ---------------------------------------------------------------- |
+| 2026-04-05 | Pre-implementation architecture review                         | ./research/COMPLIANCE.md                                         |
+| 2026-04-05 | Full tech stack audit (Report Mode) — Docker/self-hosted focus | ./research/TECHFILE.md                                           |
+| 2026-04-05 | Second review -- technology verification on updated scope      | ./research/TECHFILE.md (Second Review)                           |
+| 2026-04-05 | Second security review -- updated architecture analysis        | ./research/SECFILE.md (Second Review)                            |
+| 2026-04-05 | Second compliance review -- updated scope (15 new findings)    | ./research/COMPLIANCE.md (Second Review)                         |
+| 2026-05-07 | programmer                                                     | Realtime UPDATE (3.8), offline search (5.6), pre-push hook (1.1) |

+ 9 - 9
src/__tests__/hooks/use-roll.test.ts

@@ -64,7 +64,7 @@ describe("useRoll", () => {
     vi.restoreAllMocks();
   });
 
-  it("transitions idle -> rolling -> complete with the 2500ms timer", () => {
+  it("transitions idle -> rolling -> complete with the 3350ms timer", () => {
     const { result } = renderHook(() => useRoll());
     const pool = [makeMovie("1"), makeMovie("2"), makeMovie("3")];
 
@@ -79,7 +79,7 @@ describe("useRoll", () => {
     expect(result.current.result).not.toBeNull();
 
     act(() => {
-      vi.advanceTimersByTime(2499);
+      vi.advanceTimersByTime(3349);
     });
     expect(result.current.rollState).toBe("rolling");
 
@@ -97,7 +97,7 @@ describe("useRoll", () => {
       result.current.roll(pool);
     });
     act(() => {
-      vi.advanceTimersByTime(2500);
+      vi.advanceTimersByTime(3350);
     });
     expect(result.current.rollState).toBe("complete");
 
@@ -142,7 +142,7 @@ describe("useRoll", () => {
     pool.push(makeMovie("X"), makeMovie("Y"), makeMovie("Z"));
 
     act(() => {
-      vi.advanceTimersByTime(2500);
+      vi.advanceTimersByTime(3350);
     });
 
     expect(result.current.rollState).toBe("complete");
@@ -160,7 +160,7 @@ describe("useRoll", () => {
       result.current.roll(pool);
     });
     act(() => {
-      vi.advanceTimersByTime(2500);
+      vi.advanceTimersByTime(3350);
     });
     expect(result.current.rollState).toBe("complete");
 
@@ -172,7 +172,7 @@ describe("useRoll", () => {
     expect(result.current.result).not.toBeNull();
 
     act(() => {
-      vi.advanceTimersByTime(2500);
+      vi.advanceTimersByTime(3350);
     });
     expect(result.current.rollState).toBe("complete");
   });
@@ -184,7 +184,7 @@ describe("useRoll", () => {
       result.current.roll([makeMovie("1")]);
     });
     act(() => {
-      vi.advanceTimersByTime(2500);
+      vi.advanceTimersByTime(3350);
     });
 
     const onlySurvivor = makeMovie("only");
@@ -192,7 +192,7 @@ describe("useRoll", () => {
       result.current.roll([onlySurvivor]);
     });
     act(() => {
-      vi.advanceTimersByTime(2500);
+      vi.advanceTimersByTime(3350);
     });
 
     expect(result.current.result?.id).toBe("only");
@@ -243,7 +243,7 @@ describe("useRoll", () => {
     expect(vi.getTimerCount()).toBe(1);
 
     act(() => {
-      vi.advanceTimersByTime(2499);
+      vi.advanceTimersByTime(3349);
     });
     expect(result.current.rollState).toBe("rolling");
 

+ 4 - 3
src/components/dice/list-roll-carousel.tsx

@@ -10,9 +10,10 @@ const ITEM_WIDTH = 112; // w-28
 const ITEM_GAP = 12; // gap-3
 const POSTER_STRIDE = ITEM_WIDTH + ITEM_GAP;
 // Carousel pops in via the dice-emerge keyframe (~500ms scale-bounce), then
-// pauses briefly, then spins. useRoll's own 2500ms timer governs `state`
-// transitions but doesn't gate the visual settle — the carousel owns its
-// timeline and runs slightly longer for a punchier feel.
+// pauses briefly, then spins. Timeline: ENTRANCE_MS + PRE_SPIN_PAUSE_MS +
+// SPIN_DURATION_MS = 3350ms total to settle. useRoll's ANIMATION_DURATION_MS
+// is kept in sync with this sum so RollAnnouncer's "complete" aria-live
+// message fires when the result is actually visible.
 const ENTRANCE_MS = 500; // matches @utility animate-emerge duration
 const PRE_SPIN_PAUSE_MS = 100;
 const SPIN_DURATION_MS = 2750;

+ 3 - 0
src/components/dice/roll-announcer.tsx

@@ -24,6 +24,9 @@ export function RollAnnouncer({ state, winner }: RollAnnouncerProps) {
 
   useEffect(() => {
     if (state === "complete" && prevStateRef.current !== "complete") {
+      // Intentional: bump a counter to force a fresh aria-live span (see
+      // header comment). The setState-in-effect pattern is the point here.
+      // eslint-disable-next-line react-hooks/set-state-in-effect
       setCompleteCounter((n) => n + 1);
     }
     prevStateRef.current = state;

+ 7 - 4
src/components/landing/more-info-modal.tsx

@@ -21,11 +21,16 @@ export function MoreInfoModal({ movie, onClose }: MoreInfoModalProps) {
   const closeBtnRef = useRef<HTMLButtonElement>(null);
 
   useEffect(() => {
+    // Standard SSR-safe portal mount flag — intentional setState-in-effect.
+    // eslint-disable-next-line react-hooks/set-state-in-effect
     setMounted(true);
   }, []);
 
   const year = movie.release_date ? movie.release_date.slice(0, 4) : "";
-  const genres = movie.genre_ids.map((id) => TMDB_GENRE_MAP[id]).filter(Boolean).slice(0, 4);
+  const genres = movie.genre_ids
+    .map((id) => TMDB_GENRE_MAP[id])
+    .filter(Boolean)
+    .slice(0, 4);
   const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
 
   useEffect(() => {
@@ -121,9 +126,7 @@ export function MoreInfoModal({ movie, onClose }: MoreInfoModalProps) {
         <div className="flex min-w-0 flex-1 flex-col">
           <h2 id="more-info-title" className="pr-12 text-3xl font-semibold leading-tight">
             {movie.title}
-            {year && (
-              <span className="ml-2 text-xl font-normal text-foreground/60">({year})</span>
-            )}
+            {year && <span className="ml-2 text-xl font-normal text-foreground/60">({year})</span>}
           </h2>
 
           {genres.length > 0 && (

+ 7 - 1
src/components/movies/movie-list-client.tsx

@@ -44,7 +44,11 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
   const unwatchedPool = useMemo(() => allMovies.filter((m) => !m.watched), [allMovies]);
   const poolEmpty = unwatchedPool.length === 0;
 
-  const { data: searchData, isLoading: isSearchLoading } = useMovieSearch(searchQuery);
+  const {
+    data: searchData,
+    isLoading: isSearchLoading,
+    isError: isSearchError,
+  } = useMovieSearch(searchQuery);
   const tmdbResults = searchData?.results ?? [];
 
   const addMovie = useAddMovie();
@@ -145,6 +149,8 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
               isAdding={addMovie.isPending}
               addingTmdbId={addingTmdbId}
               onAdd={handleAdd}
+              isError={isSearchError}
+              hasData={!!searchData}
             />
           </div>
         )}

+ 25 - 7
src/components/movies/search-results.tsx

@@ -13,6 +13,8 @@ interface SearchResultsProps {
   isAdding: boolean;
   addingTmdbId: number | null;
   onAdd: (tmdbId: number) => void;
+  isError?: boolean;
+  hasData?: boolean;
 }
 
 export function SearchResults({
@@ -22,6 +24,8 @@ export function SearchResults({
   isAdding,
   addingTmdbId,
   onAdd,
+  isError = false,
+  hasData = true,
 }: SearchResultsProps) {
   if (!query || query.length < 2) return null;
 
@@ -29,9 +33,7 @@ export function SearchResults({
 
   // Filter group movies that match the search query
   const lowerQuery = query.toLowerCase();
-  const matchingGroupMovies = groupMovies.filter((m) =>
-    m.title.toLowerCase().includes(lowerQuery),
-  );
+  const matchingGroupMovies = groupMovies.filter((m) => m.title.toLowerCase().includes(lowerQuery));
 
   // Filter TMDB results: exclude movies already in the group
   const newResults = tmdbResults.filter((m) => !groupTmdbIds.has(m.id));
@@ -39,10 +41,28 @@ export function SearchResults({
   const hasInList = matchingGroupMovies.length > 0;
   const hasNew = newResults.length > 0;
 
-  if (!hasInList && !hasNew) {
+  // Distinguish a network/fetch error (e.g. offline) from a genuine zero-hit
+  // TMDB response. Showing "No results" on a transport failure is misleading
+  // — the user thinks the movie doesn't exist when really the request never
+  // reached TMDB.
+  if (isError && !hasInList) {
+    return (
+      <p role="alert" aria-live="polite" className="py-4 text-center text-sm text-red-500">
+        Couldn&apos;t reach search — check your connection.
+      </p>
+    );
+  }
+
+  if (!hasInList && !hasNew && hasData) {
     return <p className="py-4 text-center text-sm text-gray-500">No results found.</p>;
   }
 
+  if (!hasInList && !hasNew) {
+    // Still loading first response and no local matches — render nothing
+    // rather than a misleading empty-state.
+    return null;
+  }
+
   return (
     <div className="space-y-4" aria-live="polite">
       {hasInList && (
@@ -64,9 +84,7 @@ export function SearchResults({
         </section>
       )}
 
-      {hasInList && hasNew && (
-        <hr className="border-gray-200 dark:border-gray-700" />
-      )}
+      {hasInList && hasNew && <hr className="border-gray-200 dark:border-gray-700" />}
 
       {hasNew && (
         <section>

+ 7 - 3
src/hooks/use-roll.ts

@@ -6,7 +6,12 @@ import { selectRandomMovie } from "@/lib/dice/randomizer";
 
 export type RollState = "idle" | "rolling" | "complete";
 
-const ANIMATION_DURATION_MS = 2500;
+// Aligned with ListRollCarousel's visible settle: ENTRANCE_MS (500) +
+// PRE_SPIN_PAUSE_MS (100) + SPIN_DURATION_MS (2750) = 3350ms. RollAnnouncer
+// fires its "complete" aria-live message off this state transition, so it
+// must match what sighted users see — earlier values announced the result
+// while the carousel was still spinning.
+const ANIMATION_DURATION_MS = 3350;
 
 const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
 
@@ -66,8 +71,7 @@ export function useRoll(): UseRollReturn {
 
       // Capture by value so concurrent real-time cache mutations to the upstream
       // array cannot change the in-flight winner.
-      const snapshot =
-        eligibleMovies !== undefined ? [...eligibleMovies] : capturedPoolRef.current;
+      const snapshot = eligibleMovies !== undefined ? [...eligibleMovies] : capturedPoolRef.current;
       capturedPoolRef.current = snapshot;
 
       const winner = selectRandomMovie(snapshot);

+ 33 - 0
supabase/migrations/00006_movies_realtime_replica_identity.sql

@@ -0,0 +1,33 @@
+-- Enable Supabase Realtime UPDATE payloads for movies.
+--
+-- Two requirements for cross-window real-time UPDATE propagation:
+--
+-- 1. The table must be a member of the `supabase_realtime` publication.
+--    Without this, no postgres_changes events are emitted at all.
+--
+-- 2. REPLICA IDENTITY FULL is required for UPDATE payloads to include the
+--    full new row. With the default (REPLICA IDENTITY DEFAULT, which logs
+--    only the primary key), `payload.new` arrives with only `id` populated
+--    and our TanStack Query cache merge replaces a fully-populated row with
+--    a sparse one, losing the `watched` flag and other fields.
+--
+-- INSERT and DELETE work without FULL because INSERT carries the full new
+-- row regardless and DELETE only needs the PK to identify the row to remove.
+-- That matches the user-reported symptom (only UPDATE was broken).
+
+ALTER TABLE public.movies REPLICA IDENTITY FULL;
+
+-- Idempotent publication membership: ADD TABLE errors if already present,
+-- so guard with a DO block that checks pg_publication_tables first.
+DO $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1 FROM pg_publication_tables
+    WHERE pubname = 'supabase_realtime'
+      AND schemaname = 'public'
+      AND tablename = 'movies'
+  ) THEN
+    EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.movies';
+  END IF;
+END
+$$;

+ 3 - 2
tsconfig.json

@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
-    "target": "ES2017",
-    "lib": ["dom", "dom.iterable", "esnext"],
+    "target": "ES2018",
+    "lib": ["dom", "dom.iterable", "esnext", "es2018.regexp"],
     "allowJs": true,
     "skipLibCheck": true,
     "strict": true,
@@ -13,6 +13,7 @@
     "isolatedModules": true,
     "jsx": "react-jsx",
     "incremental": true,
+    "types": ["vitest/globals", "node"],
     "plugins": [
       {
         "name": "next"