fix(lint): resolve every eslint warning without suppressions

react-hooks/set-state-in-effect and react-hooks/purity had been demoted
to warnings "so CI passes", and CI ran without --max-warnings 0, so all
22 violations were invisible there. Both rules are back at error.

- Confetti is rolled once per mount instead of during render, where it
  re-rolled on every re-render and made pieces visibly jump.
- Loading flags are derived from a "last settled request" key rather than
  set synchronously inside an effect. That also adds the cancellation
  these fetches were missing, so a resolved request can no longer write
  into a unmounted or superseded view.
- Effect dependency arrays corrected; the two eslint-disable-line
  comments in Profile.tsx are gone, and the split of its two effects
  removes the guard that silently never re-ran.
- GameCompletionModal derives visibility from status instead of mirroring
  it into state, with a ref so the sound and localStorage write fire once.
- ValidationFeedback adjusts state during render, the documented pattern,
  instead of paying an extra committed render per link.
- The three react-refresh suppressions in ui/ are resolved properly by
  extracting the variants into their own modules, so the component files
  export only components. badgeVariants had no consumers and is no longer
  exported.
- Drops a stale eslint-disable directive in sw.js.

The lint script now fails on warnings so this cannot quietly regress.
This commit is contained in:
TehRiehlDeal committed 2026-09-09 19:59:10 -07:00
1 parent 70d53ed37d
commit 470a67607b
21 files changed
+337 -210

No files matched your search

+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',
{
+1 -1
View File
@@ -8,7 +8,7 @@
"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",
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-restricted-globals */
self.addEventListener('push', (event) => {
if (!event.data) return;
+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}
+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];
}
+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) {
+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
+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';
+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>