Files
movieloop-frontend/CHANGELOG.md
T
TehRiehlDeal 5a7e8cebc3 feat: repo hygiene - push opt-in, paged admin jobs, shared game screen (2.2.0)
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.
2026-09-12 00:01:42 -07:00

20 KiB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

History prior to 1.1.0 has not yet been backfilled from git.

[Unreleased]

[2.2.0] - 2026-09-11

Repository hygiene (WP-17: L2, L4, M9, M35, the frontend half of the audit's section 3 prune list). Minor: push notifications become usable, and nothing a player could reach was removed. The admin console's maintenance jobs need backend 3.0.0.

Added

  • Push notifications can be turned on. Profile → Notifications has the toggle. The whole browser half of the push stack - the service worker's push and notificationclick handlers, the subscribe/unsubscribe calls, the server's VAPID key - had shipped for months with nothing calling it, so no browser ever held a subscription (M35). The prompt is asked from a click, as browsers require, and a refusal or a server without VAPID keys is explained in place rather than failing silently. If a subscription cannot be registered with the server, the browser-side one is rolled back so the two never disagree.
  • The admin console's "Backfill Game Owners" card, for the job that re-attaches legacy games to their players. It reports how many were ambiguous - more than one player matched, so the server made no guess - alongside the count it re-attached.
  • Tests for the push library, the subscription hook, the notifications section and the pair picker (45 tests; 105 in total).

Changed

  • Admin maintenance jobs continue where they left off. Each one processes a page and, when more remains, the button becomes Continue and passes the server's cursor back. The three near-identical job cards are one component. The recalculate card also stopped printing "undefined leaderboard entries": its type declared a field the server had not sent for several releases.
  • Endless mode and quick play pick pairs through one franchise-aware picker. Endless had its own copy with no franchise check, so it could serve The Fellowship of the Ring against The Two Towers; both copies also shuffled with sort(() => Math.random() - 0.5), which is neither uniform nor a valid comparator.
  • The four game screens share one hook for the store wiring and label derivation they had each copied, and one search panel; the review page's read-only chain display is the live ChainDisplay in a static mode rather than a second component that had drifted from it. Verified byte-identical DOM across 21 rendered states before and after.
  • formatTime and error-message extraction each exist once (lib/format, lib/error) instead of five and three times.
  • noUncheckedIndexedAccess is on. Indexing now types as possibly undefined and the code handles it, with no non-null assertions added. Along the way, taking an initial with [0] became .charAt(0), which also stops an empty display name from throwing.
  • The package is named movie-loop-frontend; it had kept the scaffold's frontend-temp.
  • CI refuses to release a version whose v<version> git tag already exists on origin, before it builds anything, for the same reason the backend does.
  • The stock Vite README is replaced with one that describes this app.

Fixed

  • Whiplash in the preset pairs is TMDB id 244786; the table had 274870, a different film. Every preset title is now checked against what TMDB serves for its id.
  • Checking whether push is subscribed no longer awaits navigator.serviceWorker.ready, which never settles when no worker is registered - the check hung on any page load before registration.
  • getErrorMessage returns its fallback for a server error carrying an empty message array, rather than undefined.

Removed

  • The dead src/api/index.ts barrel and the fourteen API functions it alone referenced, three orphaned types, the Vite scaffold SVGs, the unused scroll-area primitive and four unused dialog/sheet exports, and the _onGameStart escape hatch that two stores read but nothing ever set. Nothing a user could reach.

[2.1.0] - 2026-09-11

Accessibility (WP-16: audit section 2.8).

Added

  • eslint-plugin-jsx-a11y (recommended rules) in the lint step, so an orphaned label, a clickable <div> or a missing ARIA attribute fails CI instead of shipping. It found 19 violations on arrival; all are fixed, none suppressed.
  • A "Skip to content" link on every page, visually hidden until focused, that moves focus to the main region.
  • Live regions where state used to change silently: link validation announces "Validating", a rejection (as an alert) and "Connected"; the versus and game-night lobby countdowns are assertive timers, so a screen-reader user is no longer dropped into a running match with no warning; the game-night player list announces players finishing or forfeiting.
  • Tests for the notification popover's keyboard contract (9 tests).

Changed

  • The notification bell is a real popover. It was a <div> with <div onClick> rows: nothing in it was focusable, nothing announced, no way to close it from the keyboard. The trigger now reports its state and the unread count in its name (Notifications, 3 unread), the panel is a labelled dialog the trigger controls, every notification is a button, the per-row controls have real names rather than tooltips, Escape closes and returns focus to the trigger, and the unread dot has text behind it.
  • The Challenge Friend overlay is the UI kit's Dialog. It was a fixed <div> with no role, no focus trap and no Escape - Tab went straight through it into the page behind. Base UI supplies the dialog role, aria-modal, focus trapping, Escape and focus restoration.
  • The search combobox follows the ARIA 1.2 pattern: role="combobox" with aria-expanded/aria-controls on the input itself, rather than the 1.1 wrapper-div-with-aria-owns form that current screen readers announce inconsistently. Verified live: the state flips and aria-controls resolves to the listbox.
  • Every form control has a label. Sign-in/sign-up, the three change-password fields (which had only placeholders, and placeholders vanish once you type), the admin challenge form, custom game, game night's room form (including a join-password field that had nothing at all), the versus lobby form, the friend search and the chat message box. Labels above groups of buttons ("Difficulty", "Time Limit", "Movie A") are group labels via role="group"/aria-labelledby rather than a faked htmlFor.
  • Every icon-only button has a name that says what it does: chat send, back, pagination, display-name save/cancel, notification actions, and three versus buttons whose content collapsed to a bare spinner while loading.
  • The game header's "Passed Movie B" read the same text in both states - only the icon and colour changed - so a screen reader heard the wrong thing until B was reached. It now announces "Movie B reached" or "not yet reached", and the online indicator on the friends list has text behind the green dot.
  • autoFocus is gone from the friend search. It skipped screen-reader users past the page heading; the field is one Tab away.
  • Reduced motion is respected globally. A single stylesheet rule collapses every animation and transition when the OS preference is set; the confetti overlay does not render at all under it, since a still frame of confetti has no meaning.

[2.0.1] - 2026-09-11

Bundle and asset budget (WP-15: M26).

Added

  • A test suite, where there was none: vitest with @testing-library/react and jsdom, run by npm test, with coverage via npm run test:cov. The first tests are the regression net for the audit's C1 - every multiplayer mode used to leave the completion modal on "Calculating score..." forever because no backend session was ever created - and for the chain-validation state machine in both its server-validated and anonymous client-side forms. 51 tests. Coverage thresholds of 90% (lines, branches, functions, statements) are codified in vite.config.ts; the suite does not meet them yet, so test:cov fails honestly while CI runs the plain npm test. Tests intercept the axios client rather than the API modules, because a second concurrent dynamic import() of a mocked aliased module resolved to the real file under vitest - measured, and it reached the development backend once.
  • A test job in CI.

Changed

  • Every page except Home loads on demand. App.tsx imported all 24 pages statically, so a first-time visitor downloaded the admin panel, three multiplayer modes and the preset-pair table before the landing page could render: one 682 KB chunk. Routes are now React.lazy behind a Suspense boundary, giving 56 chunks with pages at 2-11 KB each. Verified in a browser against the production build: the landing page fetches only the app chunk and vendor chunks; navigating to a route fetches that route's chunks and renders; a deep link into an authenticated route lazy-loads it, redirects to sign-in, and lazy-loads that too.
  • Vendor code is split into separately cached chunks (vendor-react, vendor-router, vendor-ui, vendor-net) via manualChunks. The first load is not much smaller - the main chunk was 95% dependencies, and the dependency set is the load - but the chunk that changes on every deploy is now 50 KB (14 KB gzipped) rather than 682 KB, so a returning visitor after a deploy re-downloads the app, not React. Vite's 500 KB chunk warning no longer fires.

Fixed

  • The favicon was an 888 KB image - PNG data under a .jpg name, 768x768 with a fully opaque alpha channel - fetched on every page load. It is now a 2.6 KB 32px PNG, with a 180px apple-touch-icon for iOS home screens.
  • manifest.json declared that same file as both its 192x192 and 512x512 icon, typed image/jpeg. It now points at real 192px and 512px PNGs with the correct type, so installs get a correctly sized icon instead of a mislabelled one the browser has to rescale.
  • The service worker's push notifications referenced /favicon.ico, which never existed; a push with no icon in its payload rendered blank. They use the 192px icon.

Known issues

  • The useGameStore() selector item from M26 was already resolved in WP-10; the chain-validation hook subscribes per field.
  • Not done, recorded for later: socket.io-client (~90 KB of vendor-net) ships to anonymous visitors because the page chrome's notification bell reaches the notification store, which imports it statically. Loading the socket client on demand would mean an async connect() in four stores - a change in its own right, not a bundle setting.

[2.0.0] - 2026-09-10

Container and response-header hardening (WP-13: M31, M34).

⚠ Breaking

  • The production container now listens on 8080, not 80. nginx runs as an unprivileged user, and a non-root process cannot bind a privileged port without CAP_NET_BIND_SERVICE, which compose drops. Anything routing to this container must be repointed: in the homelab Traefik config that means traefik.http.services.movieloop.loadbalancer.server.port changes from 80 to 8080. The published mapping in docker-compose.prod.yml is updated here, but the Traefik label lives outside this repo and must be changed by hand.

Fixed

  • Security headers are no longer dropped from most responses. nginx's add_header is not additive across levels: a block declaring any add_header of its own discards every header inherited from its parent. location /assets/ and location = /config.js each declared one, so X-Frame-Options, X-Content-Type-Options and Referrer-Policy were silently absent from every JavaScript and CSS response - which is most of what a browser loads. The headers now come from a snippet included in each location. Verified against the built image: 6 of 6 headers present on /, on a hashed /assets/*.js bundle, and on /config.js.
  • config.js no longer interpolates API_URL unescaped. This used envsubst, which does no escaping, so a value containing a double quote closed the JavaScript string literal and everything after it became executable script on every page load. The value is now escaped when rendered. Operator-supplied, so not a likely attack - but the page hands its auth token to whatever origin that value resolves to, and a malformed value is now inert rather than executable.

Added

  • A Content-Security-Policy, plus Strict-Transport-Security and Permissions-Policy. With the JWT in localStorage, CSP is the main compensating control against injected script and there was none. connect-src is derived at container start from API_URL, because the API is not same-origin in production - the app is served from one subdomain and the API from another, so naming it explicitly is required or every request and websocket would be blocked. The inline theme script in index.html runs before first paint to avoid a flash of the wrong theme and so cannot move to a file; it is allowed by SHA-256 hash rather than by unsafe-inline, which would have bought almost nothing. If that script is edited its hash must be regenerated in docker/40-render-config.sh or the page will not apply its theme.

Changed

  • The production image is based on nginxinc/nginx-unprivileged:alpine and runs as uid 101 rather than root. It serves static files and never needed root. Verified in the built image: uid=101(nginx).
  • envsubst is no longer installed, since the entrypoint renders config.js itself in order to escape the value.

[1.3.0] - 2026-09-10

Session handling and effect correctness.

Fixed

  • An expired or revoked session no longer leaves the app half signed in. The response interceptor only wrote to the console, so the nav still showed your name while every request quietly failed. A 401 now clears the token and returns you to sign-in. Signing in with the wrong password is unaffected - that is an answer, not an expired session.
  • Signing out ends the whole session. It disconnected the notifications socket and nothing else, leaving the chat, versus and game night sockets open with a token that had just been removed, and the friends and game stores populated - so signing in as someone else on the same tab showed the previous person's friend list and pending-request badge.
  • Leaving a match no longer carries its result into the next one. Disconnecting cleared only the socket, so the match id, result and score survived and the next match opened showing the previous match's completion modal.
  • Reconnecting no longer duplicates everything. All four sockets tested whether the existing connection was connected rather than whether it existed, so one that was mid-reconnect got replaced without being closed; the abandoned socket kept its own handlers and delivered every event a second time.
  • Game night spectators are detected again. The check decoded the session token with atob, which rejects the base64url encoding tokens actually use, so it threw inside a socket handler and the spectator flag was never set.
  • Reloading your profile no longer discards a game in progress. Effects depended on the whole user object, which is rebuilt on every profile load, so they re-ran when nothing had changed - and in an async match the re-run reset the board mid-game.
  • Undo is applied in order and no longer drifts from the server. Rapid undos fired overlapping requests at a server that removes one link per call, and a failure was only logged while the local chain had already been shortened.
  • Leaving a page cancels its in-flight requests instead of letting them land afterwards.
  • The friends page no longer removes other components' socket listeners when it unmounts.
  • Copying a result no longer leaves a timer running after the page closes.
  • Share links point at the deployment that produced them, rather than always at production.

Security

  • The runtime API setting is escaped when written into the page. It was substituted raw, so a value containing a quote ended the string and the rest became executable JavaScript on every page load - and the page hands its auth token to whatever that value resolves to. A malformed value is now also rejected on read, falling back to the default.

Removed

  • The difficulty selector in Endless mode. It was never applied to anything: difficulty is derived from cast analysis on the server, and Endless builds its pairs in the browser, so the control silently did nothing. The genre and decade filters are unaffected.

[1.2.3] - 2026-09-10

Fixed

  • The async challenge leaderboard no longer shows an empty heading to anyone who has not played that match. The backend now withholds the movie pair from non-participants - the pair is the puzzle - so the two titles arrive absent rather than empty, and the page rendered a bare separator between nothing. It now says the movies are hidden until you play, and the type reflects that they are optional.

[1.2.2] - 2026-09-09

Fixed

  • Shell scripts and Dockerfiles are pinned to LF so a Windows checkout no longer copies CRLF line endings into the container image. docker/40-render-config.sh was affected; nginx sources it rather than executing it, so nothing was visibly broken, but the file was wrong in the image.

[1.2.1] - 2026-09-09

Fixed

  • All high-severity dependency advisories cleared (npm audit: 23 -> 0), entirely within existing semver ranges.
  • Five further set-state-in-effect violations caught by the updated react-hooks plugin, including search state that was cleared on every keystroke below the search threshold, and an async submit guard that re-ran its own effect.

Changed

  • In-range dependency updates, notably react 19.2 -> 19.3 and @base-ui/react 1.2 -> 1.8.
  • shadcn, tailwindcss, @tailwindcss/vite and tw-animate-css moved to devDependencies. They are build-time only - shadcn is a scaffolding CLI with no runtime imports - but as runtime dependencies they pulled msw, @babel/core and ts-morph into the production dependency tree that vulnerability scanning reads.

[1.2.0] - 2026-09-09

Changed

  • Game completion, hints and all versus submissions follow the server's authoritative scoring contract: completion posts no payload, hints go through the server (which also records them), and match submissions send a session id rather than a score.

Fixed

  • Stale hints are cleared when the chain changes; the previous movie's cast used to stay on screen after picking an actor another way.

[1.1.0] - 2026-09-09

Fixed

  • Versus, Game Night and Async Versus can complete a match again. None of the three ever created a backend game session, so no score was produced, nothing was submitted, the opponent was never notified, and the completion modal had no way out.
  • Chain links may be reused. Repeating an actor or movie was rejected outright instead of being allowed and penalised, which could strand a player mid-chain. Reuse now warns that it costs points, and the penalty appears in the score breakdown.
  • A failed async score submission is no longer retried on every render.
  • Confetti no longer re-randomises mid-flight.
  • Page data fetches are cancelled on unmount, so a late response cannot write into a view the player has left.
  • Profile stats and history load independently, fixing a guard that could stop the stats request from ever re-running.

Changed

  • The dev container starts the Vite server correctly and now picks up source edits, so hot reload works.
  • The lint CI job fails on warnings, and the two React hook rules that had been demoted to warnings are back at error.