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.
This commit is contained in:
TehRiehlDeal committed 2026-09-12 00:01:42 -07:00
1 parent c0643de4b4
commit 5a7e8cebc3
64 files changed
+1787 -1089

No files matched your search

+73
View File
@@ -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<version>` git tag already exists on
origin, before it builds anything, for the same reason the backend does.
- The stock Vite README is replaced with one that describes this app.
### Fixed
- *Whiplash* in the preset pairs is TMDB id 244786; the table had 274870, a
different film. Every preset title is now checked against what TMDB serves
for its id.
- Checking whether push is subscribed no longer awaits
`navigator.serviceWorker.ready`, which never settles when no worker is
registered - the check hung on any page load before registration.
- `getErrorMessage` returns its fallback for a server error carrying an empty
message array, rather than `undefined`.
### Removed
- The dead `src/api/index.ts` barrel and the fourteen API functions it alone
referenced, three orphaned types, the Vite scaffold SVGs, the unused
`scroll-area` primitive and four unused dialog/sheet exports, and the
`_onGameStart` escape hatch that two stores read but nothing ever set.
Nothing a user could reach.
## [2.1.0] - 2026-09-11
Accessibility (WP-16: audit section 2.8).
+109 -58
View File
@@ -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<version>` already exists as a git tag or as a Harbor image tag, pushes to Harbor, signs with cosign, and pushes the git tag back. Bump `package.json` before merging or the run fails on purpose.
## License
Private. All rights reserved.
+70 -4
View File
@@ -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",
+2 -2
View File
@@ -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",
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-5
View File
@@ -13,11 +13,6 @@ export interface Achievement {
unlockedAt: string | null;
}
export async function getAllAchievements(): Promise<Achievement[]> {
const { data } = await apiClient.get<Achievement[]>('/achievements');
return data;
}
export async function getUserAchievements(
signal?: AbortSignal,
): Promise<Achievement[]> {
+40 -11
View File
@@ -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<RecalculateResult> {
export async function recalculateScores(
cursor?: string,
): Promise<RecalculateResult> {
const { data } = await apiClient.post<RecalculateResult>(
'/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<BackfillGameOwnersResult> {
const { data } = await apiClient.post<BackfillGameOwnersResult>(
'/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<RetroactiveAchievementsResult> {
export async function retroactiveAchievements(
cursor?: string,
): Promise<RetroactiveAchievementsResult> {
const { data } = await apiClient.post<RetroactiveAchievementsResult>(
'/admin/retroactive-achievements',
undefined,
{ params: cursor ? { cursor } : undefined },
);
return data;
}
-5
View File
@@ -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;
}
-35
View File
@@ -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<DailyChallenge> {
const { data } = await apiClient.get<DailyChallenge>(
'/daily-challenges/today',
);
return data;
}
export async function getTodaysChallenges(
signal?: AbortSignal,
): Promise<DailyChallenge[]> {
@@ -37,23 +22,3 @@ export async function getTodaysChallenges(
);
return data;
}
export async function getChallengeHistory(
page = 1,
limit = 20,
): Promise<DailyChallengeHistory> {
const { data } = await apiClient.get<DailyChallengeHistory>(
'/daily-challenges',
{ params: { page, limit } },
);
return data;
}
export async function getChallengeByDate(
date: string,
): Promise<DailyChallenge> {
const { data } = await apiClient.get<DailyChallenge>(
`/daily-challenges/${date}`,
);
return data;
}
-5
View File
@@ -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,
-16
View File
@@ -82,11 +82,6 @@ export async function getOpenRooms(
return data;
}
export async function getMyRooms(): Promise<GameNightRoom[]> {
const { data } = await apiClient.get<GameNightRoom[]>('/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<void> {
await apiClient.post(`/game-night/${roomId}/leave`);
}
export async function getRoom(roomId: string): Promise<GameNightRoom> {
const { data } = await apiClient.get<GameNightRoom>(
`/game-night/${roomId}`,
);
return data;
}
export async function getRoomResults(
roomId: string,
signal?: AbortSignal,
-81
View File
@@ -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';
-10
View File
@@ -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<StreakInfo> {
const { data } = await apiClient.get<StreakInfo>('/leaderboards/me/streak');
return data;
}
-4
View File
@@ -42,7 +42,3 @@ export async function markAllNotificationsRead(): Promise<void> {
export async function deleteNotification(id: string): Promise<void> {
await apiClient.delete(`/notifications/${id}`);
}
export async function deleteAllReadNotifications(): Promise<void> {
await apiClient.delete('/notifications/read');
}
-10
View File
@@ -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<TmdbPersonDetailsResponse> {
const { data } = await apiClient.get<TmdbPersonDetailsResponse>(
`/persons/${id}`,
);
return data;
}
export async function getPersonMovieCredits(
id: number,
): Promise<TmdbPersonMovieCreditsResponse> {
-7
View File
@@ -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,
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+6 -1
View File
@@ -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,
+76 -11
View File
@@ -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 (
<ChainRows
chain={props.chain}
movieAId={props.movieAId}
movieBId={props.movieBId}
status="completed"
isValidating={false}
live={false}
/>
);
}
return <LiveChainDisplay />;
}
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 (
<ChainRows
chain={chain}
movieAId={movieA?.id}
movieBId={movieB?.id}
status={status}
isValidating={isValidating}
live
/>
);
}
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() {
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
<Dot muted={!isComplete} />
<Dot muted={live && !isComplete} />
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie ? (
<div className="flex items-center gap-1.5">
@@ -113,7 +178,7 @@ export default function ChainDisplay() {
{movieTarget && <TargetBadge target={movieTarget} />}
</div>
) : (
<PendingSlot loading={isValidating && status === 'playing'} />
live && <PendingSlot loading={isValidating && status === 'playing'} />
)}
</div>
</>
@@ -127,10 +192,10 @@ export default function ChainDisplay() {
<MovieCard movie={pair.movie} compact />
</div>
) : (
<PendingSlot loading={isValidating && status === 'playing'} />
live && <PendingSlot loading={isValidating && status === 'playing'} />
)}
</div>
<Dot muted={!isComplete} />
<Dot muted={live && !isComplete} />
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
+2 -7
View File
@@ -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() {
<>
<ScoreDisplay score={score} />
<div className="mt-3 flex justify-center gap-4 text-sm text-muted-foreground">
<span>Completed in {formatElapsed(score.elapsedSeconds)}</span>
<span>Completed in {formatTime(score.elapsedSeconds)}</span>
{hintsUsed > 0 && (
<span>{hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used</span>
)}
+75
View File
@@ -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 (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="flex items-center gap-2">
<HintButton />
<Button
variant="ghost"
size="sm"
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Undo
</Button>
{children}
</div>
</div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
);
}
+6 -6
View File
@@ -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':
-140
View File
@@ -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 (
<div>
{startMovie && (
<MovieCard movie={startMovie} isTarget="A" />
)}
{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 (
<div key={pairIndex}>
<SerpentineConnector lineX={lineX} />
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
<div
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
style={{ left: `${lineX}%` }}
/>
<div
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
style={{ left: `${armLeft}%`, right: `${armRight}%` }}
/>
<div
className={cn(
'flex w-[85%] items-center overflow-hidden rounded-lg border p-2.5',
movieTarget === 'B' && 'border-green-500 bg-green-500/5',
movieTarget === 'A' && 'border-primary bg-primary/5',
!movieTarget && 'border-border bg-card',
)}
>
{isOdd ? (
<>
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
<Dot />
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie && (
<div className="flex items-center gap-1.5">
<MovieCard movie={pair.movie} compact />
{movieTarget && <TargetBadge target={movieTarget} />}
</div>
)}
</div>
</>
) : (
<>
<div className="flex min-w-0 flex-1 items-center justify-center">
{pair.movie && (
<div className="flex items-center gap-1.5">
{movieTarget && <TargetBadge target={movieTarget} />}
<MovieCard movie={pair.movie} compact />
</div>
)}
</div>
<Dot />
<div className="flex min-w-0 flex-1 items-center justify-center">
<ActorCard actor={pair.actor} compact />
</div>
</>
)}
</div>
</div>
</div>
);
})}
<div className="flex justify-center py-3">
<div className="rounded-full bg-green-500/10 px-4 py-1.5 text-sm font-medium text-green-600">
Loop Complete!
</div>
</div>
</div>
);
}
function Dot() {
return (
<div className="mx-2 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
);
}
function TargetBadge({ target }: { target: 'A' | 'B' }) {
return (
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
target === 'A'
? 'bg-blue-500/10 text-blue-600'
: 'bg-green-500/10 text-green-600',
)}
>
{target}
</span>
);
}
@@ -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();
});
@@ -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(<PushNotificationsSection />);
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(<PushNotificationsSection />);
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(<PushNotificationsSection />);
expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled();
});
it('is disabled while an action is in flight', () => {
state.busy = true;
render(<PushNotificationsSection />);
expect(
screen.getByRole('button', { name: 'Turn on push notifications' }),
).toBeDisabled();
});
it('explains a blocked permission and disables the toggle', () => {
state.support = 'denied';
render(<PushNotificationsSection />);
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(<PushNotificationsSection />);
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(<PushNotificationsSection />);
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(<PushNotificationsSection />);
expect(screen.queryByRole('button')).toBeNull();
expect(
screen.getByText('This browser does not support push notifications.'),
).toBeInTheDocument();
});
});
@@ -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 (
<div className="space-y-2">
<h3 className="text-sm font-semibold">Notifications</h3>
<p className="text-sm text-muted-foreground">
This browser does not support push notifications.
</p>
</div>
);
}
const checking = push.subscribed === null;
const blocked = push.support === 'denied';
return (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold">Notifications</h3>
<p className="text-sm text-muted-foreground">
Get a push notification when a friend challenges you or a versus
match finishes, even when the game is not open.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={checking || push.busy || (blocked && !push.subscribed)}
onClick={() => void (push.subscribed ? push.disable() : push.enable())}
aria-pressed={push.subscribed === true}
>
{push.busy || checking ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" />
) : push.subscribed ? (
<BellOff className="mr-1.5 h-4 w-4" aria-hidden="true" />
) : (
<Bell className="mr-1.5 h-4 w-4" aria-hidden="true" />
)}
{checking
? 'Checking…'
: push.subscribed
? 'Turn off push notifications'
: 'Turn on push notifications'}
</Button>
{blocked && !push.subscribed && !push.error && (
<p className="text-sm text-muted-foreground">
Notifications are blocked for this site. Allow them in your browser
settings to turn push on.
</p>
)}
{push.error && (
<Alert variant="destructive">
<AlertDescription>{push.error}</AlertDescription>
</Alert>
)}
</div>
);
}
-5
View File
@@ -19,10 +19,6 @@ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
@@ -145,7 +141,6 @@ function DialogDescription({
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
-54
View File
@@ -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 (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
-30
View File
@@ -15,10 +15,6 @@ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
@@ -90,16 +86,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
@@ -110,26 +96,10 @@ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+2 -2
View File
@@ -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();
});
+3 -19
View File
@@ -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;
}
+90
View File
@@ -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<void>;
validateAndAddMovie: (movie: TmdbMovieResult) => Promise<void>;
/** 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<GameScreen, { ready: true }>;
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 };
}
+170
View File
@@ -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<typeof import('@/lib/push')>('@/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<boolean>((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<void>((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => expect(result.current.subscribed).toBe(false));
let pending: Promise<void>;
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);
});
});
+79
View File
@@ -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<void>;
disable: () => Promise<void>;
}
/**
* 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>(() => pushSupport());
// An unsupported browser is known not-subscribed up front; otherwise the
// answer comes from the probe below.
const [subscribed, setSubscribed] = useState<boolean | null>(() =>
support === 'unsupported' ? false : null,
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<void>, 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 };
}
+1 -6
View File
@@ -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));
+19 -2
View File
@@ -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 {
+15
View File
@@ -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')}`;
}
+72
View File
@@ -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<Movie>([])).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);
});
});
+90
View File
@@ -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<number, string> = {
// 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<T>(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<T extends { id: number; title: string }>(
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;
}
+7 -3
View File
@@ -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 };
+305
View File
@@ -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<unknown>>();
return {
registration: {
pushManager: {
getSubscription: vi.fn().mockResolvedValue(subscription),
subscribe,
},
} as unknown as ServiceWorkerRegistration,
subscribe,
};
}
interface FakeSubscription {
endpoint: string;
toJSON: () => unknown;
unsubscribe: ReturnType<typeof vi.fn<() => Promise<boolean>>>;
}
function fakeSubscription(endpoint = 'https://push.example/abc'): FakeSubscription {
const unsubscribe = vi.fn<() => Promise<boolean>>();
unsubscribe.mockResolvedValue(true);
return {
endpoint,
toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }),
unsubscribe,
};
}
type AsyncMock = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
let getRegistration: AsyncMock;
let register: AsyncMock;
let requestPermission: AsyncMock;
let permission: NotificationPermission;
beforeEach(() => {
vi.clearAllMocks();
getRegistration = vi.fn<(...args: unknown[]) => Promise<unknown>>();
getRegistration.mockResolvedValue(undefined);
register = vi.fn<(...args: unknown[]) => Promise<unknown>>();
requestPermission = vi.fn<(...args: unknown[]) => Promise<unknown>>();
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();
});
});
+99 -18
View File
@@ -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<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) return null;
@@ -12,43 +39,97 @@ export async function registerServiceWorker(): Promise<ServiceWorkerRegistration
}
}
export async function subscribeToPush(): Promise<boolean> {
const registration = await registerServiceWorker();
if (!registration) return false;
async function currentRegistration(): Promise<ServiceWorkerRegistration | null> {
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<void> {
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<void> {
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<boolean> {
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 {
+3 -3
View File
@@ -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() {
<CardContent className="flex items-center justify-between py-3 px-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(result.displayName || result.username)[0].toUpperCase()}
{(result.displayName || result.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium">{result.displayName || result.username}</p>
+105 -122
View File
@@ -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() {
<Separator />
<RecalculateSection />
<Separator />
<BackfillGameOwnersSection />
<Separator />
<AchievementsSection />
<Separator />
<BackfillUsernamesSection />
@@ -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<BatchPage> {
return 'hasMore' in result ? (result as BatchPage) : {};
}
function BatchJobSection<T extends object>({
icon: Icon,
title,
description,
action,
run,
failureMessage,
renderResult,
}: {
icon: LucideIcon;
title: string;
description: string;
action: string;
run: (cursor?: string) => Promise<T>;
failureMessage: string;
renderResult: (result: T) => ReactNode;
}) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<RecalculateResult | null>(null);
const [result, setResult] = useState<T | null>(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 (
<div>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
<RefreshCw className="h-5 w-5" />
Recalculate Scores
<Icon className="h-5 w-5" aria-hidden="true" />
{title}
</h2>
<p className="mb-3 text-sm text-muted-foreground">
Recalculates scores for all completed games using the current scoring
formula.
</p>
<Button onClick={handleRecalculate} disabled={loading}>
<p className="mb-3 text-sm text-muted-foreground">{description}</p>
<Button onClick={handleRun} disabled={loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<Icon className="mr-2 h-4 w-4" aria-hidden="true" />
)}
Recalculate All Scores
{cursor ? 'Continue' : action}
</Button>
{result && (
<Alert className="mt-3">
<AlertDescription>
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.'}
</AlertDescription>
</Alert>
)}
@@ -188,112 +216,67 @@ function RecalculateSection() {
);
}
function RecalculateSection() {
return (
<BatchJobSection<RecalculateResult>
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 (
<BatchJobSection<BackfillGameOwnersResult>
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<RetroactiveAchievementsResult | null>(
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 (
<div>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
<Trophy className="h-5 w-5" />
Retroactive Achievements
</h2>
<p className="mb-3 text-sm text-muted-foreground">
Evaluate all existing games and award any achievements that should have
been earned.
</p>
<Button onClick={handleRetroactive} disabled={loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trophy className="mr-2 h-4 w-4" />
)}
Award Retroactive Achievements
</Button>
{result && (
<Alert className="mt-3">
<AlertDescription>
Awarded {result.totalAwarded} achievements across{' '}
{result.usersAffected} users.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive" className="mt-3">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<BatchJobSection<RetroactiveAchievementsResult>
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<BackfillUsernamesResult | null>(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 (
<div>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
<Users className="h-5 w-5" />
Backfill Normalized Usernames
</h2>
<p className="mb-3 text-sm text-muted-foreground">
Populates the normalized username column for existing users so they can
be found via friend search.
</p>
<Button onClick={handleBackfill} disabled={loading}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Users className="mr-2 h-4 w-4" />
)}
Backfill Usernames
</Button>
{result && (
<Alert className="mt-3">
<AlertDescription>
Backfilled {result.backfilled} user{result.backfilled !== 1 ? 's' : ''}.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive" className="mt-3">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<BatchJobSection<BackfillUsernamesResult>
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' : ''}.`
}
/>
);
}
+2 -3
View File
@@ -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);
}
+7 -68
View File
@@ -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<AsyncMode>('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 (
<PageLayout>
<div className="space-y-4">
@@ -265,45 +242,7 @@ export default function AsyncVersusGame() {
<ChainDisplay />
{status === 'playing' && (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="flex items-center gap-2">
<HintButton />
<Button
variant="ghost"
size="sm"
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Undo
</Button>
</div>
</div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
)}
{status === 'playing' && <GameSearchPanel screen={screen} />}
</div>
<AsyncCompletionModal
+1 -1
View File
@@ -164,7 +164,7 @@ export default function Chat() {
</Link>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(friend?.displayName || friend?.username || '?')[0].toUpperCase()}
{(friend?.displayName || friend?.username || '?').charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{friend?.displayName || friend?.username || 'Unknown'}</p>
+4 -2
View File
@@ -96,6 +96,8 @@ export default function DailyChallenge() {
});
};
const firstChallenge = challenges[0];
return (
<PageLayout>
<div className="flex flex-col items-center gap-8 py-12">
@@ -104,9 +106,9 @@ export default function DailyChallenge() {
<Calendar className="h-5 w-5 text-muted-foreground" />
<h1 className="text-2xl font-bold">Daily Challenges</h1>
</div>
{challenges.length > 0 && (
{firstChallenge && (
<p className="text-sm text-muted-foreground">
{formatDate(challenges[0].date)}
{formatDate(firstChallenge.date)}
</p>
)}
</div>
+6 -4
View File
@@ -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);
+4 -4
View File
@@ -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() {
<div className="flex items-center gap-3">
<div className="relative">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(friend.displayName || friend.username)[0].toUpperCase()}
{(friend.displayName || friend.username).charAt(0).toUpperCase()}
</div>
{onlineFriendIds.has(friend.id) && (
<>
@@ -240,7 +240,7 @@ export default function Friends() {
<CardContent className="flex items-center justify-between py-3 px-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
{(from.displayName || from.username)[0].toUpperCase()}
{(from.displayName || from.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium">{from.displayName || from.username}</p>
+6 -75
View File
@@ -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 (
<PageLayout>
<div className="space-y-4">
@@ -63,45 +32,7 @@ export default function Game() {
<ChainDisplay />
{status === 'playing' && (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="flex items-center gap-2">
<HintButton />
<Button
variant="ghost"
size="sm"
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Undo
</Button>
</div>
</div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
)}
{status === 'playing' && <GameSearchPanel screen={screen} />}
</div>
<GameCompletionModal />
+16 -74
View File
@@ -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 (
<PageLayout>
<div className="flex gap-6">
@@ -93,51 +70,16 @@ export default function GameNightPlay() {
<ChainDisplay />
{status === 'playing' && !isSpectator && (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="flex items-center gap-2">
<HintButton />
<Button
variant="ghost"
size="sm"
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Undo
</Button>
<Button
variant="destructive"
size="sm"
onClick={sendPlayerForfeit}
>
<Flag className="mr-1 h-3.5 w-3.5" />
I Give Up
</Button>
</div>
</div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
<GameSearchPanel screen={screen}>
<Button
variant="destructive"
size="sm"
onClick={sendPlayerForfeit}
>
<Flag className="mr-1 h-3.5 w-3.5" />
I Give Up
</Button>
</GameSearchPanel>
)}
</div>
+3 -8
View File
@@ -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<GameHistoryEntry | null>(null);
@@ -97,7 +92,7 @@ export default function GameReview() {
{Array.isArray(game.chain) && game.chain.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Chain</h2>
<StaticChainDisplay
<ChainDisplay
chain={game.chain}
movieAId={game.movieAId}
movieBId={game.movieBId}
+4 -46
View File
@@ -7,39 +7,7 @@ import PageLayout from '@/components/layout/PageLayout';
import { cn } from '@/lib/utils';
import { Loader2, Calendar, Swords, Shuffle, Crosshair, Users } from 'lucide-react';
import type { MoviePair } from '@/types';
/** Known franchise groups — movies in the same group won't be paired together. */
const FRANCHISE_TAGS: Record<number, string> = {
// 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<MoviePair> {
// Pick a random page from the first 20 pages of popular movies
@@ -47,21 +15,11 @@ async function getRandomTmdbPair(): Promise<MoviePair> {
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 },
+1 -6
View File
@@ -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':
+7 -6
View File
@@ -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() {
<Separator />
{/* Notifications */}
<PushNotificationsSection />
<Separator />
{loading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
+4 -4
View File
@@ -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`);
}
+6 -69
View File
@@ -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 (
<PageLayout>
<div className="space-y-4">
@@ -80,45 +55,7 @@ export default function VersusGame() {
<ChainDisplay />
{status === 'playing' && (
<div className="space-y-3">
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{searchLabel}</label>
<div className="flex items-center gap-2">
<HintButton />
<Button
variant="ghost"
size="sm"
onClick={undoLastLink}
disabled={chain.length <= 1 || isValidating}
>
<Undo2 className="mr-1 h-3.5 w-3.5" />
Undo
</Button>
</div>
</div>
<SearchAutocomplete
mode={currentSearchMode}
onSelectActor={validateAndAddActor}
onSelectMovie={validateAndAddMovie}
disabled={isValidating}
placeholder={searchPlaceholder}
/>
<ValidationFeedback />
{showLoopHint && (
<p className="text-sm text-green-600">
Select &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
)}
{status === 'playing' && <GameSearchPanel screen={screen} />}
</div>
<VersusCompletionModal />
+1 -8
View File
@@ -97,10 +97,6 @@ export const useGameNightStore = create<GameNightState>((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<GameNightState>((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(
+2 -2
View File
@@ -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');
});
});
+2
View File
@@ -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<GameState>((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(
+1 -6
View File
@@ -108,13 +108,8 @@ export const useVersusStore = create<VersusState>((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 }) => {
-14
View File
@@ -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;
}
+1
View File
@@ -18,6 +18,7 @@
/* Linting */
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,