Merge pull request 'Bugfix/multiplayer scoring and duplicates' (#2) from bugfix/multiplayer-scoring-and-duplicates into main
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / lint (push) Successful in 14s
frontend-ci / build (push) Successful in 35s
frontend-ci / push (push) Successful in 33s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
TehRiehlDeal committed 2026-09-09 22:06:30 -07:00
commit 51dbc45608
42 files changed
+3216 -2317

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npx eslint .
- run: npx eslint . --max-warnings 0
typecheck:
runs-on: ubuntu-latest
+71
View File
@@ -0,0 +1,71 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> History prior to 1.1.0 has not yet been backfilled from git.
## [Unreleased]
## [1.2.1] - 2026-09-09
### Fixed
- All high-severity dependency advisories cleared (`npm audit`: 23 -> 0),
entirely within existing semver ranges.
- Five further `set-state-in-effect` violations caught by the updated
react-hooks plugin, including search state that was cleared on every
keystroke below the search threshold, and an async submit guard that re-ran
its own effect.
### Changed
- In-range dependency updates, notably react 19.2 -> 19.3 and
`@base-ui/react` 1.2 -> 1.8.
- `shadcn`, `tailwindcss`, `@tailwindcss/vite` and `tw-animate-css` moved to
devDependencies. They are build-time only - `shadcn` is a scaffolding CLI
with no runtime imports - but as runtime dependencies they pulled `msw`,
`@babel/core` and `ts-morph` into the production dependency tree that
vulnerability scanning reads.
## [1.2.0] - 2026-09-09
### Changed
- Game completion, hints and all versus submissions follow the server's
authoritative scoring contract: completion posts no payload, hints go through
the server (which also records them), and match submissions send a session id
rather than a score.
### Fixed
- Stale hints are cleared when the chain changes; the previous movie's cast
used to stay on screen after picking an actor another way.
## [1.1.0] - 2026-09-09
### Fixed
- Versus, Game Night and Async Versus can complete a match again. None of the
three ever created a backend game session, so no score was produced, nothing
was submitted, the opponent was never notified, and the completion modal had
no way out.
- Chain links may be reused. Repeating an actor or movie was rejected outright
instead of being allowed and penalised, which could strand a player mid-chain.
Reuse now warns that it costs points, and the penalty appears in the score
breakdown.
- A failed async score submission is no longer retried on every render.
- Confetti no longer re-randomises mid-flight.
- Page data fetches are cancelled on unmount, so a late response cannot write
into a view the player has left.
- Profile stats and history load independently, fixing a guard that could stop
the stats request from ever re-running.
### Changed
- The dev container starts the Vite server correctly and now picks up source
edits, so hot reload works.
- The lint CI job fails on warnings, and the two React hook rules that had been
demoted to warnings are back at error.
+4 -1
View File
@@ -5,7 +5,10 @@ COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
# Invoke vite directly rather than through `npm run`: under `docker compose
# up -d` (no TTY, no stdin) the npm wrapper printed its banner and exited 0
# immediately, so the dev server never came up.
CMD ["node", "node_modules/vite/bin/vite.js", "--host"]
# --- Build stage ---
# No VITE_API_URL build arg: the production image uses runtime config (/config.js)
+7 -6
View File
@@ -16,15 +16,16 @@ export default defineConfig([
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
// Matches the ES2022 target in tsconfig.app.json.
ecmaVersion: 2022,
globals: globals.browser,
},
rules: {
// The react-hooks v6 "set-state-in-effect" and "purity" rules flag
// standard data-fetching patterns. Surface as warnings so CI passes
// and they remain visible in the editor.
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
// Left at their recommended 'error' severity. These were previously
// downgraded to 'warn' so CI would pass, which meant every violation was
// invisible in CI, including the stale-closure and cascading-render bugs
// that downgrade was hiding. The underlying violations are fixed, so the
// rules stay enforcing.
'@typescript-eslint/no-unused-vars': [
'error',
{
+2378 -1913
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,14 +1,14 @@
{
"name": "frontend-temp",
"private": true,
"version": "1.0.1",
"version": "1.2.1",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint": "eslint . --max-warnings 0",
"preview": "vite preview",
"release": "commit-and-tag-version",
"release:minor": "commit-and-tag-version --release-as minor",
@@ -18,7 +18,6 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -26,25 +25,26 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.13.1",
"shadcn": "^4.0.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"commit-and-tag-version": "^12.5.0",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"commit-and-tag-version": "^12.5.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"shadcn": "^4.0.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-restricted-globals */
self.addEventListener('push', (event) => {
if (!event.data) return;
+26 -6
View File
@@ -27,15 +27,35 @@ export async function createGameSession(
return data;
}
/**
* No payload. The server scores the chain it validated link by link and the
* hint count it incremented; sending our own was how a chain that had never
* been validated could be scored.
*/
export async function completeGameSession(
id: string,
chain: ChainLink[],
hintsUsed: number,
): Promise<ScoreBreakdown> {
const { data } = await apiClient.post<ScoreBreakdown>(`/games/${id}/complete`, {
chain,
hintsUsed,
});
const { data } = await apiClient.post<ScoreBreakdown>(
`/games/${id}/complete`,
{},
);
return data;
}
export interface HintResponse {
hintsUsed: number;
hintsRemaining: number;
actors: {
id: number;
name: string;
profilePath: string | null;
popularity: number;
}[];
}
/** Takes a hint. The server records it, which is what makes the penalty real. */
export async function useHint(id: string): Promise<HintResponse> {
const { data } = await apiClient.post<HintResponse>(`/games/${id}/hint`, {});
return data;
}
+11 -15
View File
@@ -80,15 +80,15 @@ export async function getUserMatchHistory(page = 1, limit = 20) {
return data;
}
/** Sends the session to score from; the server will not accept a score. */
export async function submitCreatorScore(
matchId: string,
score: object,
chainLength: number,
sessionId: string,
): Promise<VersusMatch> {
const { data } = await apiClient.post<VersusMatch>(`/versus/${matchId}/creator-score`, {
score,
chainLength,
});
const { data } = await apiClient.post<VersusMatch>(
`/versus/${matchId}/creator-score`,
{ sessionId },
);
return data;
}
@@ -114,15 +114,11 @@ export async function startAsyncAttempt(
return data;
}
export async function submitAsyncAttempt(
matchId: string,
score: object,
chainLength: number,
) {
const { data } = await apiClient.post(`/versus/${matchId}/async-attempt/submit`, {
score,
chainLength,
});
export async function submitAsyncAttempt(matchId: string, sessionId: string) {
const { data } = await apiClient.post(
`/versus/${matchId}/async-attempt/submit`,
{ sessionId },
);
return data;
}
@@ -20,6 +20,8 @@ export default function GameNightCompletionModal() {
const finishedPlayers = useGameNightStore((s) => s.finishedPlayers);
const forfeitedPlayers = useGameNightStore((s) => s.forfeitedPlayers);
const roomId = useGameNightStore((s) => s.roomId);
const leaveRoom = useGameNightStore((s) => s.leaveRoom);
const reset = useGameNightStore((s) => s.reset);
const isOpen = status === 'completed' || lobbyState === 'finished';
@@ -34,6 +36,14 @@ export default function GameNightCompletionModal() {
}
};
// Players who stall or drop must not strand everyone else in a modal whose
// only control appears once the room reaches 'finished'.
const handleLeave = () => {
leaveRoom();
reset();
navigate('/game-night');
};
return (
<Dialog open={isOpen}>
<DialogContent className="max-w-md">
@@ -82,6 +92,15 @@ export default function GameNightCompletionModal() {
);
})}
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleLeave}
>
Leave game
</Button>
</div>
)}
+39 -25
View File
@@ -8,8 +8,31 @@ const CONFETTI_COLORS = [
const CONFETTI_COUNT = 20;
interface ConfettiPiece {
color: string;
left: number;
delay: number;
duration: number;
size: number;
round: boolean;
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, () => ({
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
left: Math.random() * 100,
delay: Math.random() * 1,
duration: 2 + Math.random() * 1.5,
size: 6 + Math.random() * 6,
round: Math.random() > 0.5,
}));
}
export default function CelebrationOverlay() {
const [visible, setVisible] = useState(true);
// Rolled once per mount. Randomising in the render body re-rolled every piece
// on each re-render, so confetti visibly jumped mid-flight.
const [pieces] = useState(makeConfetti);
useEffect(() => {
const timer = setTimeout(() => setVisible(false), 3000);
@@ -20,31 +43,22 @@ export default function CelebrationOverlay() {
return (
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden">
{Array.from({ length: CONFETTI_COUNT }).map((_, i) => {
const color =
CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
const left = Math.random() * 100;
const delay = Math.random() * 1;
const duration = 2 + Math.random() * 1.5;
const size = 6 + Math.random() * 6;
return (
<div
key={i}
className="absolute animate-confetti"
style={{
left: `${left}%`,
top: '-10px',
width: `${size}px`,
height: `${size}px`,
backgroundColor: color,
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
animationDelay: `${delay}s`,
animationDuration: `${duration}s`,
}}
/>
);
})}
{pieces.map((piece, i) => (
<div
key={i}
className="absolute animate-confetti"
style={{
left: `${piece.left}%`,
top: '-10px',
width: `${piece.size}px`,
height: `${piece.size}px`,
backgroundColor: piece.color,
borderRadius: piece.round ? '50%' : '2px',
animationDelay: `${piece.delay}s`,
animationDuration: `${piece.duration}s`,
}}
/>
))}
</div>
);
}
+29 -15
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -36,25 +36,39 @@ export default function GameCompletionModal() {
const validationError = useGameStore((s) => s.validationError);
const resetGame = useGameStore((s) => s.resetGame);
const navigate = useNavigate();
const [showModal, setShowModal] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [prevStatus, setPrevStatus] = useState(status);
const celebrated = useRef(false);
// Derived from status rather than mirrored into state by an effect, with
// dismissal reset as soon as the game leaves the completed state.
if (status !== prevStatus) {
setPrevStatus(status);
if (status !== 'completed') setDismissed(false);
}
const showModal = status === 'completed' && !dismissed;
const gameMode: GameMode = isDailyChallenge && dailyChallengeDate
? { type: 'daily', date: dailyChallengeDate }
: { type: 'freeplay' };
// Side effects only — fired once per completion, guarded by a ref so a
// re-render or dependency change cannot replay the sound or the write.
useEffect(() => {
if (status === 'completed') {
setShowModal(true);
playSound('completion');
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
if (isDailyChallenge && dailyChallengeDate) {
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
}
} else {
setShowModal(false);
if (status !== 'completed') {
celebrated.current = false;
return;
}
}, [status]);
if (celebrated.current) return;
celebrated.current = true;
playSound('completion');
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
if (isDailyChallenge && dailyChallengeDate) {
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
}
}, [status, isDailyChallenge, dailyChallengeDate, dailyChallengeDifficulty]);
const handlePlayAgain = () => {
resetGame();
@@ -62,13 +76,13 @@ export default function GameCompletionModal() {
};
const handleViewChain = () => {
setShowModal(false);
setDismissed(true);
};
return (
<>
{status === 'completed' && <CelebrationOverlay />}
<Dialog open={showModal} onOpenChange={(open) => !open && setShowModal(false)}>
<Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}>
<DialogContent
className="sm:max-w-md"
showCloseButton={false}
+51 -39
View File
@@ -28,8 +28,6 @@ export default function SearchAutocomplete({
}: SearchAutocompleteProps) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [movieResults, setMovieResults] = useState<TmdbMovieResult[]>([]);
const [personResults, setPersonResults] = useState<TmdbPersonResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
@@ -38,67 +36,81 @@ export default function SearchAutocomplete({
const inputRef = useRef<HTMLInputElement>(null);
const listId = `search-listbox-${mode}`;
const results = mode === 'actor' ? personResults : movieResults;
// A query too short to search is a derived condition. Clearing results in an
// effect meant an extra committed render on every keystroke below the
// threshold, and left the stored results and the displayed ones out of step.
const isSearchable = debouncedQuery.trim().length >= 2;
const results = isSearchable
? mode === 'actor'
? personResults
: movieResults
: [];
// Loading and error are derived from which search has settled, so neither is
// set synchronously inside the effect.
const searchKey = `${mode}:${debouncedQuery}`;
const [settled, setSettled] = useState<{
key: string;
error: string | null;
} | null>(null);
const isLoading = isSearchable && settled?.key !== searchKey;
const searchError = settled?.key === searchKey ? settled.error : null;
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2) {
setMovieResults([]);
setPersonResults([]);
setIsOpen(false);
return;
}
if (!isSearchable) return;
let cancelled = false;
setIsLoading(true);
setSearchError(null);
const doSearch = async () => {
void (async () => {
let error: string | null = null;
try {
if (mode === 'actor') {
const data = await searchPersons(debouncedQuery);
if (!cancelled) {
setPersonResults(
data.results.filter(
(p) => p.known_for_department === 'Acting',
),
);
setMovieResults([]);
setIsOpen(true);
setActiveIndex(-1);
}
if (cancelled) return;
setPersonResults(
data.results.filter((p) => p.known_for_department === 'Acting'),
);
setMovieResults([]);
setIsOpen(true);
setActiveIndex(-1);
} else {
const data = await searchMovies(debouncedQuery);
if (!cancelled) {
setMovieResults(data.results);
setPersonResults([]);
setIsOpen(true);
setActiveIndex(-1);
}
if (cancelled) return;
setMovieResults(data.results);
setPersonResults([]);
setIsOpen(true);
setActiveIndex(-1);
}
} catch {
if (!cancelled) {
setSearchError('Search failed. Please try again.');
setIsOpen(true);
}
if (cancelled) return;
error = 'Search failed. Please try again.';
setIsOpen(true);
} finally {
if (!cancelled) setIsLoading(false);
if (!cancelled) setSettled({ key: searchKey, error });
}
};
})();
doSearch();
return () => {
cancelled = true;
};
}, [debouncedQuery, mode]);
}, [debouncedQuery, mode, isSearchable, searchKey]);
// Reset when mode changes
useEffect(() => {
// Reset when the mode flips between actor and movie. Adjusting state during
// render is React's documented pattern for reacting to a changed prop; doing
// it in an effect costs an extra committed render, and remounting via a
// `key` would drop focus in the middle of a chain.
const [prevMode, setPrevMode] = useState(mode);
if (mode !== prevMode) {
setPrevMode(mode);
setQuery('');
setMovieResults([]);
setPersonResults([]);
setIsOpen(false);
setActiveIndex(-1);
// Auto-focus on mode change
}
// Focus is a DOM side effect, so it stays in an effect.
useEffect(() => {
inputRef.current?.focus();
}, [mode]);
+13 -7
View File
@@ -7,15 +7,21 @@ export default function ValidationFeedback() {
const validationError = useGameStore((s) => s.validationError);
const chain = useGameStore((s) => s.chain);
const [showCheck, setShowCheck] = useState(false);
const [flashedLength, setFlashedLength] = useState(0);
// Flash a green check when the chain grows (successful validation). Adjusting
// state during render is React's documented pattern for reacting to a changed
// value; doing it in an effect costs an extra committed render each link.
if (chain.length > 1 && chain.length !== flashedLength) {
setFlashedLength(chain.length);
setShowCheck(true);
}
// Flash a green check when chain grows (successful validation)
useEffect(() => {
if (chain.length > 1) {
setShowCheck(true);
const timer = setTimeout(() => setShowCheck(false), 600);
return () => clearTimeout(timer);
}
}, [chain.length]);
if (!showCheck) return;
const timer = setTimeout(() => setShowCheck(false), 600);
return () => clearTimeout(timer);
}, [showCheck]);
if (isValidating) {
return (
+1 -2
View File
@@ -49,5 +49,4 @@ function Badge({
})
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants }
export { Badge }
+38
View File
@@ -0,0 +1,38 @@
import { cva } from "class-variance-authority"
export const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
+4 -40
View File
@@ -1,45 +1,10 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { buttonVariants } from "./button-variants"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
@@ -55,5 +20,4 @@ function Button({
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }
export { Button }
+1 -20
View File
@@ -1,24 +1,5 @@
import { cn } from '@/lib/utils';
type Difficulty = 'easy' | 'medium' | 'hard';
const DIFFICULTY_STYLES: Record<Difficulty, string> = {
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
};
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = {
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
};
// eslint-disable-next-line react-refresh/only-export-components
export function getDifficultyButtonClass(difficulty: Difficulty, isActive: boolean): string {
if (!isActive) return '';
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
}
import { DIFFICULTY_STYLES, type Difficulty } from './difficulty-styles';
export function DifficultyBadge({
difficulty,
+21
View File
@@ -0,0 +1,21 @@
export type Difficulty = 'easy' | 'medium' | 'hard';
export const DIFFICULTY_STYLES: Record<Difficulty, string> = {
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
};
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = {
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
};
export function getDifficultyButtonClass(
difficulty: Difficulty,
isActive: boolean,
): string {
if (!isActive) return '';
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
}
@@ -45,6 +45,11 @@ export default function VersusCompletionModal() {
<Loader2 className="h-4 w-4 animate-spin" />
<span>Waiting for opponent to finish...</span>
</div>
{/* An opponent who never finishes must not strand this player in
a modal with no controls. */}
<Button variant="outline" size="sm" onClick={handleBackToVersus}>
Back to Versus
</Button>
</div>
</>
) : matchResult ? (
+19 -18
View File
@@ -1,3 +1,4 @@
import { toast } from 'sonner';
import { useCallback } from 'react';
import { useGameStore } from '@/stores/game-store';
import { validateLink } from '@/api/games';
@@ -16,13 +17,14 @@ import type {
* When a backend session exists, validation is delegated to the server.
*/
export function useChainValidation() {
const {
chain,
addActorToChain,
addMovieToChain,
setValidating,
setValidationError,
} = useGameStore();
// Selected individually rather than destructured off the whole store: the
// unselected form re-rendered every consumer on any state change, including
// each keystroke-driven isValidating flip. These are stable action refs, so
// the callbacks below now keep a stable identity too.
const addActorToChain = useGameStore((s) => s.addActorToChain);
const addMovieToChain = useGameStore((s) => s.addMovieToChain);
const setValidating = useGameStore((s) => s.setValidating);
const setValidationError = useGameStore((s) => s.setValidationError);
const validateAndAddActor = useCallback(
async (person: TmdbPersonResult) => {
@@ -35,11 +37,12 @@ export function useChainValidation() {
return;
}
// Quick client-side duplicate check (fast feedback)
// Duplicates are allowed by design and charged at scoring time, so this
// is a heads-up rather than a rejection.
if (currentChain.some((link) => link.type === 'actor' && link.id === person.id)) {
setValidationError(`${person.name} is already in your chain.`);
playSound('invalid');
return;
toast.warning(`${person.name} is already in your chain`, {
description: 'Reusing a link costs you points.',
});
}
setValidating(true);
@@ -86,7 +89,7 @@ export function useChainValidation() {
setValidating(false);
}
},
[chain, addActorToChain, setValidating, setValidationError],
[addActorToChain, setValidating, setValidationError],
);
const validateAndAddMovie = useCallback(
@@ -103,16 +106,14 @@ export function useChainValidation() {
// Quick client-side duplicate / loop-closure checks
const isMovieA = movie.id === movieA?.id;
const isMovieB = movie.id === movieB?.id;
if (
!isMovieA &&
!isMovieB &&
currentChain.some((link) => link.type === 'movie' && link.id === movie.id)
) {
setValidationError(`"${movie.title}" is already in your chain.`);
playSound('invalid');
return;
toast.warning(`"${movie.title}" is already in your chain`, {
description: 'Reusing a link costs you points.',
});
}
if (isMovieA && !passedMovieB) {
@@ -169,7 +170,7 @@ export function useChainValidation() {
setValidating(false);
}
},
[chain, addMovieToChain, setValidating, setValidationError],
[addMovieToChain, setValidating, setValidationError],
);
return { validateAndAddActor, validateAndAddMovie };
+30 -20
View File
@@ -1,19 +1,21 @@
import { useState, useCallback } from 'react';
import { useGameStore } from '@/stores/game-store';
import { getMovieCredits } from '@/api/movies';
import { useHint as requestHint } from '@/api/games';
import type { TmdbCastMember } from '@/types';
const MAX_HINTS = 3;
const HINTS_PER_REQUEST = 3;
export function useHints() {
const [hints, setHints] = useState<TmdbCastMember[]>([]);
const [hintsForChainLength, setHintsForChainLength] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chain = useGameStore((s) => s.chain);
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
const hintsUsed = useGameStore((s) => s.hintsUsed);
const incrementHintsUsed = useGameStore((s) => s.incrementHintsUsed);
const setHintsUsed = useGameStore((s) => s.setHintsUsed);
const sessionId = useGameStore((s) => s.sessionId);
const status = useGameStore((s) => s.status);
// Can only hint when looking for an actor after a movie
@@ -23,39 +25,47 @@ export function useHints() {
currentSearchMode === 'actor' &&
lastLink?.type === 'movie' &&
hintsUsed < MAX_HINTS &&
!!sessionId &&
!isLoading;
const getHint = useCallback(async () => {
if (!canHint || lastLink?.type !== 'movie') return;
if (!canHint || !sessionId) return;
setIsLoading(true);
setError(null);
try {
const credits = await getMovieCredits(lastLink.id);
// Filter out actors already in the chain
const chainActorIds = new Set(
chain.filter((l) => l.type === 'actor').map((l) => l.id),
// The server picks the actors and records the hint. Doing this in the
// browser meant the hint count - and so the hint penalty - was whatever
// the client chose to report at completion time.
const result = await requestHint(sessionId);
setHints(
result.actors.map((actor) => ({
id: actor.id,
name: actor.name,
profile_path: actor.profilePath,
popularity: actor.popularity,
})) as TmdbCastMember[],
);
const available = credits.cast.filter(
(member) => !chainActorIds.has(member.id),
);
// Pick random actors from available cast
const shuffled = [...available].sort(() => Math.random() - 0.5);
setHints(shuffled.slice(0, HINTS_PER_REQUEST));
incrementHintsUsed();
setHintsUsed(result.hintsUsed);
setHintsForChainLength(chain.length);
} catch {
// silently fail
setError('Could not fetch a hint. Please try again.');
} finally {
setIsLoading(false);
}
}, [canHint, lastLink, chain, incrementHintsUsed]);
}, [canHint, sessionId, setHintsUsed, chain.length]);
const clearHints = useCallback(() => setHints([]), []);
// Hints belong to the movie they were fetched for. Deriving visibility means
// they disappear as soon as the chain moves on, without an effect that
// clears state and forces a second render.
const visibleHints = hintsForChainLength === chain.length ? hints : [];
return {
hints,
hints: visibleHints,
isLoading,
error,
getHint,
clearHints,
canHint,
+14 -7
View File
@@ -15,18 +15,25 @@ function formatTime(seconds: number): string {
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));
}
export function useTimer(startTime: number | null, isRunning: boolean) {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
// Seeded lazily so the first render is already correct; the interval then
// recomputes from Date.now() each tick, so it cannot drift.
const [elapsedSeconds, setElapsedSeconds] = useState(() =>
elapsedFrom(startTime),
);
useEffect(() => {
if (!startTime || !isRunning) return;
// Set initial value
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
const interval = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
const interval = setInterval(
() => setElapsedSeconds(elapsedFrom(startTime)),
1000,
);
return () => clearInterval(interval);
}, [startTime, isRunning]);
+18 -7
View File
@@ -399,19 +399,30 @@ function GenerateChallengeSection() {
function UsersSection() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loadedPage, setLoadedPage] = useState<number | null>(null);
// Derived rather than a flag flipped inside the effect: a page change puts
// the list back into its loading state on the same render that requests it.
const loading = loadedPage !== page;
useEffect(() => {
setLoading(true);
listUsers(page, 20)
.then((res) => {
let cancelled = false;
void (async () => {
try {
const res = await listUsers(page, 20);
if (cancelled) return;
setUsers(res.data);
setTotalPages(res.totalPages);
})
.catch(() => setUsers([]))
.finally(() => setLoading(false));
} catch {
if (!cancelled) setUsers([]);
} finally {
if (!cancelled) setLoadedPage(page);
}
})();
return () => {
cancelled = true;
};
}, [page]);
return (
+18 -6
View File
@@ -29,16 +29,28 @@ export default function AsyncMatchLeaderboard() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const [data, setData] = useState<AsyncLeaderboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadedMatchId, setLoadedMatchId] = useState<string | null>(null);
const loading = loadedMatchId !== (matchId ?? null);
useEffect(() => {
if (!matchId) return;
setLoading(true);
getAsyncLeaderboard(matchId)
.then(setData)
.catch((err) => setError(err?.response?.data?.message ?? 'Failed to load'))
.finally(() => setLoading(false));
let cancelled = false;
void (async () => {
try {
const res = await getAsyncLeaderboard(matchId);
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');
} finally {
if (!cancelled) setLoadedMatchId(matchId);
}
})();
return () => {
cancelled = true;
};
}, [matchId]);
if (loading) {
+44 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useGameStore } from '@/stores/game-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -39,6 +39,7 @@ export default function AsyncVersusGame() {
const movieB = useGameStore((s) => s.movieB);
const isValidating = useGameStore((s) => s.isValidating);
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);
@@ -48,7 +49,9 @@ export default function AsyncVersusGame() {
const [mode, setMode] = useState<AsyncMode>('creator');
const [targetChainLength, setTargetChainLength] = useState<number | null>(null);
const [showCompletion, setShowCompletion] = useState(false);
const [submitting, setSubmitting] = useState(false);
// A ref, not state: this only guards re-entry, and setting state here made
// the effect re-run itself.
const submittingRef = useRef(false);
const [creatorScore, setCreatorScore] = useState<{ totalScore: number } | null>(null);
const [myRank, setMyRank] = useState<number | undefined>();
const [error, setError] = useState<string | null>(null);
@@ -77,25 +80,35 @@ export default function AsyncVersusGame() {
return;
}
setMode('creator');
await startGameFromVersus(
const started = await startGameFromVersus(
{
movieA: { id: match.movieAId, title: match.movieATitle! },
movieB: { id: match.movieBId, title: match.movieBTitle! },
},
Date.now(),
);
if (started === 'superseded') return;
if (started === 'failed') {
setError('Could not start this match. Please try again.');
return;
}
} else {
// Challenger mode
const result = await startAsyncAttempt(matchId!);
setMode('challenger');
setTargetChainLength(result.chainLength);
await startGameFromVersus(
const started = await startGameFromVersus(
{
movieA: { id: result.movieAId, title: result.movieATitle },
movieB: { id: result.movieBId, title: result.movieBTitle },
},
Date.now(),
);
if (started === 'superseded') return;
if (started === 'failed') {
setError('Could not start this match. Please try again.');
return;
}
}
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
@@ -111,21 +124,31 @@ export default function AsyncVersusGame() {
return () => {
resetGame();
};
}, [matchId, user, authLoading, navigate]);
}, [matchId, user, authLoading, navigate, resetGame, startGameFromVersus]);
// Handle game completion
useEffect(() => {
if (status !== 'completed' || !score || !matchId || submitting || showCompletion) return;
// `error` participates so a failed submission is not retried on every
// subsequent render — it previously fired ~20 identical failing requests.
if (
status !== 'completed' ||
!score ||
!matchId ||
!sessionId ||
submittingRef.current ||
showCompletion ||
error
)
return;
setSubmitting(true);
const chainLength = chain.length;
submittingRef.current = true;
(async () => {
try {
if (mode === 'creator') {
await submitCreatorScore(matchId, score, chainLength);
await submitCreatorScore(matchId, sessionId);
} else {
await submitAsyncAttempt(matchId, score, chainLength);
await submitAsyncAttempt(matchId, sessionId);
// Fetch leaderboard to get rank and creator score
try {
const lb = await getAsyncLeaderboard(matchId);
@@ -143,10 +166,19 @@ export default function AsyncVersusGame() {
} catch {
setError('Failed to submit score');
} finally {
setSubmitting(false);
submittingRef.current = false;
}
})();
}, [status, score, matchId, mode, submitting, showCompletion]);
}, [
status,
score,
matchId,
mode,
showCompletion,
sessionId,
user?.id,
error,
]);
if (!user) return null;
+8 -4
View File
@@ -62,10 +62,6 @@ export default function Chat() {
return;
}
if (friends.length === 0) {
fetchFriends();
}
if (friendId) {
connect();
openConversation(friendId);
@@ -77,6 +73,14 @@ export default function Chat() {
};
}, [user, authLoading, friendId, navigate, connect, disconnect, openConversation, clearChat]);
// Kept separate from the socket lifecycle above: folding friends.length into
// that effect's deps would tear down and rebuild the socket on every change
// to the friend list.
useEffect(() => {
if (authLoading || !user) return;
if (friends.length === 0) fetchFriends();
}, [authLoading, user, friends.length, fetchFriends]);
// Scroll to bottom on new messages if user hasn't scrolled up
useEffect(() => {
if (isAtBottom.current) {
+1 -1
View File
@@ -6,7 +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 { getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
import { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
import { Loader2, Shuffle, Filter } from 'lucide-react';
import { cn } from '@/lib/utils';
+21 -15
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -32,7 +32,9 @@ export default function GameNight() {
const user = useAuthStore((s) => s.user);
const [tab, setTab] = useState<'browse' | 'create'>('browse');
const [openRooms, setOpenRooms] = useState<GameNightRoom[]>([]);
const [loadingRooms, setLoadingRooms] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [settledToken, setSettledToken] = useState(-1);
const loadingRooms = settledToken !== refreshToken;
const [creating, setCreating] = useState(false);
// Create form state
@@ -49,20 +51,24 @@ export default function GameNight() {
const [joinPassword, setJoinPassword] = useState('');
const [joinError, setJoinError] = useState('');
const fetchRooms = async () => {
setLoadingRooms(true);
try {
const rooms = await getOpenRooms();
setOpenRooms(rooms);
} catch {
// ignore
}
setLoadingRooms(false);
};
const refreshRooms = useCallback(() => setRefreshToken((t) => t + 1), []);
useEffect(() => {
fetchRooms();
}, []);
let cancelled = false;
void (async () => {
try {
const rooms = await getOpenRooms();
if (!cancelled) setOpenRooms(rooms);
} catch {
// Leave the list as-is; the empty state below covers it.
} finally {
if (!cancelled) setSettledToken(refreshToken);
}
})();
return () => {
cancelled = true;
};
}, [refreshToken]);
const handleSelectMovieA = (movie: TmdbMovieResult) => {
setMovieA({
@@ -194,7 +200,7 @@ export default function GameNight() {
<Button
variant="ghost"
size="sm"
onClick={fetchRooms}
onClick={refreshRooms}
disabled={loadingRooms}
>
<RefreshCw
+7 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import PageLayout from '@/components/layout/PageLayout';
@@ -44,10 +45,15 @@ export default function GameNightLobby() {
movieB: MoviePair['movieB'];
startTime: number;
}) => {
await startGameFromVersus(
const result = await startGameFromVersus(
{ movieA: data.movieA, movieB: data.movieB },
data.startTime,
);
if (result === 'superseded') return;
if (result === 'failed') {
toast.error('Could not start the game. Please try again.');
return;
}
navigate('/game-night/play');
},
[startGameFromVersus, navigate],
+4 -4
View File
@@ -30,6 +30,7 @@ export default function GameNightPlay() {
const sendChainUpdate = useGameNightStore((s) => s.sendChainUpdate);
const sendPlayerComplete = useGameNightStore((s) => s.sendPlayerComplete);
const sessionId = useGameStore((s) => s.sessionId);
const sendPlayerForfeit = useGameNightStore((s) => s.sendPlayerForfeit);
const isSpectator = useGameNightStore((s) => s.isSpectator);
const lobbyState = useGameNightStore((s) => s.lobbyState);
@@ -44,11 +45,10 @@ export default function GameNightPlay() {
// Send completion when game completes
useEffect(() => {
if (status === 'completed' && score && !isSpectator) {
const linkCount = Math.floor((chain.length - 1) / 2);
sendPlayerComplete(score, linkCount);
if (status === 'completed' && score && sessionId && !isSpectator) {
sendPlayerComplete(sessionId);
}
}, [status, score, chain.length, sendPlayerComplete, isSpectator]);
}, [status, score, sessionId, sendPlayerComplete, isSpectator]);
useEffect(() => {
if (status === 'idle') {
+17 -8
View File
@@ -20,17 +20,26 @@ export default function GameNightResults() {
const [results, setResults] = useState<GameNightResultsType | null>(
storeResults,
);
const [loading, setLoading] = useState(!storeResults);
const [tab, setTab] = useState<RankingTab>('score');
const [fetchSettled, setFetchSettled] = useState(false);
const loading = !results && !fetchSettled;
useEffect(() => {
if (!results && roomId) {
setLoading(true);
getRoomResults(roomId)
.then(setResults)
.catch(() => {})
.finally(() => setLoading(false));
}
if (results || !roomId) return;
let cancelled = false;
void (async () => {
try {
const res = await getRoomResults(roomId);
if (!cancelled) setResults(res);
} catch {
// Leave results null — the empty state below covers it.
} finally {
if (!cancelled) setFetchSettled(true);
}
})();
return () => {
cancelled = true;
};
}, [roomId, results]);
const handlePlayAgain = () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useNavigate, Link } from 'react-router';
import { Button, buttonVariants } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';
import { useGameStore } from '@/stores/game-store';
import { discoverMovies } from '@/api/movies';
import PageLayout from '@/components/layout/PageLayout';
+23 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
@@ -44,25 +44,32 @@ export default function Leaderboard() {
const [period, setPeriod] = useState<Period>('daily');
const [difficulty, setDifficulty] = useState<DifficultyFilter>('all');
const [data, setData] = useState<LeaderboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [settledKey, setSettledKey] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const diffParam = difficulty === 'all' ? undefined : difficulty;
const result = await getLeaderboard(period, page, 20, diffParam);
setData(result);
} catch {
setData(null);
} finally {
setLoading(false);
}
}, [period, page, difficulty]);
// Derived rather than a flag set inside the effect: changing period, page or
// difficulty puts the table back into its loading state on the same render
// that requests it, instead of costing an extra committed render.
const requestKey = `${period}:${page}:${difficulty}`;
const loading = settledKey !== requestKey;
useEffect(() => {
fetchData();
}, [fetchData]);
let cancelled = false;
void (async () => {
try {
const diffParam = difficulty === 'all' ? undefined : difficulty;
const result = await getLeaderboard(period, page, 20, diffParam);
if (!cancelled) setData(result);
} catch {
if (!cancelled) setData(null);
} finally {
if (!cancelled) setSettledKey(requestKey);
}
})();
return () => {
cancelled = true;
};
}, [period, page, difficulty, requestKey]);
const handlePeriodChange = (p: Period) => {
setPeriod(p);
+46 -21
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router';
import { Button, buttonVariants } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
@@ -22,15 +23,23 @@ function formatTime(seconds: number) {
export default function Profile() {
const user = useAuthStore((s) => s.user);
// Depend on the id, not the user object: loadUser() rebuilds that object
// literal on every call, which would otherwise re-trigger the fetches below.
const userId = user?.id;
const authLoading = useAuthStore((s) => s.isLoading);
const logout = useAuthStore((s) => s.logout);
const loadUser = useAuthStore((s) => s.loadUser);
const navigate = useNavigate();
const [stats, setStats] = useState<UserStats | null>(null);
const [gameHistory, setGameHistory] = useState<GameHistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [statsMode, setStatsMode] = useState<'all' | 'daily'>('all');
const [statsLoading, setStatsLoading] = useState(false);
const [historySettled, setHistorySettled] = useState(false);
const [statsSettledMode, setStatsSettledMode] = useState<string | null>(null);
// The page skeleton covers the first load of both requests; the dimmed-tile
// state only applies to a later mode switch.
const loading = !historySettled || statsSettledMode === null;
const statsLoading =
statsSettledMode !== null && statsSettledMode !== statsMode;
const [editing, setEditing] = useState(false);
const [displayName, setDisplayName] = useState('');
const [changingPassword, setChangingPassword] = useState(false);
@@ -40,31 +49,47 @@ export default function Profile() {
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
// Game history is independent of statsMode, so it is fetched once.
useEffect(() => {
if (authLoading) return;
if (!user) {
if (!userId) {
navigate('/login');
return;
}
Promise.all([
getUserStats(statsMode).catch(() => null),
getGameHistory(1, 10).catch(() => null),
])
.then(([s, h]) => {
setStats(s);
if (h) setGameHistory(h.data);
})
.finally(() => setLoading(false));
}, [user, authLoading, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
let cancelled = false;
void (async () => {
try {
const h = await getGameHistory(1, 10);
if (!cancelled && h) setGameHistory(h.data);
} catch {
// Leave history empty; the empty state below covers it.
} finally {
if (!cancelled) setHistorySettled(true);
}
})();
return () => {
cancelled = true;
};
}, [userId, authLoading, navigate]);
// Stats re-fetch on first load and on every mode switch.
useEffect(() => {
if (!user || loading) return;
setStatsLoading(true);
getUserStats(statsMode)
.then((s) => setStats(s))
.catch(() => {})
.finally(() => setStatsLoading(false));
}, [statsMode]); // eslint-disable-line react-hooks/exhaustive-deps
if (authLoading || !userId) return;
let cancelled = false;
void (async () => {
try {
const s = await getUserStats(statsMode);
if (!cancelled) setStats(s);
} catch {
if (!cancelled) setStats(null);
} finally {
if (!cancelled) setStatsSettledMode(statsMode);
}
})();
return () => {
cancelled = true;
};
}, [userId, authLoading, statsMode]);
const handleLogout = () => {
logout();
+38 -23
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DifficultyBadge, getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
import { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import {
@@ -57,7 +58,8 @@ export default function Versus() {
const navigate = useNavigate();
const [matches, setMatches] = useState<VersusMatch[]>([]);
const [myMatches, setMyMatches] = useState<VersusMatch[]>([]);
const [loading, setLoading] = useState(true);
const [refreshToken, setRefreshToken] = useState(0);
const [settledKey, setSettledKey] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [joining, setJoining] = useState<string | null>(null);
const [cancelling, setCancelling] = useState<string | null>(null);
@@ -73,22 +75,15 @@ export default function Versus() {
const [joinPassword, setJoinPassword] = useState('');
const [joinError, setJoinError] = useState('');
const fetchMatches = async () => {
setLoading(true);
try {
const [open, mine] = await Promise.all([
getWaitingMatches(user?.id, filterMode === 'all' ? undefined : filterMode),
getMyWaitingMatches(),
]);
setMatches(open);
setMyMatches(mine);
} catch {
setMatches([]);
setMyMatches([]);
} finally {
setLoading(false);
}
};
const userId = user?.id;
// One key covers both triggers: changing the filter and pressing refresh each
// produce a new key, so `loading` flips true on the same render as the request.
const requestKey = `${filterMode}:${refreshToken}`;
const loading = settledKey !== requestKey;
// A manual refresh bumps this token; the effect below owns the actual fetch,
// so nothing sets loading state synchronously from inside an effect.
const refreshMatches = useCallback(() => setRefreshToken((t) => t + 1), []);
const handleCancel = async (matchId: string) => {
setCancelling(matchId);
@@ -104,12 +99,32 @@ export default function Versus() {
useEffect(() => {
if (authLoading) return;
if (!user) {
if (!userId) {
navigate('/login');
return;
}
fetchMatches();
}, [user, authLoading, navigate, filterMode]);
let cancelled = false;
void (async () => {
try {
const [open, mine] = await Promise.all([
getWaitingMatches(userId, filterMode === 'all' ? undefined : filterMode),
getMyWaitingMatches(),
]);
if (cancelled) return;
setMatches(open);
setMyMatches(mine);
} catch {
if (cancelled) return;
setMatches([]);
setMyMatches([]);
} finally {
if (!cancelled) setSettledKey(requestKey);
}
})();
return () => {
cancelled = true;
};
}, [userId, authLoading, navigate, filterMode, requestKey]);
const handleCreate = async () => {
setCreating(true);
@@ -365,7 +380,7 @@ export default function Versus() {
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">Open Matches</h2>
</div>
<Button variant="ghost" size="sm" onClick={fetchMatches}>
<Button variant="ghost" size="sm" onClick={refreshMatches}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
+4 -3
View File
@@ -29,6 +29,7 @@ export default function VersusGame() {
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
const sendChainUpdate = useVersusStore((s) => s.sendChainUpdate);
const sendMatchComplete = useVersusStore((s) => s.sendMatchComplete);
const sessionId = useGameStore((s) => s.sessionId);
// Broadcast chain updates to opponent
useEffect(() => {
@@ -39,10 +40,10 @@ export default function VersusGame() {
// Send match completion when game completes
useEffect(() => {
if (status === 'completed' && score) {
sendMatchComplete(score);
if (status === 'completed' && score && sessionId) {
sendMatchComplete(sessionId, score);
}
}, [status, score, sendMatchComplete]);
}, [status, score, sessionId, sendMatchComplete]);
useEffect(() => {
if (status === 'idle') {
+10 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import PageLayout from '@/components/layout/PageLayout';
import { useAuthStore } from '@/stores/auth-store';
@@ -32,10 +33,18 @@ export default function VersusLobby() {
const handleGameStart = useCallback(
async (data: { movieA: MoviePair['movieA']; movieB: MoviePair['movieB']; startTime: number }) => {
await startGameFromVersus(
const result = await startGameFromVersus(
{ movieA: data.movieA, movieB: data.movieB },
data.startTime,
);
// A newer start is already in charge; saying anything here would be wrong.
if (result === 'superseded') return;
// Navigating on a failed start used to bounce straight back here via
// VersusGame's idle guard, with nothing explaining why.
if (result === 'failed') {
toast.error('Could not start the match. Please try again.');
return;
}
navigate('/versus/game');
},
[startGameFromVersus, navigate],
+3 -3
View File
@@ -32,7 +32,7 @@ interface GameNightState {
toggleReady: () => void;
startCountdown: () => void;
sendChainUpdate: (chainLength: number) => void;
sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => void;
sendPlayerComplete: (sessionId: string) => void;
sendPlayerForfeit: () => void;
leaveRoom: () => void;
reset: () => void;
@@ -219,10 +219,10 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
socket.emit('chain-update', { roomId, chainLength });
},
sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => {
sendPlayerComplete: (sessionId: string) => {
const { socket, roomId } = get();
if (!socket || !roomId) return;
socket.emit('player-complete', { roomId, score, chainLength });
socket.emit('player-complete', { roomId, sessionId });
},
sendPlayerForfeit: () => {
+151 -45
View File
@@ -30,13 +30,14 @@ interface GameState {
dailyChallengeDifficulty: string | null;
startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise<void>;
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<void>;
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<StartResult>;
addActorToChain: (actor: ActorChainLink) => void;
addMovieToChain: (movie: MovieChainLink) => void;
undoLastLink: () => void;
setValidating: (isValidating: boolean) => void;
setValidationError: (error: string | null) => void;
incrementHintsUsed: () => void;
/** Set from the server's response; the count is no longer tracked locally. */
setHintsUsed: (count: number) => void;
resetGame: () => void;
}
@@ -59,10 +60,104 @@ const initialState = {
dailyChallengeDifficulty: null as string | null,
};
/**
* Outcome of starting a multiplayer game.
* - 'started' play can begin
* - 'superseded' a newer game replaced this one mid-start (React StrictMode
* remounts an effect, and its cleanup resets the store) — the
* caller must stay silent, because another start is in charge
* - 'failed' genuinely could not start; the caller should surface it
*/
export type StartResult = 'started' | 'superseded' | 'failed';
interface SessionArgs {
movieAId: number;
movieATitle: string;
movieBId: number;
movieBTitle: string;
movieAReleaseDate: string | null;
movieBReleaseDate: string | null;
dailyChallengeId?: string;
}
// Every started game takes the next generation number. Anything async that was
// kicked off for an older generation must not write back into the store — that
// is how a slow session-creation call used to land its id in the *next* game.
let gameGeneration = 0;
let sessionCreation: Promise<string | null> = Promise.resolve(null);
async function createSession(
generation: number,
args: SessionArgs,
): Promise<string | null> {
try {
const { createGameSession } = await import('@/api/games');
const session = await createGameSession(
args.movieAId,
args.movieATitle,
args.movieBId,
args.movieBTitle,
args.dailyChallengeId,
args.movieAReleaseDate,
args.movieBReleaseDate,
);
if (generation !== gameGeneration) return null;
useGameStore.setState({ sessionId: session.id });
return session.id;
} catch (err) {
console.error('[GameStore] Failed to create game session:', err);
return null;
}
}
// Resolves the final score once the loop closes. Split out of addMovieToChain
// so it can await a session that may still be in flight: a fast player can
// close the loop before creation returns, which previously left score null
// forever with no error path.
async function finalizeGame(generation: number): Promise<void> {
const stillCurrent = () => generation === gameGeneration;
try {
const sessionId =
useGameStore.getState().sessionId ?? (await sessionCreation);
if (!stillCurrent()) return;
if (!sessionId) {
useGameStore.setState({
validationError:
'Your loop is complete, but the server never registered this game, so it could not be scored.',
});
return;
}
const { completeGameSession } = await import('@/api/games');
const backendScore = await completeGameSession(sessionId);
if (!stillCurrent()) return;
// Achievements are a completion side effect, not part of the score. Storing
// them on `score` meant versus/game-night posted them straight back to the
// API, where forbidNonWhitelisted rejected the whole submission with a 400.
const { newAchievements, ...breakdown } = backendScore;
useGameStore.setState({ score: breakdown });
if (newAchievements?.length) {
for (const achievement of newAchievements) {
toast.success(`Achievement Unlocked: ${achievement.name}`, {
description: achievement.description,
duration: 5000,
});
}
}
} catch {
if (!stillCurrent()) return;
useGameStore.setState({ validationError: 'Failed to calculate score.' });
}
}
export const useGameStore = create<GameState>((set, get) => ({
...initialState,
startGame: async (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => {
const generation = ++gameGeneration;
// Reset if already playing
set({ ...initialState, status: 'playing', isValidating: true, isCustomGame: options?.isCustom ?? false, isDailyChallenge: options?.isDaily ?? false, dailyChallengeDate: options?.dailyDate ?? null, dailyChallengeDifficulty: options?.difficulty ?? null });
@@ -99,22 +194,17 @@ export const useGameStore = create<GameState>((set, get) => ({
isValidating: false,
});
// Fire-and-forget backend session creation
import('@/api/games').then(({ createGameSession }) => {
createGameSession(
pair.movieA.id,
pair.movieA.title,
pair.movieB.id,
pair.movieB.title,
options?.dailyChallengeId,
detailsA.release_date ?? null,
detailsB.release_date ?? null,
)
.then((session) => set({ sessionId: session.id }))
.catch((err) => {
// Backend unavailable — game continues without persistence
console.error('[GameStore] Failed to create game session:', err);
});
// Single-player deliberately keeps session creation off the critical path
// so play starts instantly and still works when the backend is down. The
// promise is retained so completion can await it instead of racing it.
sessionCreation = createSession(generation, {
movieAId: pair.movieA.id,
movieATitle: pair.movieA.title,
movieBId: pair.movieB.id,
movieBTitle: pair.movieB.title,
dailyChallengeId: options?.dailyChallengeId,
movieAReleaseDate: detailsA.release_date ?? null,
movieBReleaseDate: detailsB.release_date ?? null,
});
} catch {
set({
@@ -126,6 +216,7 @@ export const useGameStore = create<GameState>((set, get) => ({
},
startGameFromVersus: async (pair: MoviePair, startTime: number) => {
const generation = ++gameGeneration;
set({ ...initialState, status: 'playing', isValidating: true });
try {
@@ -152,20 +243,50 @@ export const useGameStore = create<GameState>((set, get) => ({
popularity: detailsB.popularity,
};
if (generation !== gameGeneration) return 'superseded';
// Unlike single-player, multiplayer cannot degrade to an unpersisted game:
// no session means no score, and no score means the match never resolves
// for either player. So this is awaited, and failing here is fatal.
sessionCreation = createSession(generation, {
movieAId: pair.movieA.id,
movieATitle: pair.movieA.title,
movieBId: pair.movieB.id,
movieBTitle: pair.movieB.title,
movieAReleaseDate: detailsA.release_date ?? null,
movieBReleaseDate: detailsB.release_date ?? null,
});
const sessionId = await sessionCreation;
if (generation !== gameGeneration) return 'superseded';
if (!sessionId) {
set({
status: 'idle',
isValidating: false,
validationError:
'Could not start the match — the server did not register this game.',
});
return 'failed';
}
set({
movieA: movieALink,
movieB: movieBLink,
chain: [movieALink],
currentSearchMode: 'actor',
startTime,
sessionId,
isValidating: false,
});
return 'started';
} catch {
if (generation !== gameGeneration) return 'superseded';
set({
status: 'idle',
isValidating: false,
validationError: 'Failed to load movie details. Please try again.',
});
return 'failed';
}
},
@@ -181,7 +302,7 @@ export const useGameStore = create<GameState>((set, get) => ({
},
addMovieToChain: (movie: MovieChainLink) => {
const { status, chain, movieA, movieB, passedMovieB, hintsUsed } = get();
const { status, chain, movieA, movieB, passedMovieB } = get();
if (status !== 'playing') return;
const newChain = [...chain, movie];
@@ -206,29 +327,10 @@ export const useGameStore = create<GameState>((set, get) => ({
validationError: null,
});
// Await backend score calculation
const { sessionId } = get();
if (sessionId) {
import('@/api/games').then(({ completeGameSession }) => {
completeGameSession(sessionId, newChain, hintsUsed)
.then((backendScore) => {
set({ score: backendScore });
// Show toast for each newly earned achievement
if (backendScore.newAchievements?.length) {
for (const achievement of backendScore.newAchievements) {
toast.success(`Achievement Unlocked: ${achievement.name}`, {
description: achievement.description,
duration: 5000,
});
}
}
})
.catch(() => {
set({ validationError: 'Failed to calculate score.' });
});
});
}
// Resolve the score out of band. finalizeGame awaits an in-flight session
// and always lands on either a score or a validationError — never on the
// silent null that left completion modals with nothing to render.
void finalizeGame(gameGeneration);
return;
}
@@ -280,8 +382,12 @@ export const useGameStore = create<GameState>((set, get) => ({
setValidationError: (error: string | null) =>
set({ validationError: error }),
incrementHintsUsed: () =>
set((state) => ({ hintsUsed: state.hintsUsed + 1 })),
setHintsUsed: (count: number) => set({ hintsUsed: count }),
resetGame: () => set(initialState),
resetGame: () => {
// Retire the generation so any in-flight session creation or scoring call
// for the abandoned game cannot write back into the fresh state.
gameGeneration++;
set(initialState);
},
}));
+6 -3
View File
@@ -39,7 +39,7 @@ interface VersusState {
joinLobby: (matchId: string) => void;
startCountdown: () => void;
sendChainUpdate: (chainLength: number) => void;
sendMatchComplete: (score: ScoreBreakdown) => void;
sendMatchComplete: (sessionId: string, score: ScoreBreakdown) => void;
leaveLobby: () => void;
reset: () => void;
}
@@ -185,11 +185,14 @@ export const useVersusStore = create<VersusState>((set, get) => ({
socket.emit('chain-update', { matchId, chainLength });
},
sendMatchComplete: (score: ScoreBreakdown) => {
sendMatchComplete: (sessionId: string, score: ScoreBreakdown) => {
const { socket, matchId } = get();
if (!socket || !matchId) return;
// The score is kept locally only so this client can render its own result
// immediately; the server scores from the session and ignores anything we
// would otherwise send.
set({ myScore: score });
socket.emit('match-complete', { matchId, score });
socket.emit('match-complete', { matchId, sessionId });
},
leaveLobby: () => {
+6
View File
@@ -11,6 +11,12 @@ export default defineConfig({
},
},
server: {
// Bind-mounted source on Docker Desktop (Windows/macOS) does not deliver
// inotify events, so Vite never sees host edits and silently serves stale
// modules. Polling is opt-in via the compose file so native dev is unaffected.
watch: process.env.VITE_USE_POLLING
? { usePolling: true, interval: 300 }
: undefined,
proxy: {
'/api': {
target: 'http://localhost:3000',