From 5a7e8cebc3ea2c8c27ef707f7eb297a8f5bb25ef Mon Sep 17 00:00:00 2001 From: Kevin Riehl Date: Sat, 12 Sep 2026 00:01:42 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 73 +++++ README.md | 167 ++++++---- package-lock.json | 74 ++++- package.json | 4 +- public/vite.svg | 1 - src/api/achievements.ts | 5 - src/api/admin.ts | 51 ++- src/api/chat.ts | 5 - src/api/daily-challenges.ts | 35 -- src/api/friends.ts | 5 - src/api/game-night.ts | 16 - src/api/index.ts | 81 ----- src/api/leaderboards.ts | 10 - src/api/notifications.ts | 4 - src/api/persons.ts | 10 - src/api/versus.ts | 7 - src/assets/react.svg | 1 - src/components/game/CelebrationOverlay.tsx | 7 +- src/components/game/ChainDisplay.tsx | 87 ++++- src/components/game/GameCompletionModal.tsx | 9 +- src/components/game/GameSearchPanel.tsx | 75 +++++ src/components/game/SearchAutocomplete.tsx | 12 +- src/components/game/StaticChainDisplay.tsx | 140 -------- .../layout/NotificationBell.test.tsx | 12 +- .../profile/PushNotificationsSection.test.tsx | 113 +++++++ .../profile/PushNotificationsSection.tsx | 70 ++++ src/components/ui/dialog.tsx | 5 - src/components/ui/scroll-area.tsx | 54 ---- src/components/ui/sheet.tsx | 30 -- src/hooks/use-chain-validation.test.ts | 4 +- src/hooks/use-chain-validation.ts | 22 +- src/hooks/use-game-screen.ts | 90 ++++++ src/hooks/use-push-subscription.test.ts | 170 ++++++++++ src/hooks/use-push-subscription.ts | 79 +++++ src/hooks/use-timer.ts | 7 +- src/lib/error.ts | 21 +- src/lib/format.ts | 15 + src/lib/movie-pair.test.ts | 72 +++++ src/lib/movie-pair.ts | 90 ++++++ src/lib/movie-pairs.ts | 10 +- src/lib/push.test.ts | 305 ++++++++++++++++++ src/lib/push.ts | 117 +++++-- src/pages/AddFriend.tsx | 6 +- src/pages/Admin.tsx | 227 ++++++------- src/pages/AsyncMatchLeaderboard.tsx | 5 +- src/pages/AsyncVersusGame.tsx | 75 +---- src/pages/Chat.tsx | 2 +- src/pages/DailyChallenge.tsx | 6 +- src/pages/Endless.tsx | 10 +- src/pages/Friends.tsx | 8 +- src/pages/Game.tsx | 81 +---- src/pages/GameNightPlay.tsx | 90 +----- src/pages/GameReview.tsx | 11 +- src/pages/Home.tsx | 50 +-- src/pages/Leaderboard.tsx | 7 +- src/pages/Profile.tsx | 13 +- src/pages/Versus.tsx | 8 +- src/pages/VersusGame.tsx | 75 +---- src/stores/game-night-store.ts | 9 +- src/stores/game-store.test.ts | 4 +- src/stores/game-store.ts | 2 + src/stores/versus-store.ts | 7 +- src/types/tmdb.ts | 14 - tsconfig.app.json | 1 + 64 files changed, 1787 insertions(+), 1089 deletions(-) delete mode 100644 public/vite.svg delete mode 100644 src/api/index.ts delete mode 100644 src/assets/react.svg create mode 100644 src/components/game/GameSearchPanel.tsx delete mode 100644 src/components/game/StaticChainDisplay.tsx create mode 100644 src/components/profile/PushNotificationsSection.test.tsx create mode 100644 src/components/profile/PushNotificationsSection.tsx delete mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/hooks/use-game-screen.ts create mode 100644 src/hooks/use-push-subscription.test.ts create mode 100644 src/hooks/use-push-subscription.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/movie-pair.test.ts create mode 100644 src/lib/movie-pair.ts create mode 100644 src/lib/push.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e7f82..057d1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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` 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). diff --git a/README.md b/README.md index d2e7761..31f3dfd 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,124 @@ -# React + TypeScript + Vite +# movieloop-frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +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](https://www.themoviedb.org/) as you type; the server scores the result. -Currently, two official plugins are available: +React 19 · TypeScript (strict) · Vite 7 · Tailwind CSS v4 + shadcn/Base UI · React Router 7 · Zustand · Axios · socket.io-client. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +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. -## React Compiler +## What's in the app -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +| 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) | -## Expanding the ESLint configuration +Every route is code-split with `React.lazy`; vendor libraries are chunked separately so a deploy invalidates only the app chunk. -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +## Running it -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +### With Docker (the normal way) - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +From the parent folder: - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```sh +docker compose up -d # postgres, redis, rabbitmq, backend, and this app on http://localhost:5173 ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +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. -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +### Natively -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```sh +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 + +```sh +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` 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. diff --git a/package-lock.json b/package-lock.json index 1797304..16b8c3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "frontend-temp", - "version": "2.1.0", + "name": "movie-loop-frontend", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend-temp", - "version": "2.1.0", + "name": "movie-loop-frontend", + "version": "2.2.0", "license": "MIT", "dependencies": { "@base-ui/react": "^1.2.0", @@ -2625,6 +2625,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", diff --git a/package.json b/package.json index d03102f..75e5054 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "frontend-temp", + "name": "movie-loop-frontend", "private": true, - "version": "2.1.0", + "version": "2.2.0", "type": "module", "license": "MIT", "author": "Kevin Riehl", diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/api/achievements.ts b/src/api/achievements.ts index 1c92f77..84d8b51 100644 --- a/src/api/achievements.ts +++ b/src/api/achievements.ts @@ -13,11 +13,6 @@ export interface Achievement { unlockedAt: string | null; } -export async function getAllAchievements(): Promise { - const { data } = await apiClient.get('/achievements'); - return data; -} - export async function getUserAchievements( signal?: AbortSignal, ): Promise { diff --git a/src/api/admin.ts b/src/api/admin.ts index 9e1fdc6..4db2704 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -28,11 +28,18 @@ export interface AdminUsersResponse { totalPages: number; } -export interface RecalculateResult { +/** + * The maintenance jobs process one page per request. `hasMore` says whether + * to call again, and `nextCursor` is what to call again with. + */ +export interface BatchPage { + hasMore: boolean; + nextCursor: string | null; +} + +export interface RecalculateResult extends BatchPage { updated: number; skipped: number; - leaderboardUpdated: number; - backfilled: number; total: number; } @@ -57,9 +64,32 @@ export async function listUsers( return data; } -export async function recalculateScores(): Promise { +export async function recalculateScores( + cursor?: string, +): Promise { const { data } = await apiClient.post( '/admin/recalculate-scores', + undefined, + { params: cursor ? { cursor } : undefined }, + ); + return data; +} + +export interface BackfillGameOwnersResult extends BatchPage { + backfilled: number; + /** Left alone: more than one player matched, so no guess was made. */ + ambiguous: number; + unmatched: number; + total: number; +} + +export async function backfillGameOwners( + cursor?: string, +): Promise { + const { data } = await apiClient.post( + '/admin/backfill-user-ids', + undefined, + { params: cursor ? { cursor } : undefined }, ); return data; } @@ -75,11 +105,6 @@ export async function generateChallenge( return data; } -export async function deleteChallenge(id: string) { - const { data } = await apiClient.delete(`/admin/daily-challenges/${id}`); - return data; -} - export interface GeneratedChallenge { id: string; difficulty: string; @@ -98,14 +123,18 @@ export async function generateAllChallenges( return data; } -export interface RetroactiveAchievementsResult { +export interface RetroactiveAchievementsResult extends BatchPage { totalAwarded: number; usersAffected: number; } -export async function retroactiveAchievements(): Promise { +export async function retroactiveAchievements( + cursor?: string, +): Promise { const { data } = await apiClient.post( '/admin/retroactive-achievements', + undefined, + { params: cursor ? { cursor } : undefined }, ); return data; } diff --git a/src/api/chat.ts b/src/api/chat.ts index d1a95b5..640a15d 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -33,8 +33,3 @@ export async function getMessages( const { data } = await apiClient.get(`/chat/${friendId}/messages`, { params }); return data; } - -export async function deleteMessage(messageId: string) { - const { data } = await apiClient.delete(`/chat/messages/${messageId}`); - return data; -} diff --git a/src/api/daily-challenges.ts b/src/api/daily-challenges.ts index 0f54c41..269a59a 100644 --- a/src/api/daily-challenges.ts +++ b/src/api/daily-challenges.ts @@ -13,21 +13,6 @@ export interface DailyChallenge { difficulty: string; } -export interface DailyChallengeHistory { - data: DailyChallenge[]; - total: number; - page: number; - limit: number; - totalPages: number; -} - -export async function getTodaysChallenge(): Promise { - const { data } = await apiClient.get( - '/daily-challenges/today', - ); - return data; -} - export async function getTodaysChallenges( signal?: AbortSignal, ): Promise { @@ -37,23 +22,3 @@ export async function getTodaysChallenges( ); return data; } - -export async function getChallengeHistory( - page = 1, - limit = 20, -): Promise { - const { data } = await apiClient.get( - '/daily-challenges', - { params: { page, limit } }, - ); - return data; -} - -export async function getChallengeByDate( - date: string, -): Promise { - const { data } = await apiClient.get( - `/daily-challenges/${date}`, - ); - return data; -} diff --git a/src/api/friends.ts b/src/api/friends.ts index bbcbfd6..aaa7d54 100644 --- a/src/api/friends.ts +++ b/src/api/friends.ts @@ -76,11 +76,6 @@ export async function removeFriend(friendshipId: string) { return data; } -export async function blockUser(userId: string) { - const { data } = await apiClient.post('/friends/block', { userId }); - return data; -} - export async function createFriendChallenge( friendId: string, difficulty: string, diff --git a/src/api/game-night.ts b/src/api/game-night.ts index 3781c87..787c5a0 100644 --- a/src/api/game-night.ts +++ b/src/api/game-night.ts @@ -82,11 +82,6 @@ export async function getOpenRooms( return data; } -export async function getMyRooms(): Promise { - const { data } = await apiClient.get('/game-night/me'); - return data; -} - export async function joinRoom( roomId: string, password?: string, @@ -98,17 +93,6 @@ export async function joinRoom( return data; } -export async function leaveRoom(roomId: string): Promise { - await apiClient.post(`/game-night/${roomId}/leave`); -} - -export async function getRoom(roomId: string): Promise { - const { data } = await apiClient.get( - `/game-night/${roomId}`, - ); - return data; -} - export async function getRoomResults( roomId: string, signal?: AbortSignal, diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index 5f0f362..0000000 --- a/src/api/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -export { searchMovies, getMovieDetails, getMovieCredits, discoverMovies } from './movies'; -export { - searchPersons, - getPersonDetails, - getPersonMovieCredits, -} from './persons'; -export { createGameSession, completeGameSession, getGameHistory, getGameSession } from './games'; -export { register, login, getProfile } from './auth'; -export { - getTodaysChallenge, - getTodaysChallenges, - getChallengeHistory, - getChallengeByDate, -} from './daily-challenges'; -export { - getLeaderboard, - getUserStats, - getUserStreak, - checkDailyCompletion, -} from './leaderboards'; -export { - getFriends, - getPendingRequests, - getSentRequests, - getPendingCount, - searchUsers, - sendFriendRequest, - acceptRequest, - rejectRequest, - removeFriend, - blockUser, - createFriendChallenge, -} from './friends'; -export { - getNotifications, - getUnreadCount, - markNotificationRead, - markAllNotificationsRead, - deleteNotification, - deleteAllReadNotifications, -} from './notifications'; -export { - getConversations, - getMessages, - deleteMessage, -} from './chat'; -export { - createMatch, - getWaitingMatches, - joinMatch, - cancelMatch, - getMyWaitingMatches, - getMatch, - getUserMatchHistory, - submitCreatorScore, - startAsyncAttempt, - submitAsyncAttempt, - getAsyncLeaderboard, -} from './versus'; -export { - createRoom, - getOpenRooms, - getMyRooms, - joinRoom, - leaveRoom, - getRoom, - getRoomResults, -} from './game-night'; -export { updateProfile, changePassword } from './users'; -export { getAllAchievements, getUserAchievements } from './achievements'; -export { getVapidKey, subscribeToPush, unsubscribeFromPush } from './push'; -export { - getPlatformStats, - listUsers, - recalculateScores, - generateChallenge, - deleteChallenge, - generateAllChallenges, - retroactiveAchievements, - backfillUsernames, -} from './admin'; diff --git a/src/api/leaderboards.ts b/src/api/leaderboards.ts index d9bad50..865c57d 100644 --- a/src/api/leaderboards.ts +++ b/src/api/leaderboards.ts @@ -34,11 +34,6 @@ export interface UserStats { }[]; } -export interface StreakInfo { - currentStreak: number; - longestStreak: number; -} - export async function getLeaderboard( period: 'daily' | 'weekly' | 'all-time' = 'daily', page = 1, @@ -79,8 +74,3 @@ export async function checkDailyCompletion( ); return data; } - -export async function getUserStreak(): Promise { - const { data } = await apiClient.get('/leaderboards/me/streak'); - return data; -} diff --git a/src/api/notifications.ts b/src/api/notifications.ts index 9e0baa6..9f4b136 100644 --- a/src/api/notifications.ts +++ b/src/api/notifications.ts @@ -42,7 +42,3 @@ export async function markAllNotificationsRead(): Promise { export async function deleteNotification(id: string): Promise { await apiClient.delete(`/notifications/${id}`); } - -export async function deleteAllReadNotifications(): Promise { - await apiClient.delete('/notifications/read'); -} diff --git a/src/api/persons.ts b/src/api/persons.ts index 7afd2d7..c42e98c 100644 --- a/src/api/persons.ts +++ b/src/api/persons.ts @@ -1,7 +1,6 @@ import apiClient from './client'; import type { TmdbSearchPersonResponse, - TmdbPersonDetailsResponse, TmdbPersonMovieCreditsResponse, } from '@/types'; @@ -16,15 +15,6 @@ export async function searchPersons( return data; } -export async function getPersonDetails( - id: number, -): Promise { - const { data } = await apiClient.get( - `/persons/${id}`, - ); - return data; -} - export async function getPersonMovieCredits( id: number, ): Promise { diff --git a/src/api/versus.ts b/src/api/versus.ts index eb4c46d..3a59614 100644 --- a/src/api/versus.ts +++ b/src/api/versus.ts @@ -86,13 +86,6 @@ export async function getMatch( return data; } -export async function getUserMatchHistory(page = 1, limit = 20) { - const { data } = await apiClient.get('/versus/me/history', { - params: { page, limit }, - }); - return data; -} - /** Sends the session to score from; the server will not accept a score. */ export async function submitCreatorScore( matchId: string, diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/game/CelebrationOverlay.tsx b/src/components/game/CelebrationOverlay.tsx index 1e6519f..3d2e77c 100644 --- a/src/components/game/CelebrationOverlay.tsx +++ b/src/components/game/CelebrationOverlay.tsx @@ -17,9 +17,14 @@ interface ConfettiPiece { round: boolean; } +/** Always in range (index < CONFETTI_COLORS.length); the fallback only satisfies the type. */ +function randomConfettiColor(): string { + return CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)] ?? '#f44336'; +} + function makeConfetti(): ConfettiPiece[] { return Array.from({ length: CONFETTI_COUNT }, () => ({ - color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)], + color: randomConfettiColor(), left: Math.random() * 100, delay: Math.random() * 1, duration: 2 + Math.random() * 1.5, diff --git a/src/components/game/ChainDisplay.tsx b/src/components/game/ChainDisplay.tsx index 5de40b8..32dafef 100644 --- a/src/components/game/ChainDisplay.tsx +++ b/src/components/game/ChainDisplay.tsx @@ -4,20 +4,83 @@ import ActorCard from './ActorCard'; import SerpentineConnector from './SerpentineConnector'; import { cn } from '@/lib/utils'; import { Loader2 } from 'lucide-react'; -import type { ActorChainLink, MovieChainLink } from '@/types'; +import type { + ActorChainLink, + ChainLink, + GameStatus, + MovieChainLink, +} from '@/types'; interface ChainPair { actor: ActorChainLink; movie?: MovieChainLink; } -export default function ChainDisplay() { +/** + * Without props the display is live: it follows the game store, animates the + * newest row and shows a pending slot while the next link is validated. Given + * a `chain`, it is a still of that finished chain (e.g. a game review) and + * reads nothing from the store. + */ +type ChainDisplayProps = + | { chain?: undefined; movieAId?: undefined; movieBId?: undefined } + | { chain: ChainLink[]; movieAId: number; movieBId: number }; + +export default function ChainDisplay(props: ChainDisplayProps) { + if (props.chain !== undefined) { + // A chain handed in as a prop is a finished game: the loop is complete + // and nothing is being validated. + return ( + + ); + } + return ; +} + +function LiveChainDisplay() { const chain = useGameStore((s) => s.chain); const movieA = useGameStore((s) => s.movieA); const movieB = useGameStore((s) => s.movieB); const status = useGameStore((s) => s.status); const isValidating = useGameStore((s) => s.isValidating); + return ( + + ); +} + +interface ChainRowsProps { + chain: ChainLink[]; + movieAId: number | undefined; + movieBId: number | undefined; + status: GameStatus; + isValidating: boolean; + /** Store-driven: animate the newest row and show the pending movie slot. */ + live: boolean; +} + +function ChainRows({ + chain, + movieAId, + movieBId, + status, + isValidating, + live, +}: ChainRowsProps) { // chain[0] = MovieA (standalone starting point) // chain[1..2] = pair 0 (Actor, Movie) // chain[3..4] = pair 1 (Actor, Movie) @@ -55,9 +118,9 @@ export default function ChainDisplay() { // Determine movie target markers let movieTarget: 'A' | 'B' | undefined; if (pair.movie) { - if (pair.movie.id === movieB?.id) movieTarget = 'B'; + if (pair.movie.id === movieBId) movieTarget = 'B'; else if ( - pair.movie.id === movieA?.id && + pair.movie.id === movieAId && isLastPair && status === 'completed' ) @@ -94,9 +157,11 @@ export default function ChainDisplay() { movieTarget === 'B' && 'border-green-500 bg-green-500/5', movieTarget === 'A' && 'border-primary bg-primary/5', !movieTarget && 'border-border bg-card', - isLastPair && !isComplete && 'animate-slide-in', - isLastPair && isComplete && status !== 'completed' && 'animate-slide-in', - isLastPair && status === 'completed' && 'animate-glow-pulse', + live && [ + isLastPair && !isComplete && 'animate-slide-in', + isLastPair && isComplete && status !== 'completed' && 'animate-slide-in', + isLastPair && status === 'completed' && 'animate-glow-pulse', + ], )} > {isOdd ? ( @@ -105,7 +170,7 @@ export default function ChainDisplay() {
- +
{pair.movie ? (
@@ -113,7 +178,7 @@ export default function ChainDisplay() { {movieTarget && }
) : ( - + live && )}
@@ -127,10 +192,10 @@ export default function ChainDisplay() { ) : ( - + live && )} - +
diff --git a/src/components/game/GameCompletionModal.tsx b/src/components/game/GameCompletionModal.tsx index f0213b2..22552c9 100644 --- a/src/components/game/GameCompletionModal.tsx +++ b/src/components/game/GameCompletionModal.tsx @@ -14,15 +14,10 @@ import type { GameMode } from './ShareableResult'; import CelebrationOverlay from './CelebrationOverlay'; import { playSound } from '@/lib/sounds'; import { storage } from '@/lib/storage'; +import { formatTime } from '@/lib/format'; import { useNavigate } from 'react-router'; import { Loader2 } from 'lucide-react'; -function formatElapsed(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - export default function GameCompletionModal() { const status = useGameStore((s) => s.status); const score = useGameStore((s) => s.score); @@ -111,7 +106,7 @@ export default function GameCompletionModal() { <>
- Completed in {formatElapsed(score.elapsedSeconds)} + Completed in {formatTime(score.elapsedSeconds)} {hintsUsed > 0 && ( {hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used )} diff --git a/src/components/game/GameSearchPanel.tsx b/src/components/game/GameSearchPanel.tsx new file mode 100644 index 0000000..4f7db2b --- /dev/null +++ b/src/components/game/GameSearchPanel.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react'; +import SearchAutocomplete from './SearchAutocomplete'; +import ValidationFeedback from './ValidationFeedback'; +import HintButton from './HintButton'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Undo2 } from 'lucide-react'; +import type { ReadyGameScreen } from '@/hooks/use-game-screen'; + +interface GameSearchPanelProps { + screen: ReadyGameScreen; + /** Further actions beside Undo, e.g. a forfeit button. */ + children?: ReactNode; +} + +/** + * The "build the next link" panel shared by every game screen: the contextual + * prompt, hint and undo controls, the search box, validation feedback and the + * close-the-loop nudge. Callers decide when it shows (playing, not spectating). + */ +export default function GameSearchPanel({ screen, children }: GameSearchPanelProps) { + const { + chain, + currentSearchMode, + isValidating, + undoLastLink, + validateAndAddActor, + validateAndAddMovie, + searchLabel, + searchPlaceholder, + showLoopHint, + movieA, + } = screen; + + return ( +
+ + +
+
+ +
+ + + {children} +
+
+ + + + + + {showLoopHint && ( +

+ Select "{movieA.title}" to close the loop! +

+ )} +
+
+ ); +} diff --git a/src/components/game/SearchAutocomplete.tsx b/src/components/game/SearchAutocomplete.tsx index 26e07f8..76c64a2 100644 --- a/src/components/game/SearchAutocomplete.tsx +++ b/src/components/game/SearchAutocomplete.tsx @@ -162,12 +162,12 @@ export default function SearchAutocomplete({ break; case 'Enter': e.preventDefault(); - if (activeIndex >= 0 && activeIndex < results.length) { - if (mode === 'actor') { - handleSelectPerson(personResults[activeIndex]); - } else { - handleSelectMovie(movieResults[activeIndex]); - } + if (mode === 'actor') { + const person = personResults[activeIndex]; + if (person) handleSelectPerson(person); + } else { + const movie = movieResults[activeIndex]; + if (movie) handleSelectMovie(movie); } break; case 'Escape': diff --git a/src/components/game/StaticChainDisplay.tsx b/src/components/game/StaticChainDisplay.tsx deleted file mode 100644 index 5141ede..0000000 --- a/src/components/game/StaticChainDisplay.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import MovieCard from './MovieCard'; -import ActorCard from './ActorCard'; -import SerpentineConnector from './SerpentineConnector'; -import { cn } from '@/lib/utils'; -import type { ChainLink, ActorChainLink, MovieChainLink } from '@/types'; - -interface StaticChainDisplayProps { - chain: ChainLink[]; - movieAId: number; - movieBId: number; -} - -interface ChainPair { - actor: ActorChainLink; - movie?: MovieChainLink; -} - -export default function StaticChainDisplay({ - chain, - movieAId, - movieBId, -}: StaticChainDisplayProps) { - const pairs: ChainPair[] = []; - for (let i = 1; i < chain.length; i += 2) { - const actor = chain[i] as ActorChainLink; - const movie = chain[i + 1] as MovieChainLink | undefined; - pairs.push({ actor, movie }); - } - - const startMovie = chain[0] as MovieChainLink | undefined; - - return ( -
- {startMovie && ( - - )} - - {pairs.map((pair, pairIndex) => { - const isOdd = pairIndex % 2 === 0; - const isLastPair = pairIndex === pairs.length - 1; - - let movieTarget: 'A' | 'B' | undefined; - if (pair.movie) { - if (pair.movie.id === movieBId) movieTarget = 'B'; - else if (pair.movie.id === movieAId && isLastPair) - movieTarget = 'A'; - } - - const lineX = isOdd ? 7.5 : 92.5; - const cardEdge = isOdd ? 15 : 85; - const armLeft = Math.min(lineX, cardEdge); - const armRight = 100 - Math.max(lineX, cardEdge); - - return ( -
- - -
-
-
-
- {isOdd ? ( - <> -
- -
- -
- {pair.movie && ( -
- - {movieTarget && } -
- )} -
- - ) : ( - <> -
- {pair.movie && ( -
- {movieTarget && } - -
- )} -
- -
- -
- - )} -
-
-
- ); - })} - -
-
- Loop Complete! -
-
-
- ); -} - -function Dot() { - return ( -
- ); -} - -function TargetBadge({ target }: { target: 'A' | 'B' }) { - return ( - - {target} - - ); -} diff --git a/src/components/layout/NotificationBell.test.tsx b/src/components/layout/NotificationBell.test.tsx index 30b0d7a..76a781a 100644 --- a/src/components/layout/NotificationBell.test.tsx +++ b/src/components/layout/NotificationBell.test.tsx @@ -105,33 +105,35 @@ describe('NotificationBell', () => { const dialog = screen.getByRole('dialog'); const rows = within(dialog).getAllByRole('listitem'); expect(rows).toHaveLength(2); + const [row0, row1] = rows; + if (!row0 || !row1) throw new Error('expected two rows'); // The row action is a button, and it is not wrapping the other buttons. // The name is the row's full content (title, body, time) - what a sighted // user sees - prefixed by the visually hidden unread marker. Anchored, so // it cannot match the "Mark ... as read" / "Dismiss ..." siblings. - const rowButton = within(rows[0]).getByRole('button', { + const rowButton = within(row0).getByRole('button', { name: /^Unread:\s*Alice challenged you/, }); expect(rowButton.querySelector('button')).toBeNull(); // Per-row controls have real names, not just a tooltip. expect( - within(rows[0]).getByRole('button', { + within(row0).getByRole('button', { name: 'Mark "Alice challenged you" as read', }), ).toBeInTheDocument(); expect( - within(rows[0]).getByRole('button', { + within(row0).getByRole('button', { name: 'Dismiss "Alice challenged you"', }), ).toBeInTheDocument(); // A read notification has no mark-read control and no "Unread" prefix. expect( - within(rows[1]).queryByRole('button', { name: /Mark .* as read/ }), + within(row1).queryByRole('button', { name: /Mark .* as read/ }), ).toBeNull(); expect( - within(rows[1]).getByRole('button', { name: /^Bob accepted/ }), + within(row1).getByRole('button', { name: /^Bob accepted/ }), ).toBeInTheDocument(); }); diff --git a/src/components/profile/PushNotificationsSection.test.tsx b/src/components/profile/PushNotificationsSection.test.tsx new file mode 100644 index 0000000..2473373 --- /dev/null +++ b/src/components/profile/PushNotificationsSection.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PushSubscriptionState } from '@/hooks/use-push-subscription'; + +/** + * The Profile page's push toggle: the view over usePushSubscription. The hook + * has its own tests; these cover what each hook state looks like to a user. + */ + +const enable = vi.fn(); +const disable = vi.fn(); +let state: PushSubscriptionState; + +vi.mock('@/hooks/use-push-subscription', () => ({ + usePushSubscription: () => state, +})); + +const { default: PushNotificationsSection } = await import( + './PushNotificationsSection' +); + +beforeEach(() => { + vi.clearAllMocks(); + state = { + support: 'supported', + subscribed: false, + busy: false, + error: null, + enable, + disable, + }; +}); + +describe('PushNotificationsSection', () => { + it('offers to turn push on, and calls enable', async () => { + render(); + const button = screen.getByRole('button', { + name: 'Turn on push notifications', + }); + expect(button).toHaveAttribute('aria-pressed', 'false'); + + await userEvent.click(button); + + expect(enable).toHaveBeenCalledTimes(1); + expect(disable).not.toHaveBeenCalled(); + }); + + it('offers to turn push off when subscribed, and calls disable', async () => { + state.subscribed = true; + render(); + const button = screen.getByRole('button', { + name: 'Turn off push notifications', + }); + expect(button).toHaveAttribute('aria-pressed', 'true'); + + await userEvent.click(button); + + expect(disable).toHaveBeenCalledTimes(1); + }); + + it('is disabled while the initial check is running', () => { + state.subscribed = null; + render(); + expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled(); + }); + + it('is disabled while an action is in flight', () => { + state.busy = true; + render(); + expect( + screen.getByRole('button', { name: 'Turn on push notifications' }), + ).toBeDisabled(); + }); + + it('explains a blocked permission and disables the toggle', () => { + state.support = 'denied'; + render(); + expect( + screen.getByRole('button', { name: 'Turn on push notifications' }), + ).toBeDisabled(); + expect( + screen.getByText(/Notifications are blocked for this site/), + ).toBeInTheDocument(); + }); + + it('still lets a subscribed user turn push off after blocking it in the browser', () => { + state.support = 'denied'; + state.subscribed = true; + render(); + expect( + screen.getByRole('button', { name: 'Turn off push notifications' }), + ).toBeEnabled(); + expect(screen.queryByText(/blocked for this site/)).toBeNull(); + }); + + it('shows the hook’s error', () => { + state.error = 'Push notifications are not configured on this server.'; + render(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Push notifications are not configured on this server.', + ); + }); + + it('says so, with no toggle, when the browser cannot do push', () => { + state.support = 'unsupported'; + render(); + expect(screen.queryByRole('button')).toBeNull(); + expect( + screen.getByText('This browser does not support push notifications.'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/profile/PushNotificationsSection.tsx b/src/components/profile/PushNotificationsSection.tsx new file mode 100644 index 0000000..772381a --- /dev/null +++ b/src/components/profile/PushNotificationsSection.tsx @@ -0,0 +1,70 @@ +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { usePushSubscription } from '@/hooks/use-push-subscription'; +import { Bell, BellOff, Loader2 } from 'lucide-react'; + +/** + * The opt-in for Web Push. Lives here rather than firing at sign-in because + * the browser only shows the permission prompt in response to a click, and + * asking on the first visit is how sites train people to say no. + */ +export default function PushNotificationsSection() { + const push = usePushSubscription(); + + if (push.support === 'unsupported') { + return ( +
+

Notifications

+

+ This browser does not support push notifications. +

+
+ ); + } + + const checking = push.subscribed === null; + const blocked = push.support === 'denied'; + + return ( +
+
+

Notifications

+

+ Get a push notification when a friend challenges you or a versus + match finishes, even when the game is not open. +

+
+ + {blocked && !push.subscribed && !push.error && ( +

+ Notifications are blocked for this site. Allow them in your browser + settings to turn push on. +

+ )} + {push.error && ( + + {push.error} + + )} +
+ ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 807e1fa..276f69c 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -19,10 +19,6 @@ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { return } -function DialogClose({ ...props }: DialogPrimitive.Close.Props) { - return -} - function DialogOverlay({ className, ...props @@ -145,7 +141,6 @@ function DialogDescription({ export { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx deleted file mode 100644 index 72000e5..0000000 --- a/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" - -import { cn } from "@/lib/utils" - -function ScrollArea({ - className, - children, - ...props -}: ScrollAreaPrimitive.Root.Props) { - return ( - - - {children} - - - - - ) -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: ScrollAreaPrimitive.Scrollbar.Props) { - return ( - - - - ) -} - -export { ScrollArea, ScrollBar } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 78c7fa3..63b3962 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -15,10 +15,6 @@ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { return } -function SheetClose({ ...props }: SheetPrimitive.Close.Props) { - return -} - function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { return } @@ -90,16 +86,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { ) } -function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( - ) -} - export { Sheet, SheetTrigger, - SheetClose, SheetContent, SheetHeader, - SheetFooter, SheetTitle, - SheetDescription, } diff --git a/src/hooks/use-chain-validation.test.ts b/src/hooks/use-chain-validation.test.ts index 55a31b0..ef72dfe 100644 --- a/src/hooks/use-chain-validation.test.ts +++ b/src/hooks/use-chain-validation.test.ts @@ -204,7 +204,7 @@ describe('validateAndAddActor', () => { await act(() => h.validateAndAddActor({ ...person(7), profile_path: '/x.jpg' }), ); - expect(validateLink.mock.calls[0][1]).toMatchObject({ + expect(validateLink.mock.calls[0]?.[1]).toMatchObject({ profilePath: '/x.jpg', }); }); @@ -377,7 +377,7 @@ describe('validateAndAddMovie', () => { ); // The schema accepts null and normalises it, but the hook does not lean on // that: it sends undefined, which axios drops from the JSON body. - const body = validateLink.mock.calls[0][1] as { posterPath?: unknown }; + const body = validateLink.mock.calls[0]?.[1] as { posterPath?: unknown }; expect(body.posterPath).toBeUndefined(); }); diff --git a/src/hooks/use-chain-validation.ts b/src/hooks/use-chain-validation.ts index b0a5eda..cfae6db 100644 --- a/src/hooks/use-chain-validation.ts +++ b/src/hooks/use-chain-validation.ts @@ -5,6 +5,7 @@ import { validateLink } from '@/api/games'; import { getMovieCredits } from '@/api/movies'; import { getPersonMovieCredits } from '@/api/persons'; import { playSound } from '@/lib/sounds'; +import { getErrorMessage } from '@/lib/error'; import type { ActorChainLink, MovieChainLink, @@ -82,7 +83,7 @@ export function useChainValidation() { playSound('valid'); } catch (error: unknown) { - const message = extractErrorMessage(error, 'Failed to validate. Please try again.'); + const message = getErrorMessage(error, 'Failed to validate. Please try again.'); setValidationError(message); playSound('invalid'); } finally { @@ -163,7 +164,7 @@ export function useChainValidation() { playSound('valid'); } catch (error: unknown) { - const message = extractErrorMessage(error, 'Failed to validate. Please try again.'); + const message = getErrorMessage(error, 'Failed to validate. Please try again.'); setValidationError(message); playSound('invalid'); } finally { @@ -175,20 +176,3 @@ export function useChainValidation() { return { validateAndAddActor, validateAndAddMovie }; } - -/** Extract a human-readable error message from an Axios error or generic error. */ -function extractErrorMessage(error: unknown, fallback: string): string { - if (error && typeof error === 'object' && 'response' in error) { - const resp = (error as { response?: { data?: { message?: string | string[] } } }) - .response; - if (resp?.data?.message) { - return typeof resp.data.message === 'string' - ? resp.data.message - : resp.data.message[0]; - } - } - if (error instanceof Error && error.message === 'Network Error') { - return 'Network error \u2014 check your connection and try again.'; - } - return fallback; -} diff --git a/src/hooks/use-game-screen.ts b/src/hooks/use-game-screen.ts new file mode 100644 index 0000000..d211a6c --- /dev/null +++ b/src/hooks/use-game-screen.ts @@ -0,0 +1,90 @@ +import { useGameStore } from '@/stores/game-store'; +import { useChainValidation } from '@/hooks/use-chain-validation'; +import type { + ChainLink, + GameStatus, + MovieChainLink, + SearchMode, + TmdbMovieResult, + TmdbPersonResult, +} from '@/types'; + +/** + * Everything the four game screens (solo, versus, game night, async versus) + * derive identically from the game store: the store fields they all read, the + * validation callbacks, and the copy that depends on where the chain stands. + * + * Each screen still owns what makes it that screen - its idle redirect, its + * multiplayer broadcasts, its layout - and only reads shared state from here. + */ +interface GameScreenState { + status: GameStatus; + chain: ChainLink[]; + currentSearchMode: SearchMode; + passedMovieB: boolean; + isValidating: boolean; + undoLastLink: () => void; + validateAndAddActor: (person: TmdbPersonResult) => Promise; + validateAndAddMovie: (movie: TmdbMovieResult) => Promise; + /** Prompt for the next link, e.g. `Who was in "Speed"?`; '' when the chain is mid-transition. */ + searchLabel: string; + searchPlaceholder: string; + /** Movie B is behind the player and the next movie can close the loop. */ + showLoopHint: boolean; +} + +export type GameScreen = + /** Nothing to show: no game is in progress. Screens render null. */ + | (GameScreenState & { ready: false }) + /** A game is in progress or complete, with both target movies known. */ + | (GameScreenState & { ready: true; movieA: MovieChainLink; movieB: MovieChainLink }); + +export type ReadyGameScreen = Extract; + +export function useGameScreen(): GameScreen { + const status = useGameStore((s) => s.status); + const chain = useGameStore((s) => s.chain); + const currentSearchMode = useGameStore((s) => s.currentSearchMode); + const passedMovieB = useGameStore((s) => s.passedMovieB); + const movieA = useGameStore((s) => s.movieA); + const movieB = useGameStore((s) => s.movieB); + const isValidating = useGameStore((s) => s.isValidating); + const undoLastLink = useGameStore((s) => s.undoLastLink); + const { validateAndAddActor, validateAndAddMovie } = useChainValidation(); + + // Build contextual search label + const lastLink = chain[chain.length - 1]; + let searchLabel = ''; + let searchPlaceholder = ''; + + if (currentSearchMode === 'actor' && lastLink?.type === 'movie') { + searchLabel = `Who was in "${lastLink.title}"?`; + searchPlaceholder = 'Search for an actor...'; + } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') { + searchLabel = `What movie was ${lastLink.name} also in?`; + searchPlaceholder = 'Search for a movie...'; + } + + // Hint for closing the loop + const showLoopHint = + passedMovieB && currentSearchMode === 'movie' && movieA !== null; + + const state: GameScreenState = { + status, + chain, + currentSearchMode, + passedMovieB, + isValidating, + undoLastLink, + validateAndAddActor, + validateAndAddMovie, + searchLabel, + searchPlaceholder, + showLoopHint, + }; + + if (status === 'idle' || !movieA || !movieB) { + return { ...state, ready: false }; + } + return { ...state, ready: true, movieA, movieB }; +} diff --git a/src/hooks/use-push-subscription.test.ts b/src/hooks/use-push-subscription.test.ts new file mode 100644 index 0000000..83e54de --- /dev/null +++ b/src/hooks/use-push-subscription.test.ts @@ -0,0 +1,170 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The state behind the Profile page's push toggle: an initial "is it on?" + * check, then enable/disable that report success or a reason for failure. + */ + +const isPushSubscribed = vi.fn(); +const subscribeToPush = vi.fn(); +const unsubscribeFromPush = vi.fn(); +const pushSupport = vi.fn(); + +vi.mock('@/lib/push', async () => { + const actual = await vi.importActual('@/lib/push'); + return { + PushError: actual.PushError, + isPushSubscribed: () => isPushSubscribed(), + subscribeToPush: () => subscribeToPush(), + unsubscribeFromPush: () => unsubscribeFromPush(), + pushSupport: () => pushSupport(), + }; +}); + +const { PushError } = await import('@/lib/push'); +const { usePushSubscription } = await import('./use-push-subscription'); + +beforeEach(() => { + vi.clearAllMocks(); + pushSupport.mockReturnValue('supported'); + isPushSubscribed.mockResolvedValue(false); + subscribeToPush.mockResolvedValue(undefined); + unsubscribeFromPush.mockResolvedValue(undefined); +}); + +describe('usePushSubscription', () => { + it('starts unknown, then reports the browser’s current subscription', async () => { + isPushSubscribed.mockResolvedValue(true); + const { result } = renderHook(() => usePushSubscription()); + + expect(result.current.subscribed).toBeNull(); + await waitFor(() => expect(result.current.subscribed).toBe(true)); + expect(result.current.support).toBe('supported'); + expect(result.current.error).toBeNull(); + }); + + it('does not probe an unsupported browser and reports not subscribed', async () => { + pushSupport.mockReturnValue('unsupported'); + const { result } = renderHook(() => usePushSubscription()); + + await waitFor(() => expect(result.current.subscribed).toBe(false)); + expect(isPushSubscribed).not.toHaveBeenCalled(); + expect(result.current.support).toBe('unsupported'); + }); + + it('ignores a probe that settles after unmount', async () => { + let settle!: (value: boolean) => void; + isPushSubscribed.mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }), + ); + const { result, unmount } = renderHook(() => usePushSubscription()); + expect(result.current.subscribed).toBeNull(); + + unmount(); + // Settling now must not write to an unmounted component; React would + // warn, and the test's error handler would surface it. + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { + settle(true); + await Promise.resolve(); + }); + expect(errors).not.toHaveBeenCalled(); + }); + + it('treats a failed probe as not subscribed rather than staying unknown', async () => { + isPushSubscribed.mockRejectedValue(new Error('boom')); + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(false)); + }); + + it('enable subscribes and flips the state, clearing any earlier error', async () => { + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(false)); + + await act(() => result.current.enable()); + + expect(subscribeToPush).toHaveBeenCalledTimes(1); + expect(result.current.subscribed).toBe(true); + expect(result.current.busy).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('disable unsubscribes and flips the state back', async () => { + isPushSubscribed.mockResolvedValue(true); + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(true)); + + await act(() => result.current.disable()); + + expect(unsubscribeFromPush).toHaveBeenCalledTimes(1); + expect(result.current.subscribed).toBe(false); + }); + + it('surfaces a PushError’s own message and leaves the state unchanged', async () => { + subscribeToPush.mockRejectedValue( + new PushError('Push notifications are not configured on this server.', 'no-key'), + ); + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(false)); + + await act(() => result.current.enable()); + + expect(result.current.error).toBe( + 'Push notifications are not configured on this server.', + ); + expect(result.current.subscribed).toBe(false); + expect(result.current.support).toBe('supported'); + }); + + it('a denied permission also moves support to "denied" so the UI can explain itself', async () => { + subscribeToPush.mockRejectedValue(new PushError('Blocked.', 'denied')); + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(false)); + + await act(() => result.current.enable()); + + expect(result.current.support).toBe('denied'); + expect(result.current.error).toBe('Blocked.'); + }); + + it('maps a server error through getErrorMessage, with a push-specific fallback', async () => { + subscribeToPush.mockRejectedValue({ + response: { status: 500, data: { message: 'Internal server error' } }, + }); + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(false)); + await act(() => result.current.enable()); + expect(result.current.error).toBe('Internal server error'); + + unsubscribeFromPush.mockRejectedValue(new Error('something odd')); + await act(() => result.current.disable()); + expect(result.current.error).toBe('Could not update push notifications.'); + }); + + it('reports busy while an action is in flight', async () => { + let finish!: () => void; + subscribeToPush.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => usePushSubscription()); + await waitFor(() => expect(result.current.subscribed).toBe(false)); + + let pending: Promise; + act(() => { + pending = result.current.enable(); + }); + expect(result.current.busy).toBe(true); + + await act(async () => { + finish(); + await pending; + }); + expect(result.current.busy).toBe(false); + expect(result.current.subscribed).toBe(true); + }); +}); diff --git a/src/hooks/use-push-subscription.ts b/src/hooks/use-push-subscription.ts new file mode 100644 index 0000000..cb25175 --- /dev/null +++ b/src/hooks/use-push-subscription.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getErrorMessage } from '@/lib/error'; +import { + isPushSubscribed, + PushError, + pushSupport, + subscribeToPush, + unsubscribeFromPush, + type PushSupport, +} from '@/lib/push'; + +export interface PushSubscriptionState { + /** Whether this browser can do push at all, and whether it is blocked. */ + support: PushSupport; + /** `null` until the initial check has completed. */ + subscribed: boolean | null; + busy: boolean; + error: string | null; + enable: () => Promise; + disable: () => Promise; +} + +/** + * The state behind a push-notifications toggle. `enable` must be called from + * a user gesture: the browser will not show the permission prompt otherwise. + */ +export function usePushSubscription(): PushSubscriptionState { + const [support, setSupport] = useState(() => pushSupport()); + // An unsupported browser is known not-subscribed up front; otherwise the + // answer comes from the probe below. + const [subscribed, setSubscribed] = useState(() => + support === 'unsupported' ? false : null, + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (support === 'unsupported') return; + let cancelled = false; + isPushSubscribed() + .then((value) => { + if (!cancelled) setSubscribed(value); + }) + .catch(() => { + if (!cancelled) setSubscribed(false); + }); + return () => { + cancelled = true; + }; + }, [support]); + + const run = useCallback( + async (action: () => Promise, endState: boolean) => { + setBusy(true); + setError(null); + try { + await action(); + setSubscribed(endState); + } catch (err) { + if (err instanceof PushError) { + setError(err.message); + if (err.reason === 'denied') setSupport('denied'); + } else { + setError( + getErrorMessage(err, 'Could not update push notifications.'), + ); + } + } finally { + setBusy(false); + } + }, + [], + ); + + const enable = useCallback(() => run(subscribeToPush, true), [run]); + const disable = useCallback(() => run(unsubscribeFromPush, false), [run]); + + return { support, subscribed, busy, error, enable, disable }; +} diff --git a/src/hooks/use-timer.ts b/src/hooks/use-timer.ts index 23c00f1..97fb52f 100644 --- a/src/hooks/use-timer.ts +++ b/src/hooks/use-timer.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { formatTime } from '@/lib/format'; export type BonusTier = 'high' | 'medium' | 'low' | 'none'; @@ -9,12 +10,6 @@ function getBonusTier(seconds: number): BonusTier { return 'none'; } -function formatTime(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - function elapsedFrom(startTime: number | null): number { if (!startTime) return 0; return Math.max(0, Math.floor((Date.now() - startTime) / 1000)); diff --git a/src/lib/error.ts b/src/lib/error.ts index fe1e447..69c0e0f 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -1,14 +1,31 @@ interface AxiosLikeError { response?: { - data?: { message?: string }; + data?: { message?: string | string[] }; status?: number; }; message?: string; } +/** + * Extract a human-readable error message from an Axios error or generic + * error. A server validation message may arrive as a `string[]`, in which + * case the first entry is used. A bare `Network Error` (axios's message when + * the request never reached the server) is mapped to a friendlier sentence + * rather than surfaced verbatim. + */ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string { const e = error as AxiosLikeError; - return e?.response?.data?.message ?? e?.message ?? fallback; + const responseMessage = e?.response?.data?.message; + if (responseMessage) { + if (Array.isArray(responseMessage)) { + return responseMessage[0] ?? fallback; + } + return responseMessage; + } + if (error instanceof Error && error.message === 'Network Error') { + return 'Network error — check your connection and try again.'; + } + return fallback; } export function getErrorStatus(error: unknown): number | undefined { diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..58615bb --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,15 @@ +/** + * Shared display formatting. + * + * `formatTime` existed as five byte-identical private copies (use-timer, + * GameReview, Leaderboard, Profile, and GameCompletionModal under the name + * formatElapsed). One definition, so a change to how a duration reads happens + * once. + */ + +/** Seconds as `m:ss`, e.g. 125 -> "2:05". */ +export function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} diff --git a/src/lib/movie-pair.test.ts b/src/lib/movie-pair.test.ts new file mode 100644 index 0000000..6d6c341 --- /dev/null +++ b/src/lib/movie-pair.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { pickDistinctPair, areSameFranchise } from './movie-pair'; + +interface Movie { + id: number; + title: string; +} + +const movie = (id: number, title = `Movie ${id}`): Movie => ({ id, title }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('pickDistinctPair', () => { + it('returns null when fewer than two movies are given', () => { + expect(pickDistinctPair([])).toBeNull(); + expect(pickDistinctPair([movie(1)])).toBeNull(); + }); + + it('never returns the same movie twice', () => { + const movies = [movie(1), movie(2), movie(3), movie(4), movie(5)]; + for (let i = 0; i < 50; i++) { + const pair = pickDistinctPair(movies); + expect(pair).not.toBeNull(); + const [a, b] = pair!; + expect(a.id).not.toBe(b.id); + } + }); + + it('never pairs two movies from the same franchise, even when a naive pick would collide', () => { + // MCU ids 299536 and 299534 are tagged as the same franchise. A naive + // "shuffle and take the first two" pick has a good chance of colliding on + // a small list like this one; pickDistinctPair must not. + const movies = [ + movie(299536, 'Avengers: Endgame'), + movie(299534, 'Avengers: Infinity War'), + movie(603, 'The Matrix'), + ]; + + for (let i = 0; i < 50; i++) { + const pair = pickDistinctPair(movies); + expect(pair).not.toBeNull(); + const [a, b] = pair!; + expect(areSameFranchise(a.id, b.id)).toBe(false); + expect(a.id === 603 || b.id === 603).toBe(true); + } + }); + + it('returns null when every possible pair collides on franchise', () => { + const movies = [ + movie(299536, 'Avengers: Endgame'), + movie(299534, 'Avengers: Infinity War'), + movie(24428, 'The Avengers'), + ]; + + expect(pickDistinctPair(movies)).toBeNull(); + }); + + it('still returns a valid pair when Math.random is spied to a fixed sequence', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + + const movies = [movie(1), movie(2), movie(3), movie(4)]; + const pair = pickDistinctPair(movies); + + expect(pair).not.toBeNull(); + const [a, b] = pair!; + expect(a.id).not.toBe(b.id); + expect(movies.map((m) => m.id)).toContain(a.id); + expect(movies.map((m) => m.id)).toContain(b.id); + }); +}); diff --git a/src/lib/movie-pair.ts b/src/lib/movie-pair.ts new file mode 100644 index 0000000..8b41d87 --- /dev/null +++ b/src/lib/movie-pair.ts @@ -0,0 +1,90 @@ +/** + * Shared "pick two distinct, unrelated movies" logic. + * + * Home and Endless each reimplemented this independently and diverged: Home + * checked for same-franchise collisions but shuffled with + * `.sort(() => Math.random() - 0.5)` (neither uniform nor a valid comparator), + * while Endless's unfiltered path skipped the franchise check entirely, so it + * could hand out two films from the same series. One implementation, so both + * modes get a uniform shuffle and the same franchise guard. + */ + +/** Known franchise groups — movies in the same group won't be paired together. */ +export const FRANCHISE_TAGS: Record = { + // MCU + 299536: 'mcu', 299534: 'mcu', 24428: 'mcu', 100402: 'mcu', 284053: 'mcu', + 118340: 'mcu', 99861: 'mcu', 68721: 'mcu', 1726: 'mcu', 10195: 'mcu', + 315635: 'mcu', 284054: 'mcu', 271110: 'mcu', 283995: 'mcu', 429617: 'mcu', + // Star Wars + 11: 'sw', 1891: 'sw', 1892: 'sw', 1893: 'sw', 1894: 'sw', 1895: 'sw', + 140607: 'sw', 181808: 'sw', 181812: 'sw', 330459: 'sw', 348350: 'sw', + // LotR / Hobbit + 120: 'lotr', 121: 'lotr', 122: 'lotr', 49051: 'lotr', 57158: 'lotr', 122917: 'lotr', + // Harry Potter + 671: 'hp', 672: 'hp', 673: 'hp', 674: 'hp', 675: 'hp', 767: 'hp', 12444: 'hp', 12445: 'hp', + // Fast & Furious + 9799: 'ff', 13804: 'ff', 51497: 'ff', 82992: 'ff', 168259: 'ff', 337339: 'ff', 385128: 'ff', + // Godfather + 238: 'gf', 240: 'gf', 242: 'gf', + // Dark Knight + 155: 'dk', 49026: 'dk', 272: 'dk', + // Jurassic + 329: 'jp', 330: 'jp', 331: 'jp', 135397: 'jp', 351286: 'jp', + // Toy Story + 862: 'ts', 863: 'ts', 10193: 'ts', 301528: 'ts', + // Pirates + 22: 'potc', 58: 'potc', 285: 'potc', 1865: 'potc', 166426: 'potc', +}; + +export function areSameFranchise(idA: number, idB: number): boolean { + const tagA = FRANCHISE_TAGS[idA]; + const tagB = FRANCHISE_TAGS[idB]; + return !!(tagA && tagB && tagA === tagB); +} + +/** Fisher-Yates shuffle — uniform, unlike `.sort(() => Math.random() - 0.5)`. */ +function shuffle(items: T[]): T[] { + const shuffled = [...items]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const a = shuffled[i]; + const b = shuffled[j]; + if (a === undefined || b === undefined) continue; // unreachable: i, j are always valid indices + shuffled[i] = b; + shuffled[j] = a; + } + return shuffled; +} + +/** + * Pick two distinct movies that are not from the same franchise. + * + * Shuffles uniformly, then scans shuffled pairs in order for the first that + * clears the franchise check — so any valid pairing present in `movies` will + * be found, not just one relative to a fixed first pick. Returns `null` when + * fewer than two movies are given, or when every possible pair collides on + * franchise (e.g. the list is entirely one series). + */ +export function pickDistinctPair( + movies: T[], +): [T, T] | null { + if (movies.length < 2) return null; + + const shuffled = shuffle(movies); + + for (let i = 0; i < shuffled.length; i++) { + const a = shuffled[i]; + if (!a) continue; // unreachable: i is always a valid index + + for (let j = i + 1; j < shuffled.length; j++) { + const b = shuffled[j]; + if (!b) continue; // unreachable: j is always a valid index + + if (!areSameFranchise(a.id, b.id)) { + return [a, b]; + } + } + } + + return null; +} diff --git a/src/lib/movie-pairs.ts b/src/lib/movie-pairs.ts index 88d8970..ea08b4c 100644 --- a/src/lib/movie-pairs.ts +++ b/src/lib/movie-pairs.ts @@ -174,7 +174,7 @@ const PRESET_PAIRS: TaggedPair[] = [ decade: '2000s', }, { - movieA: { id: 274870, title: 'Whiplash' }, + movieA: { id: 244786, title: 'Whiplash' }, movieB: { id: 313369, title: 'La La Land' }, description: 'Damien Chazelle music', genres: ['Drama'], @@ -226,7 +226,9 @@ const PRESET_PAIRS: TaggedPair[] = [ export function getRandomPair(): PresetMoviePair { const index = Math.floor(Math.random() * PRESET_PAIRS.length); - return PRESET_PAIRS[index]; + const pair = PRESET_PAIRS[index]; + if (!pair) throw new Error('PRESET_PAIRS is empty'); + return pair; } export function getFilteredPair( @@ -247,7 +249,9 @@ export function getFilteredPair( } const index = Math.floor(Math.random() * filtered.length); - return filtered[index]; + const pair = filtered[index]; + if (!pair) throw new Error('no filtered pairs available'); + return pair; } export { PRESET_PAIRS }; diff --git a/src/lib/push.test.ts b/src/lib/push.test.ts new file mode 100644 index 0000000..05da211 --- /dev/null +++ b/src/lib/push.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Browser-side Web Push. The important behaviours are the ones the old + * implementation got wrong: it awaited `serviceWorker.ready`, which never + * settles with no worker registered, so "is push on?" could hang forever; and + * it swallowed every failure into `false`, so the UI could not tell a blocked + * permission from a server outage. + * + * Requests are intercepted at the axios client, as the other tests do. + */ + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiDelete = vi.fn(); + +vi.mock('@/api/client', () => ({ + default: { + get: (...a: unknown[]) => apiGet(...a), + post: (...a: unknown[]) => apiPost(...a), + delete: (...a: unknown[]) => apiDelete(...a), + }, +})); + +const push = await import('./push'); + +// A minimal PushManager / registration double. `subscription` is what +// getSubscription() resolves to; `subscribe` and `unsubscribe` are spies. +function fakeRegistration(subscription: FakeSubscription | null) { + const subscribe = vi.fn<(...args: unknown[]) => Promise>(); + return { + registration: { + pushManager: { + getSubscription: vi.fn().mockResolvedValue(subscription), + subscribe, + }, + } as unknown as ServiceWorkerRegistration, + subscribe, + }; +} + +interface FakeSubscription { + endpoint: string; + toJSON: () => unknown; + unsubscribe: ReturnType Promise>>; +} + +function fakeSubscription(endpoint = 'https://push.example/abc'): FakeSubscription { + const unsubscribe = vi.fn<() => Promise>(); + unsubscribe.mockResolvedValue(true); + return { + endpoint, + toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }), + unsubscribe, + }; +} + +type AsyncMock = ReturnType Promise>>; + +let getRegistration: AsyncMock; +let register: AsyncMock; +let requestPermission: AsyncMock; +let permission: NotificationPermission; + +beforeEach(() => { + vi.clearAllMocks(); + getRegistration = vi.fn<(...args: unknown[]) => Promise>(); + getRegistration.mockResolvedValue(undefined); + register = vi.fn<(...args: unknown[]) => Promise>(); + requestPermission = vi.fn<(...args: unknown[]) => Promise>(); + requestPermission.mockResolvedValue('granted'); + permission = 'default'; + + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration, register }, + }); + vi.stubGlobal('PushManager', function PushManager() {}); + vi.stubGlobal('Notification', { + get permission() { + return permission; + }, + requestPermission: (...a: unknown[]) => requestPermission(...a), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(navigator, 'serviceWorker'); +}); + +describe('pushSupport', () => { + it('is supported when the three APIs exist and permission is not denied', () => { + expect(push.pushSupport()).toBe('supported'); + }); + + it('reports denied when the user has blocked notifications', () => { + permission = 'denied'; + expect(push.pushSupport()).toBe('denied'); + }); + + it('is unsupported without a service worker', () => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + expect(push.pushSupport()).toBe('unsupported'); + }); + + it('is unsupported without PushManager', () => { + vi.stubGlobal('PushManager', undefined); + // stubGlobal(undefined) still defines the key; the check is `in window`, + // so remove it outright. + Reflect.deleteProperty(window, 'PushManager'); + expect(push.pushSupport()).toBe('unsupported'); + }); +}); + +describe('isPushSubscribed', () => { + it('resolves false, rather than hanging, when no worker is registered', async () => { + await expect(push.isPushSubscribed()).resolves.toBe(false); + }); + + it('reflects the registration’s current subscription', async () => { + getRegistration.mockResolvedValue( + fakeRegistration(fakeSubscription()).registration, + ); + await expect(push.isPushSubscribed()).resolves.toBe(true); + + getRegistration.mockResolvedValue(fakeRegistration(null).registration); + await expect(push.isPushSubscribed()).resolves.toBe(false); + }); + + it('is false without a service worker API at all', async () => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + await expect(push.isPushSubscribed()).resolves.toBe(false); + }); +}); + +describe('subscribeToPush', () => { + it('registers a worker if none exists, subscribes with the server key, and posts the subscription', async () => { + const sub = fakeSubscription(); + const { registration, subscribe } = fakeRegistration(null); + subscribe.mockResolvedValue(sub); + register.mockResolvedValue(registration); + apiGet.mockResolvedValue({ data: { key: 'BAbc_-12' } }); + apiPost.mockResolvedValue({ data: { id: 's1' } }); + + await push.subscribeToPush(); + + expect(register).toHaveBeenCalledWith('/sw.js'); + expect(apiGet).toHaveBeenCalledWith('/notifications/push/vapid-key'); + expect(requestPermission).toHaveBeenCalledTimes(1); + const options = subscribe.mock.calls[0]?.[0] as PushSubscriptionOptionsInit; + expect(options.userVisibleOnly).toBe(true); + // base64url decoded: 'BAbc_-12' -> 6 bytes + expect(new Uint8Array(options.applicationServerKey as ArrayBuffer)).toHaveLength(6); + expect(apiPost).toHaveBeenCalledWith( + '/notifications/push/subscribe', + sub.toJSON(), + ); + }); + + it('reuses an existing registration instead of registering again', async () => { + const { registration, subscribe } = fakeRegistration(null); + subscribe.mockResolvedValue(fakeSubscription()); + getRegistration.mockResolvedValue(registration); + apiGet.mockResolvedValue({ data: { key: 'BAbc' } }); + apiPost.mockResolvedValue({ data: {} }); + + await push.subscribeToPush(); + + expect(register).not.toHaveBeenCalled(); + }); + + it('throws "unsupported" without touching the network when the browser cannot do push', async () => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + await expect(push.subscribeToPush()).rejects.toMatchObject({ + name: 'PushError', + reason: 'unsupported', + }); + expect(apiGet).not.toHaveBeenCalled(); + }); + + it('throws "no-key" when the server has no VAPID key configured', async () => { + getRegistration.mockResolvedValue(fakeRegistration(null).registration); + apiGet.mockResolvedValue({ data: { key: null } }); + + await expect(push.subscribeToPush()).rejects.toMatchObject({ + reason: 'no-key', + }); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it('throws "denied" when the user refuses the permission prompt, and does not subscribe', async () => { + const { registration, subscribe } = fakeRegistration(null); + getRegistration.mockResolvedValue(registration); + apiGet.mockResolvedValue({ data: { key: 'BAbc' } }); + requestPermission.mockResolvedValue('denied'); + + await expect(push.subscribeToPush()).rejects.toMatchObject({ + reason: 'denied', + }); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it('throws "failed" when the browser rejects the subscription', async () => { + const { registration, subscribe } = fakeRegistration(null); + subscribe.mockRejectedValue(new Error('AbortError')); + getRegistration.mockResolvedValue(registration); + apiGet.mockResolvedValue({ data: { key: 'BAbc' } }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(push.subscribeToPush()).rejects.toMatchObject({ + reason: 'failed', + }); + expect(apiPost).not.toHaveBeenCalled(); + }); + + it('rolls back the browser subscription when the server rejects it, and rethrows the server error', async () => { + const sub = fakeSubscription(); + const { registration, subscribe } = fakeRegistration(null); + subscribe.mockResolvedValue(sub); + getRegistration.mockResolvedValue(registration); + apiGet.mockResolvedValue({ data: { key: 'BAbc' } }); + const serverError = new Error('Request failed with status code 500'); + apiPost.mockRejectedValue(serverError); + + await expect(push.subscribeToPush()).rejects.toBe(serverError); + expect(sub.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('still reports the server error when the rollback itself fails', async () => { + const sub = fakeSubscription(); + sub.unsubscribe.mockRejectedValue(new Error('gone')); + const { registration, subscribe } = fakeRegistration(null); + subscribe.mockResolvedValue(sub); + getRegistration.mockResolvedValue(registration); + apiGet.mockResolvedValue({ data: { key: 'BAbc' } }); + const serverError = new Error('Request failed with status code 503'); + apiPost.mockRejectedValue(serverError); + + await expect(push.subscribeToPush()).rejects.toBe(serverError); + }); + + it('throws "failed" when the worker cannot be registered', async () => { + register.mockRejectedValue(new Error('SecurityError')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(push.subscribeToPush()).rejects.toMatchObject({ + reason: 'failed', + }); + }); +}); + +describe('unsubscribeFromPush', () => { + it('tells the server first, then drops the browser subscription', async () => { + const sub = fakeSubscription('https://push.example/xyz'); + getRegistration.mockResolvedValue(fakeRegistration(sub).registration); + apiDelete.mockResolvedValue({ data: { success: true } }); + + await push.unsubscribeFromPush(); + + expect(apiDelete).toHaveBeenCalledWith('/notifications/push/unsubscribe', { + data: { endpoint: 'https://push.example/xyz' }, + }); + expect(sub.unsubscribe).toHaveBeenCalledTimes(1); + expect(apiDelete.mock.invocationCallOrder[0]).toBeLessThan( + sub.unsubscribe.mock.invocationCallOrder[0] ?? Infinity, + ); + }); + + it('keeps the browser subscription if the server call fails', async () => { + const sub = fakeSubscription(); + getRegistration.mockResolvedValue(fakeRegistration(sub).registration); + apiDelete.mockRejectedValue(new Error('offline')); + + await expect(push.unsubscribeFromPush()).rejects.toThrow('offline'); + expect(sub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('is a no-op with no registration or no subscription', async () => { + await push.unsubscribeFromPush(); + getRegistration.mockResolvedValue(fakeRegistration(null).registration); + await push.unsubscribeFromPush(); + expect(apiDelete).not.toHaveBeenCalled(); + }); +}); + +describe('registerServiceWorker', () => { + it('returns the registration', async () => { + const { registration } = fakeRegistration(null); + register.mockResolvedValue(registration); + await expect(push.registerServiceWorker()).resolves.toBe(registration); + }); + + it('returns null and logs when registration fails', async () => { + register.mockRejectedValue(new Error('nope')); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await expect(push.registerServiceWorker()).resolves.toBeNull(); + expect(error).toHaveBeenCalled(); + }); + + it('returns null without a service worker API', async () => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + await expect(push.registerServiceWorker()).resolves.toBeNull(); + }); +}); diff --git a/src/lib/push.ts b/src/lib/push.ts index da43451..0cfb15e 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -1,5 +1,32 @@ import * as pushApi from '@/api/push'; +/** + * Web Push, browser side. + * + * The service worker is registered at sign-in (auth-store) so it is in place + * whenever the user reaches a screen that can enable push. Subscribing is a + * separate, explicit step: the browser only shows the permission prompt in + * response to a user gesture, so it is driven from a button in Profile. + * + * Nothing here awaits `navigator.serviceWorker.ready`: that promise never + * settles when no worker is registered, which turned "is push on?" into a + * hang on any page load where registration had not happened yet. + */ + +export type PushSupport = 'supported' | 'unsupported' | 'denied'; + +export function pushSupport(): PushSupport { + if ( + typeof window === 'undefined' || + !('serviceWorker' in navigator) || + !('PushManager' in window) || + !('Notification' in window) + ) { + return 'unsupported'; + } + return Notification.permission === 'denied' ? 'denied' : 'supported'; +} + export async function registerServiceWorker(): Promise { if (!('serviceWorker' in navigator)) return null; @@ -12,43 +39,97 @@ export async function registerServiceWorker(): Promise { - const registration = await registerServiceWorker(); - if (!registration) return false; +async function currentRegistration(): Promise { + if (!('serviceWorker' in navigator)) return null; + return (await navigator.serviceWorker.getRegistration()) ?? null; +} + +export type PushErrorReason = 'unsupported' | 'denied' | 'no-key' | 'failed'; + +export class PushError extends Error { + readonly reason: PushErrorReason; + + constructor(message: string, reason: PushErrorReason) { + super(message); + this.name = 'PushError'; + this.reason = reason; + } +} + +/** + * Asks for permission if needed, subscribes this browser, and registers the + * subscription with the server. Throws a `PushError` naming why it could not, + * so the caller can tell "you blocked notifications for this site" from "the + * server is down". + */ +export async function subscribeToPush(): Promise { + if (pushSupport() === 'unsupported') { + throw new PushError( + 'This browser does not support push notifications.', + 'unsupported', + ); + } + + const registration = + (await currentRegistration()) ?? (await registerServiceWorker()); + if (!registration) { + throw new PushError('Could not register the service worker.', 'failed'); + } const vapidKey = await pushApi.getVapidKey(); - if (!vapidKey) return false; + if (!vapidKey) { + throw new PushError( + 'Push notifications are not configured on this server.', + 'no-key', + ); + } + // `subscribe` prompts for permission itself, but asking first gives a + // clean "denied" instead of a generic failure when the user says no. + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + throw new PushError( + 'Notifications are blocked for this site. Allow them in your browser settings to turn push on.', + 'denied', + ); + } + + let subscription: PushSubscription; try { - const subscription = await registration.pushManager.subscribe({ + subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey).buffer as ArrayBuffer, }); - - await pushApi.subscribeToPush(subscription); - return true; } catch (err) { console.error('Push subscription failed:', err); - return false; + throw new PushError('The browser refused the push subscription.', 'failed'); + } + + try { + await pushApi.subscribeToPush(subscription); + } catch (err) { + // Do not leave a browser subscription the server knows nothing about. + await subscription.unsubscribe().catch(() => undefined); + throw err; } } +/** Removes this browser's subscription on the server and locally. */ export async function unsubscribeFromPush(): Promise { - const registration = await navigator.serviceWorker?.ready; + const registration = await currentRegistration(); if (!registration) return; const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - await pushApi.unsubscribeFromPush(subscription.endpoint); - await subscription.unsubscribe(); - } + if (!subscription) return; + + await pushApi.unsubscribeFromPush(subscription.endpoint); + await subscription.unsubscribe(); } export async function isPushSubscribed(): Promise { - if (!('serviceWorker' in navigator)) return false; - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - return !!subscription; + const registration = await currentRegistration(); + if (!registration) return false; + return (await registration.pushManager.getSubscription()) !== null; } function urlBase64ToUint8Array(base64String: string): Uint8Array { diff --git a/src/pages/AddFriend.tsx b/src/pages/AddFriend.tsx index d86f626..506d860 100644 --- a/src/pages/AddFriend.tsx +++ b/src/pages/AddFriend.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; import { useAuthStore } from '@/stores/auth-store'; import { useFriendsStore } from '@/stores/friends-store'; +import { getErrorMessage } from '@/lib/error'; import { toast } from 'sonner'; import { ArrowLeft, Search, UserPlus, Clock, Check, Loader2 } from 'lucide-react'; @@ -44,8 +45,7 @@ export default function AddFriend() { // Re-run search to update statuses searchUsers(query); } catch (err: unknown) { - const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to send request'; - toast.error(message); + toast.error(getErrorMessage(err, 'Failed to send request')); } finally { setSendingTo(null); } @@ -92,7 +92,7 @@ export default function AddFriend() {
- {(result.displayName || result.username)[0].toUpperCase()} + {(result.displayName || result.username).charAt(0).toUpperCase()}

{result.displayName || result.username}

diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 1a8aa52..0328191 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router'; import PageLayout from '@/components/layout/PageLayout'; import { Button } from '@/components/ui/button'; @@ -12,13 +12,16 @@ import { getPlatformStats, listUsers, recalculateScores, + backfillGameOwners, generateChallenge, generateAllChallenges, retroactiveAchievements, backfillUsernames, + type BatchPage, type PlatformStats, type AdminUser, type RecalculateResult, + type BackfillGameOwnersResult, type RetroactiveAchievementsResult, type BackfillUsernamesResult, } from '@/api/admin'; @@ -30,6 +33,8 @@ import { CalendarPlus, Trophy, Loader2, + UserCheck, + type LucideIcon, } from 'lucide-react'; export default function Admin() { @@ -65,6 +70,8 @@ export default function Admin() { + + @@ -131,20 +138,46 @@ function StatBox({ label, value }: { label: string; value: string | number }) { ); } -function RecalculateSection() { +/** + * One maintenance job: a button, a result, an error. The paged jobs return + * `hasMore`, and the button then continues from `nextCursor` instead of + * starting over - before WP-17 every run re-processed the same first page. + */ +function pageOf(result: object): Partial { + return 'hasMore' in result ? (result as BatchPage) : {}; +} + +function BatchJobSection({ + icon: Icon, + title, + description, + action, + run, + failureMessage, + renderResult, +}: { + icon: LucideIcon; + title: string; + description: string; + action: string; + run: (cursor?: string) => Promise; + failureMessage: string; + renderResult: (result: T) => ReactNode; +}) { const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [error, setError] = useState(''); - const handleRecalculate = async () => { + const page = result ? pageOf(result) : {}; + const cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined; + + const handleRun = async () => { setLoading(true); setError(''); - setResult(null); try { - const res = await recalculateScores(); - setResult(res); + setResult(await run(cursor)); } catch { - setError('Failed to recalculate scores.'); + setError(failureMessage); } finally { setLoading(false); } @@ -153,29 +186,24 @@ function RecalculateSection() { return (

- - Recalculate Scores +

-

- Recalculates scores for all completed games using the current scoring - formula. -

- {result && ( - Updated {result.updated} games and {result.leaderboardUpdated}{' '} - leaderboard entries, skipped {result.skipped} ({result.total}{' '} - total). - {result.backfilled > 0 && - ` Backfilled userId on ${result.backfilled} legacy games.`} + {renderResult(result)} + {page.hasMore && + ' More remains - press Continue to process the next page.'} )} @@ -188,112 +216,67 @@ function RecalculateSection() { ); } +function RecalculateSection() { + return ( + + icon={RefreshCw} + title="Recalculate Scores" + description="Recalculates stored scores for completed games using the current scoring formula, 500 games at a time." + action="Recalculate Scores" + run={recalculateScores} + failureMessage="Failed to recalculate scores." + renderResult={(r) => + `Updated ${r.updated} games, skipped ${r.skipped} (${r.total} in this page).` + } + /> + ); +} + +function BackfillGameOwnersSection() { + return ( + + icon={UserCheck} + title="Backfill Game Owners" + description="Re-attaches legacy games that lost their player by matching them to a leaderboard entry with the same day, time and hint count. A game that matches more than one player is left alone." + action="Backfill Game Owners" + run={backfillGameOwners} + failureMessage="Failed to backfill game owners." + renderResult={(r) => + `Re-attached ${r.backfilled} games; ${r.ambiguous} ambiguous, ${r.unmatched} unmatched (${r.total} in this page).` + } + /> + ); +} + function AchievementsSection() { - const [loading, setLoading] = useState(false); - const [result, setResult] = useState( - null, - ); - const [error, setError] = useState(''); - - const handleRetroactive = async () => { - setLoading(true); - setError(''); - setResult(null); - try { - const res = await retroactiveAchievements(); - setResult(res); - } catch { - setError('Failed to process retroactive achievements.'); - } finally { - setLoading(false); - } - }; - return ( -
-

- - Retroactive Achievements -

-

- Evaluate all existing games and award any achievements that should have - been earned. -

- - {result && ( - - - Awarded {result.totalAwarded} achievements across{' '} - {result.usersAffected} users. - - - )} - {error && ( - - {error} - - )} -
+ + icon={Trophy} + title="Retroactive Achievements" + description="Evaluates existing games, streaks and versus wins and awards any achievements that should have been earned." + action="Award Retroactive Achievements" + run={retroactiveAchievements} + failureMessage="Failed to process retroactive achievements." + renderResult={(r) => + `Awarded ${r.totalAwarded} achievements across ${r.usersAffected} users.` + } + /> ); } function BackfillUsernamesSection() { - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [error, setError] = useState(''); - - const handleBackfill = async () => { - setLoading(true); - setError(''); - setResult(null); - try { - const res = await backfillUsernames(); - setResult(res); - } catch { - setError('Failed to backfill usernames.'); - } finally { - setLoading(false); - } - }; - return ( -
-

- - Backfill Normalized Usernames -

-

- Populates the normalized username column for existing users so they can - be found via friend search. -

- - {result && ( - - - Backfilled {result.backfilled} user{result.backfilled !== 1 ? 's' : ''}. - - - )} - {error && ( - - {error} - - )} -
+ + icon={Users} + title="Backfill Normalized Usernames" + description="Populates the normalized username column for existing users so they can be found via friend search." + action="Backfill Usernames" + run={() => backfillUsernames()} + failureMessage="Failed to backfill usernames." + renderResult={(r) => + `Backfilled ${r.backfilled} user${r.backfilled !== 1 ? 's' : ''}.` + } + /> ); } diff --git a/src/pages/AsyncMatchLeaderboard.tsx b/src/pages/AsyncMatchLeaderboard.tsx index 8ebf4dc..01601ec 100644 --- a/src/pages/AsyncMatchLeaderboard.tsx +++ b/src/pages/AsyncMatchLeaderboard.tsx @@ -14,6 +14,7 @@ import { Trophy, Crown, Clock, ArrowLeft } from 'lucide-react'; import { cn } from '@/lib/utils'; import { rawToLinkCount } from '@/types'; import { releaseYear } from '@/lib/tmdb'; +import { getErrorMessage } from '@/lib/error'; function timeRemaining(expiresAt: string | null) { if (!expiresAt) return null; @@ -42,9 +43,7 @@ export default function AsyncMatchLeaderboard() { const res = await getAsyncLeaderboard(matchId, controller.signal); if (!cancelled) setData(res); } catch (err: unknown) { - const message = (err as { response?: { data?: { message?: string } } }) - ?.response?.data?.message; - if (!cancelled) setError(message ?? 'Failed to load'); + if (!cancelled) setError(getErrorMessage(err, 'Failed to load')); } finally { if (!cancelled) setLoadedMatchId(matchId); } diff --git a/src/pages/AsyncVersusGame.tsx b/src/pages/AsyncVersusGame.tsx index 099593d..04f097d 100644 --- a/src/pages/AsyncVersusGame.tsx +++ b/src/pages/AsyncVersusGame.tsx @@ -2,18 +2,16 @@ import { useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useGameStore } from '@/stores/game-store'; import { useAuthStore } from '@/stores/auth-store'; -import { useChainValidation } from '@/hooks/use-chain-validation'; +import { useGameScreen } from '@/hooks/use-game-screen'; import PageLayout from '@/components/layout/PageLayout'; import GameHeader from '@/components/game/GameHeader'; import ChainDisplay from '@/components/game/ChainDisplay'; -import SearchAutocomplete from '@/components/game/SearchAutocomplete'; -import ValidationFeedback from '@/components/game/ValidationFeedback'; -import HintButton from '@/components/game/HintButton'; +import GameSearchPanel from '@/components/game/GameSearchPanel'; import AsyncCompletionModal from '@/components/versus/AsyncCompletionModal'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; -import { Undo2, Target, Trophy } from 'lucide-react'; +import { Target, Trophy } from 'lucide-react'; import { getMatch, submitCreatorScore, @@ -36,19 +34,12 @@ export default function AsyncVersusGame() { const userId = useAuthStore((s) => s.user?.id); const authLoading = useAuthStore((s) => s.isLoading); - const status = useGameStore((s) => s.status); - const chain = useGameStore((s) => s.chain); - const currentSearchMode = useGameStore((s) => s.currentSearchMode); - const passedMovieB = useGameStore((s) => s.passedMovieB); - const movieA = useGameStore((s) => s.movieA); - const movieB = useGameStore((s) => s.movieB); - const isValidating = useGameStore((s) => s.isValidating); + const screen = useGameScreen(); + const { status, chain } = screen; const score = useGameStore((s) => s.score); const sessionId = useGameStore((s) => s.sessionId); - const undoLastLink = useGameStore((s) => s.undoLastLink); const startGameFromVersus = useGameStore((s) => s.startGameFromVersus); const resetGame = useGameStore((s) => s.resetGame); - const { validateAndAddActor, validateAndAddMovie } = useChainValidation(); const [loading, setLoading] = useState(true); const [mode, setMode] = useState('creator'); @@ -217,24 +208,10 @@ export default function AsyncVersusGame() { ); } - if (status === 'idle' || !movieA || !movieB) { + if (!screen.ready) { return null; } - const lastLink = chain[chain.length - 1]; - let searchLabel = ''; - let searchPlaceholder = ''; - - if (currentSearchMode === 'actor' && lastLink?.type === 'movie') { - searchLabel = `Who was in "${lastLink.title}"?`; - searchPlaceholder = 'Search for an actor...'; - } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') { - searchLabel = `What movie was ${lastLink.name} also in?`; - searchPlaceholder = 'Search for a movie...'; - } - - const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA; - return (
@@ -265,45 +242,7 @@ export default function AsyncVersusGame() { - {status === 'playing' && ( -
- - -
-
- -
- - -
-
- - - - - - {showLoopHint && ( -

- Select "{movieA.title}" to close the loop! -

- )} -
-
- )} + {status === 'playing' && }
- {(friend?.displayName || friend?.username || '?')[0].toUpperCase()} + {(friend?.displayName || friend?.username || '?').charAt(0).toUpperCase()}

{friend?.displayName || friend?.username || 'Unknown'}

diff --git a/src/pages/DailyChallenge.tsx b/src/pages/DailyChallenge.tsx index 3b00c46..f66a052 100644 --- a/src/pages/DailyChallenge.tsx +++ b/src/pages/DailyChallenge.tsx @@ -96,6 +96,8 @@ export default function DailyChallenge() { }); }; + const firstChallenge = challenges[0]; + return (
@@ -104,9 +106,9 @@ export default function DailyChallenge() {

Daily Challenges

- {challenges.length > 0 && ( + {firstChallenge && (

- {formatDate(challenges[0].date)} + {formatDate(firstChallenge.date)}

)}
diff --git a/src/pages/Endless.tsx b/src/pages/Endless.tsx index 4467b52..c6ae392 100644 --- a/src/pages/Endless.tsx +++ b/src/pages/Endless.tsx @@ -6,6 +6,7 @@ import PageLayout from '@/components/layout/PageLayout'; import { useGameStore } from '@/stores/game-store'; import { getFilteredPair, GENRES, DECADES } from '@/lib/movie-pairs'; import { discoverMovies } from '@/api/movies'; +import { pickDistinctPair } from '@/lib/movie-pair'; import { Loader2, Shuffle, Filter } from 'lucide-react'; export default function Endless() { @@ -25,11 +26,12 @@ export default function Endless() { const page = Math.floor(Math.random() * 20) + 1; const data = await discoverMovies(page); const movies = data.results.filter((m) => m.title && m.release_date); - if (movies.length < 2) throw new Error('Not enough movies'); - const shuffled = movies.sort(() => Math.random() - 0.5); + const distinctPair = pickDistinctPair(movies); + if (!distinctPair) throw new Error('Not enough movies'); + const [movieA, movieB] = distinctPair; pair = { - movieA: { id: shuffled[0].id, title: shuffled[0].title }, - movieB: { id: shuffled[1].id, title: shuffled[1].title }, + movieA: { id: movieA.id, title: movieA.title }, + movieB: { id: movieB.id, title: movieB.title }, }; } await startGame(pair); diff --git a/src/pages/Friends.tsx b/src/pages/Friends.tsx index eb64ab1..498eeb9 100644 --- a/src/pages/Friends.tsx +++ b/src/pages/Friends.tsx @@ -15,6 +15,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { useFriendsStore } from '@/stores/friends-store'; import { useNotificationStore } from '@/stores/notification-store'; import { createFriendChallenge } from '@/api/friends'; +import { getErrorMessage } from '@/lib/error'; import { toast } from 'sonner'; import { Users, UserPlus, Swords, MessageCircle, X, Check, Loader2 } from 'lucide-react'; @@ -103,8 +104,7 @@ export default function Friends() { navigate(`/versus/lobby/${match.id}`); } } catch (err: unknown) { - const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to send challenge'; - toast.error(message); + toast.error(getErrorMessage(err, 'Failed to send challenge')); } finally { setSending(false); } @@ -175,7 +175,7 @@ export default function Friends() {
- {(friend.displayName || friend.username)[0].toUpperCase()} + {(friend.displayName || friend.username).charAt(0).toUpperCase()}
{onlineFriendIds.has(friend.id) && ( <> @@ -240,7 +240,7 @@ export default function Friends() {
- {(from.displayName || from.username)[0].toUpperCase()} + {(from.displayName || from.username).charAt(0).toUpperCase()}

{from.displayName || from.username}

diff --git a/src/pages/Game.tsx b/src/pages/Game.tsx index ca97500..80abb9e 100644 --- a/src/pages/Game.tsx +++ b/src/pages/Game.tsx @@ -1,29 +1,17 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; -import { useGameStore } from '@/stores/game-store'; -import { useChainValidation } from '@/hooks/use-chain-validation'; +import { useGameScreen } from '@/hooks/use-game-screen'; import PageLayout from '@/components/layout/PageLayout'; import GameHeader from '@/components/game/GameHeader'; import ChainDisplay from '@/components/game/ChainDisplay'; -import SearchAutocomplete from '@/components/game/SearchAutocomplete'; -import ValidationFeedback from '@/components/game/ValidationFeedback'; +import GameSearchPanel from '@/components/game/GameSearchPanel'; import GameCompletionModal from '@/components/game/GameCompletionModal'; -import HintButton from '@/components/game/HintButton'; -import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { Undo2 } from 'lucide-react'; export default function Game() { - const status = useGameStore((s) => s.status); - const chain = useGameStore((s) => s.chain); - const currentSearchMode = useGameStore((s) => s.currentSearchMode); - const passedMovieB = useGameStore((s) => s.passedMovieB); - const movieA = useGameStore((s) => s.movieA); - const movieB = useGameStore((s) => s.movieB); - const isValidating = useGameStore((s) => s.isValidating); - const undoLastLink = useGameStore((s) => s.undoLastLink); + const screen = useGameScreen(); + const { status } = screen; const navigate = useNavigate(); - const { validateAndAddActor, validateAndAddMovie } = useChainValidation(); useEffect(() => { if (status === 'idle') { @@ -31,29 +19,10 @@ export default function Game() { } }, [status, navigate]); - if (status === 'idle' || !movieA || !movieB) { + if (!screen.ready) { return null; } - // Build contextual search label - const lastLink = chain[chain.length - 1]; - let searchLabel = ''; - let searchPlaceholder = ''; - - if (currentSearchMode === 'actor' && lastLink?.type === 'movie') { - searchLabel = `Who was in "${lastLink.title}"?`; - searchPlaceholder = 'Search for an actor...'; - } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') { - searchLabel = `What movie was ${lastLink.name} also in?`; - searchPlaceholder = 'Search for a movie...'; - } - - // Hint for closing the loop - const showLoopHint = - passedMovieB && - currentSearchMode === 'movie' && - movieA; - return (
@@ -63,45 +32,7 @@ export default function Game() { - {status === 'playing' && ( -
- - -
-
- -
- - -
-
- - - - - - {showLoopHint && ( -

- Select "{movieA.title}" to close the loop! -

- )} -
-
- )} + {status === 'playing' && }
diff --git a/src/pages/GameNightPlay.tsx b/src/pages/GameNightPlay.tsx index 5d35966..a5ee3be 100644 --- a/src/pages/GameNightPlay.tsx +++ b/src/pages/GameNightPlay.tsx @@ -2,31 +2,22 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useGameStore } from '@/stores/game-store'; import { useGameNightStore } from '@/stores/game-night-store'; -import { useChainValidation } from '@/hooks/use-chain-validation'; +import { useGameScreen } from '@/hooks/use-game-screen'; import PageLayout from '@/components/layout/PageLayout'; import GameHeader from '@/components/game/GameHeader'; import ChainDisplay from '@/components/game/ChainDisplay'; -import SearchAutocomplete from '@/components/game/SearchAutocomplete'; -import ValidationFeedback from '@/components/game/ValidationFeedback'; -import HintButton from '@/components/game/HintButton'; +import GameSearchPanel from '@/components/game/GameSearchPanel'; import PlayersProgress from '@/components/game-night/PlayersProgress'; import GameNightCompletionModal from '@/components/game-night/GameNightCompletionModal'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { Flag, Undo2 } from 'lucide-react'; +import { Flag } from 'lucide-react'; export default function GameNightPlay() { - const status = useGameStore((s) => s.status); - const chain = useGameStore((s) => s.chain); - const currentSearchMode = useGameStore((s) => s.currentSearchMode); - const passedMovieB = useGameStore((s) => s.passedMovieB); - const movieA = useGameStore((s) => s.movieA); - const movieB = useGameStore((s) => s.movieB); - const isValidating = useGameStore((s) => s.isValidating); + const screen = useGameScreen(); + const { status, chain } = screen; const score = useGameStore((s) => s.score); - const undoLastLink = useGameStore((s) => s.undoLastLink); const navigate = useNavigate(); - const { validateAndAddActor, validateAndAddMovie } = useChainValidation(); const sendChainUpdate = useGameNightStore((s) => s.sendChainUpdate); const sendPlayerComplete = useGameNightStore((s) => s.sendPlayerComplete); @@ -63,24 +54,10 @@ export default function GameNightPlay() { } }, [lobbyState, roomId, navigate]); - if (status === 'idle' || !movieA || !movieB) { + if (!screen.ready) { return null; } - const lastLink = chain[chain.length - 1]; - let searchLabel = ''; - let searchPlaceholder = ''; - - if (currentSearchMode === 'actor' && lastLink?.type === 'movie') { - searchLabel = `Who was in "${lastLink.title}"?`; - searchPlaceholder = 'Search for an actor...'; - } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') { - searchLabel = `What movie was ${lastLink.name} also in?`; - searchPlaceholder = 'Search for a movie...'; - } - - const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA; - return (
@@ -93,51 +70,16 @@ export default function GameNightPlay() { {status === 'playing' && !isSpectator && ( -
- - -
-
- -
- - - -
-
- - - - - - {showLoopHint && ( -

- Select "{movieA.title}" to close the loop! -

- )} -
-
+ + + )}
diff --git a/src/pages/GameReview.tsx b/src/pages/GameReview.tsx index 80f8e2c..50e69d2 100644 --- a/src/pages/GameReview.tsx +++ b/src/pages/GameReview.tsx @@ -1,20 +1,15 @@ import { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router'; import PageLayout from '@/components/layout/PageLayout'; -import StaticChainDisplay from '@/components/game/StaticChainDisplay'; +import ChainDisplay from '@/components/game/ChainDisplay'; import ScoreDisplay from '@/components/game/ScoreDisplay'; import { Skeleton } from '@/components/ui/skeleton'; import { getGameSession, type GameHistoryEntry } from '@/api/games'; import { ArrowLeft, Clock, Lightbulb, Calendar } from 'lucide-react'; import { releaseYear } from '@/lib/tmdb'; +import { formatTime } from '@/lib/format'; import { isAbortError } from '@/lib/abort'; -function formatTime(seconds: number) { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - export default function GameReview() { const { id } = useParams<{ id: string }>(); const [game, setGame] = useState(null); @@ -97,7 +92,7 @@ export default function GameReview() { {Array.isArray(game.chain) && game.chain.length > 0 && (

Chain

- = { - // MCU - 299536: 'mcu', 299534: 'mcu', 24428: 'mcu', 100402: 'mcu', 284053: 'mcu', - 118340: 'mcu', 99861: 'mcu', 68721: 'mcu', 1726: 'mcu', 10195: 'mcu', - 315635: 'mcu', 284054: 'mcu', 271110: 'mcu', 283995: 'mcu', 429617: 'mcu', - // Star Wars - 11: 'sw', 1891: 'sw', 1892: 'sw', 1893: 'sw', 1894: 'sw', 1895: 'sw', - 140607: 'sw', 181808: 'sw', 181812: 'sw', 330459: 'sw', 348350: 'sw', - // LotR / Hobbit - 120: 'lotr', 121: 'lotr', 122: 'lotr', 49051: 'lotr', 57158: 'lotr', 122917: 'lotr', - // Harry Potter - 671: 'hp', 672: 'hp', 673: 'hp', 674: 'hp', 675: 'hp', 767: 'hp', 12444: 'hp', 12445: 'hp', - // Fast & Furious - 9799: 'ff', 13804: 'ff', 51497: 'ff', 82992: 'ff', 168259: 'ff', 337339: 'ff', 385128: 'ff', - // Godfather - 238: 'gf', 240: 'gf', 242: 'gf', - // Dark Knight - 155: 'dk', 49026: 'dk', 272: 'dk', - // Jurassic - 329: 'jp', 330: 'jp', 331: 'jp', 135397: 'jp', 351286: 'jp', - // Toy Story - 862: 'ts', 863: 'ts', 10193: 'ts', 301528: 'ts', - // Pirates - 22: 'potc', 58: 'potc', 285: 'potc', 1865: 'potc', 166426: 'potc', -}; - -function areSameFranchise(idA: number, idB: number): boolean { - const tagA = FRANCHISE_TAGS[idA]; - const tagB = FRANCHISE_TAGS[idB]; - return !!(tagA && tagB && tagA === tagB); -} +import { pickDistinctPair } from '@/lib/movie-pair'; async function getRandomTmdbPair(): Promise { // Pick a random page from the first 20 pages of popular movies @@ -47,21 +15,11 @@ async function getRandomTmdbPair(): Promise { const data = await discoverMovies(page); const movies = data.results.filter((m) => m.title && m.release_date); - if (movies.length < 2) { + const pair = pickDistinctPair(movies); + if (!pair) { throw new Error('Not enough movies returned from TMDB'); } - - // Shuffle and pick two that aren't from the same franchise - const shuffled = movies.sort(() => Math.random() - 0.5); - const movieA = shuffled[0]; - let movieB = shuffled[1]; - - for (let i = 2; i < shuffled.length; i++) { - if (!areSameFranchise(movieA.id, shuffled[i].id)) { - movieB = shuffled[i]; - break; - } - } + const [movieA, movieB] = pair; return { movieA: { id: movieA.id, title: movieA.title }, diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx index bfd9772..852e7cb 100644 --- a/src/pages/Leaderboard.tsx +++ b/src/pages/Leaderboard.tsx @@ -6,6 +6,7 @@ import PageLayout from '@/components/layout/PageLayout'; import { getLeaderboard, type LeaderboardEntry, type LeaderboardResponse } from '@/api/leaderboards'; import { Trophy, Clock, ChevronLeft, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { formatTime } from '@/lib/format'; type Period = 'daily' | 'weekly' | 'all-time'; type DifficultyFilter = 'all' | 'easy' | 'medium' | 'hard'; @@ -23,12 +24,6 @@ const DIFFICULTIES: { value: DifficultyFilter; label: string }[] = [ { value: 'hard', label: 'Hard' }, ]; -function formatTime(seconds: number) { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - function GameModeBadge({ mode }: { mode: string }) { switch (mode) { case 'daily': diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 21c7ade..c5e0521 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -14,12 +14,8 @@ import { getErrorMessage } from '@/lib/error'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { User, Flame, Trophy, BarChart3, LogOut, Pencil, Check, X, KeyRound, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; - -function formatTime(seconds: number) { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} +import { formatTime } from '@/lib/format'; +import PushNotificationsSection from '@/components/profile/PushNotificationsSection'; export default function Profile() { const user = useAuthStore((s) => s.user); @@ -274,6 +270,11 @@ export default function Profile() { + {/* Notifications */} + + + + {loading && (
{Array.from({ length: 4 }).map((_, i) => ( diff --git a/src/pages/Versus.tsx b/src/pages/Versus.tsx index e8c3e4b..8f49f56 100644 --- a/src/pages/Versus.tsx +++ b/src/pages/Versus.tsx @@ -38,6 +38,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { rawToLinkCount } from '@/types'; +import { getErrorMessage } from '@/lib/error'; import { toast } from 'sonner'; type Difficulty = 'easy' | 'medium' | 'hard'; @@ -183,8 +184,7 @@ export default function Versus() { navigate(`/versus/lobby/${matchId}`); } catch (err: unknown) { if (pw) { - const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; - setJoinError(message ?? 'Incorrect password'); + setJoinError(getErrorMessage(err, 'Incorrect password')); } } finally { setJoining(null); @@ -198,9 +198,9 @@ export default function Versus() { setPasswordPromptMatch(null); navigate(`/versus/async-game/${matchId}`); } catch (err: unknown) { - const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + const message = getErrorMessage(err, ''); if (pw) { - setJoinError(message ?? 'Incorrect password'); + setJoinError(message || 'Incorrect password'); } else if (message === 'You have already attempted this match') { navigate(`/versus/async/${matchId}/leaderboard`); } diff --git a/src/pages/VersusGame.tsx b/src/pages/VersusGame.tsx index 541f21d..1761374 100644 --- a/src/pages/VersusGame.tsx +++ b/src/pages/VersusGame.tsx @@ -2,31 +2,20 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useGameStore } from '@/stores/game-store'; import { useVersusStore } from '@/stores/versus-store'; -import { useChainValidation } from '@/hooks/use-chain-validation'; +import { useGameScreen } from '@/hooks/use-game-screen'; import PageLayout from '@/components/layout/PageLayout'; import GameHeader from '@/components/game/GameHeader'; import ChainDisplay from '@/components/game/ChainDisplay'; -import SearchAutocomplete from '@/components/game/SearchAutocomplete'; -import ValidationFeedback from '@/components/game/ValidationFeedback'; -import HintButton from '@/components/game/HintButton'; +import GameSearchPanel from '@/components/game/GameSearchPanel'; import OpponentProgress from '@/components/versus/OpponentProgress'; import VersusCompletionModal from '@/components/versus/VersusCompletionModal'; -import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { Undo2 } from 'lucide-react'; export default function VersusGame() { - const status = useGameStore((s) => s.status); - const chain = useGameStore((s) => s.chain); - const currentSearchMode = useGameStore((s) => s.currentSearchMode); - const passedMovieB = useGameStore((s) => s.passedMovieB); - const movieA = useGameStore((s) => s.movieA); - const movieB = useGameStore((s) => s.movieB); - const isValidating = useGameStore((s) => s.isValidating); + const screen = useGameScreen(); + const { status, chain } = screen; const score = useGameStore((s) => s.score); - const undoLastLink = useGameStore((s) => s.undoLastLink); const navigate = useNavigate(); - const { validateAndAddActor, validateAndAddMovie } = useChainValidation(); const sendChainUpdate = useVersusStore((s) => s.sendChainUpdate); const sendMatchComplete = useVersusStore((s) => s.sendMatchComplete); const sessionId = useGameStore((s) => s.sessionId); @@ -51,24 +40,10 @@ export default function VersusGame() { } }, [status, navigate]); - if (status === 'idle' || !movieA || !movieB) { + if (!screen.ready) { return null; } - const lastLink = chain[chain.length - 1]; - let searchLabel = ''; - let searchPlaceholder = ''; - - if (currentSearchMode === 'actor' && lastLink?.type === 'movie') { - searchLabel = `Who was in "${lastLink.title}"?`; - searchPlaceholder = 'Search for an actor...'; - } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') { - searchLabel = `What movie was ${lastLink.name} also in?`; - searchPlaceholder = 'Search for a movie...'; - } - - const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA; - return (
@@ -80,45 +55,7 @@ export default function VersusGame() { - {status === 'playing' && ( -
- - -
-
- -
- - -
-
- - - - - - {showLoopHint && ( -

- Select "{movieA.title}" to close the loop! -

- )} -
-
- )} + {status === 'playing' && }
diff --git a/src/stores/game-night-store.ts b/src/stores/game-night-store.ts index aed263f..d384c54 100644 --- a/src/stores/game-night-store.ts +++ b/src/stores/game-night-store.ts @@ -97,10 +97,6 @@ export const useGameNightStore = create((set, get) => ({ }); }); - socket.on('player-joined', () => { - // Player joins are handled via room-state updates - }); - socket.on('player-left', (data: { userId: string }) => { set((state) => ({ players: state.players.filter((p) => p.userId !== data.userId), @@ -124,11 +120,8 @@ export const useGameNightStore = create((set, get) => ({ set({ lobbyState: 'countdown', countdownValue: data.value }); }); - socket.on('game-start', (data: unknown) => { + socket.on('game-start', () => { set({ lobbyState: 'playing' }); - const handler = (get() as { _onGameStart?: (d: unknown) => void }) - ._onGameStart; - if (handler) handler(data); }); socket.on( diff --git a/src/stores/game-store.test.ts b/src/stores/game-store.test.ts index 92d63d5..fd6153a 100644 --- a/src/stores/game-store.test.ts +++ b/src/stores/game-store.test.ts @@ -279,7 +279,7 @@ describe('movies without a release date', () => { getMovieDetails.mockImplementation((id: number) => Promise.resolve(undated(id))); const result = await useGameStore.getState().startGameFromVersus(PAIR, 1); expect(result).toBe('started'); - expect(createGameSession.mock.calls[0][0]).not.toHaveProperty('movieAReleaseDate'); + expect(createGameSession.mock.calls[0]?.[0]).not.toHaveProperty('movieAReleaseDate'); }); it('are tolerated by a single-player start', async () => { @@ -287,7 +287,7 @@ describe('movies without a release date', () => { await useGameStore.getState().startGame(PAIR); await flush(); expect(useGameStore.getState().status).toBe('playing'); - expect(createGameSession.mock.calls[0][0]).not.toHaveProperty('movieBReleaseDate'); + expect(createGameSession.mock.calls[0]?.[0]).not.toHaveProperty('movieBReleaseDate'); }); }); diff --git a/src/stores/game-store.ts b/src/stores/game-store.ts index 40c7b7c..4a8e950 100644 --- a/src/stores/game-store.ts +++ b/src/stores/game-store.ts @@ -111,6 +111,7 @@ function reconcileChain(serverChain: ChainLink[]): void { const merged = [movieA, ...serverChain]; const lastLink = merged[merged.length - 1]; + if (!lastLink) return; // unreachable: merged always has at least movieA useGameStore.setState({ chain: merged, currentSearchMode: lastLink.type === 'movie' ? 'actor' : 'movie', @@ -384,6 +385,7 @@ export const useGameStore = create((set, get) => ({ const newChain = chain.slice(0, -1); const lastLink = newChain[newChain.length - 1]; + if (!lastLink) return; // unreachable: chain.length > 1 guarantees newChain is non-empty // Recompute passedMovieB const hasPassedMovieB = newChain.some( diff --git a/src/stores/versus-store.ts b/src/stores/versus-store.ts index 114a397..aba9f85 100644 --- a/src/stores/versus-store.ts +++ b/src/stores/versus-store.ts @@ -108,13 +108,8 @@ export const useVersusStore = create((set, get) => ({ set({ lobbyState: 'countdown', countdownValue: data.value }); }); - socket.on('game-start', (data: unknown) => { + socket.on('game-start', () => { set({ lobbyState: 'playing' }); - // The VersusLobby page will handle navigation using this event - // Store the event data for the page to consume - const handler = (get() as { _onGameStart?: (d: unknown) => void }) - ._onGameStart; - if (handler) handler(data); }); socket.on('opponent-progress', (data: { chainLength: number }) => { diff --git a/src/types/tmdb.ts b/src/types/tmdb.ts index dad2656..d70ce95 100644 --- a/src/types/tmdb.ts +++ b/src/types/tmdb.ts @@ -81,17 +81,3 @@ export interface TmdbMovieDetailsResponse extends TmdbMovieResult { tagline: string; genres: { id: number; name: string }[]; } - -export interface TmdbPersonDetailsResponse { - id: number; - name: string; - biography: string; - profile_path: string | null; - birthday: string | null; - deathday: string | null; - place_of_birth: string | null; - popularity: number; - known_for_department: string; - also_known_as: string[]; - adult: boolean; -} diff --git a/tsconfig.app.json b/tsconfig.app.json index a132313..d175eb1 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -18,6 +18,7 @@ /* Linting */ "strict": true, + "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true,