WP-17. Minor: push notifications become usable and nothing a player could reach was removed. The admin console's jobs need backend 3.0.0. Push (M35): the whole browser half - the service worker's handlers, the subscribe/unsubscribe calls, the server's VAPID key - had shipped with nothing calling it, so no browser ever held a subscription. The toggle lives in Profile because the permission prompt has to come from a click. The old isPushSubscribed awaited serviceWorker.ready, which never settles with no worker registered; it uses getRegistration() now. Admin: each job pages and the button becomes Continue with the server's cursor; the recalculate card also stopped printing "undefined leaderboard entries" for a field the server had not sent for several releases. The three copy-pasted job cards are one component. Consolidation: the four game screens share use-game-screen and one search panel; the review page's static chain is ChainDisplay in a static mode (21 rendered states verified byte-identical); formatTime and error extraction exist once; Endless and quick play share a franchise-aware picker instead of two divergent sort(() => Math.random() - 0.5) copies. The dead api/index barrel and the fourteen functions only it referenced are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected and every preset title is checked against TMDB.
movieloop-frontend
The web client for "You Know Who Else Was In That Movie?" — a trivia-puzzle game in the spirit of Six Degrees of Kevin Bacon. You are given two movies and build a chain of actor→movie links that loops back to where you started. Every link is checked against TMDB as you type; the server scores the result.
React 19 · TypeScript (strict) · Vite 7 · Tailwind CSS v4 + shadcn/Base UI · React Router 7 · Zustand · Axios · socket.io-client.
The API lives in a sibling repository, movieloop-backend. The two are orchestrated by the compose files in the parent folder, which is deliberately not itself a git repository.
What's in the app
| Route | What it is |
|---|---|
/, /game |
Quick play — a random pair, chain-building, scoring |
/daily |
The three daily challenges (easy / medium / hard), same puzzles for everyone, ranked on the daily leaderboard |
/endless, /custom |
Endless mode with genre/decade filters; pick-your-own-pair |
/versus, /versus/lobby/:matchId, /versus/game |
Live head-to-head with a shared puzzle and opponent progress |
/versus/async-game/:matchId, /versus/async/:matchId/leaderboard |
Challenge a friend; they play the same pair later |
/game-night, /game-night/lobby/:roomId, /game-night/play, /game-night/:roomId/results |
Host a room for a table of players |
/leaderboard, /achievements, /profile, /game/:id/review |
Ranks, badges, account settings, replay a finished chain |
/friends, /friends/add, /friends/chat/:friendId |
Friends, requests, direct messages |
/admin |
Admin console (admins only) |
Every route is code-split with React.lazy; vendor libraries are chunked separately so a deploy invalidates only the app chunk.
Running it
With Docker (the normal way)
From the parent folder:
docker compose up -d # postgres, redis, rabbitmq, backend, and this app on http://localhost:5173
The dev container runs Vite with file polling on (VITE_USE_POLLING), because a Docker Desktop bind mount on Windows delivers no file-change events.
Natively
npm ci
npm run dev # http://localhost:5173, proxies /api and /socket.io to localhost:3000
The backend must be running on port 3000 (see its README). No .env is needed for development.
Configuration
The built app reads its API base at runtime, not at build time, so one image serves every environment.
| Where | Name | Meaning |
|---|---|---|
| Container env | API_URL |
API base, e.g. https://api.example.com/api/v1 or a same-origin path. Rendered into /config.js and into the CSP's connect-src when the container starts. Defaults to /api/v1. |
| Build-time (dev only) | VITE_API_URL |
Fallback used by npm run dev / npm run build when no /config.js is served. |
| Dev container | VITE_USE_POLLING |
Turns on Vite's polling watcher. |
src/lib/config.ts rejects a malformed API_URL (anything that is not an http(s) URL or a leading-slash path) and falls back, loudly, because the socket layer sends the auth token to whatever this resolves to.
Development
npm run lint # eslint --max-warnings 0, including jsx-a11y
npx tsc -b
npm test # vitest + testing-library, jsdom
npm run test:cov # coverage report; thresholds are 90% across the board
npm run build # tsc -b && vite build -> dist/
npm run preview # serve dist/ locally
Coverage thresholds live in vite.config.ts. The suite mocks the axios client and fails loudly on any request it did not route, so no test can reach a real backend.
noUncheckedIndexedAccess is on: indexing an array or record gives you T | undefined, and the code handles it rather than asserting it away. There are no any, no eslint-disable and no @ts-ignore in src/; the lint gate is configured to keep it that way.
Layout
src/
├── api/ one module per backend resource, all through api/client.ts (axios)
├── components/
│ ├── game/ ChainDisplay, SearchAutocomplete (ARIA combobox), GameHeader, completion modals…
│ ├── game-night/, versus/
│ ├── layout/ PageLayout, nav, NotificationBell
│ ├── profile/ PushNotificationsSection
│ └── ui/ shadcn primitives (Base UI)
├── hooks/ use-game-screen (the shared game-page shell), use-chain-validation, use-timer, use-hints, use-push-subscription
├── lib/ config, error, format, movie-pair(s), push, sounds, create-socket, storage
├── pages/ one file per route
├── stores/ Zustand: auth, game, versus, game-night, chat, friends, notifications
├── types/
└── App.tsx routes (lazy) + Suspense
public/
├── sw.js service worker: push display + notification click
├── manifest.json, icon-192.png, icon-512.png, apple-touch-icon.png, favicon-32.png
└── sounds/
How a game works, client side
game-store owns the session: it creates one on the server, sends each link to POST /games/:id/validate-link, and asks the server to score on completion. The store never computes a score. When the backend cannot be reached, anonymous play falls back to checking TMDB credits client-side through the API proxy — but such a game has no session and is not scored or ranked.
Multiplayer pages share use-game-screen, which wires the store, the timer, hints and the search panel; the mode-specific pages add the socket handling.
Sockets
Four namespaces (/notifications, /chat, /versus, /game-night), created by lib/create-socket.ts with the JWT in auth.token. Each store owns exactly one socket, and connect() replaces (and disconnects) a stale instance rather than stacking a second one. Signing out disconnects all four and resets every store that holds another user's data.
Push notifications
Opt-in from Profile → Notifications. The service worker is registered at sign-in; enabling push asks the browser for permission and registers the subscription with the server. The server sends push only when its VAPID keys are configured — otherwise the toggle explains that push is not available here.
Accessibility
eslint-plugin-jsx-a11y runs at error level. Every form control has a label, every icon-only button has a name, dialogs are real dialogs (Base UI), validation results and countdowns are announced through live regions, there is a skip link, and prefers-reduced-motion collapses every animation.
Production image
Dockerfile has three stages: dev (Vite), build, and production — nginxinc/nginx-unprivileged serving dist/ on port 8080 as uid 101. docker/40-render-config.sh runs at container start to write /config.js and the security-header snippet (CSP with the API origin in connect-src, HSTS, frame and MIME protections) from API_URL. nginx.conf repeats the headers in every location, since nginx's add_header is not inherited.
Releasing
Versions follow semver and every behavioural change updates CHANGELOG.md in the same commit. On a push to main, CI lints, typechecks, tests, scans (gitleaks, semgrep, Trivy fs + image), builds the image, refuses to proceed if v<version> already exists as a git tag or as a Harbor image tag, pushes to Harbor, signs with cosign, and pushes the git tag back. Bump package.json before merging or the run fails on purpose.
License
Private. All rights reserved.