Gold is the primary in both themes, Bricolage Grotesque carries the wordmark, titles and the score, and Movie A / Movie B wear the blue and magenta of three-strip Technicolor on their posters instead of colouring whole rows. The loop closes on screen - connectors light, a gold line draws back to Movie A, the row glows, then the result arrives - in place of confetti, on every play mode, scaled to the chain's length. Every page shares one header and hairline lists; copy is sentence case with one verb per flow, and the Home hero shows a real finished loop. Also clamps link counts at zero: an opponent who had not moved yet showed "-1 links".
233 lines
8.4 KiB
TypeScript
233 lines
8.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { useGameStore } from '@/stores/game-store';
|
|
import PageLayout from '@/components/layout/PageLayout';
|
|
import PageHeader from '@/components/layout/PageHeader';
|
|
import MovieMarker from '@/components/game/MovieMarker';
|
|
import { getTodaysChallenges, type DailyChallenge as DailyChallengeType } from '@/api/daily-challenges';
|
|
import { checkDailyCompletion, type DailyCompletionStatus } from '@/api/leaderboards';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { storage } from '@/lib/storage';
|
|
import { getErrorStatus } from '@/lib/error';
|
|
import { releaseYear } from '@/lib/tmdb';
|
|
|
|
type Difficulty = 'easy' | 'medium' | 'hard';
|
|
|
|
export default function DailyChallenge() {
|
|
const [challenges, setChallenges] = useState<DailyChallengeType[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// A day with nothing generated yet is an ordinary state, kept apart from
|
|
// a real failure so it is not dressed as one.
|
|
const [notReady, setNotReady] = useState(false);
|
|
const [completed, setCompleted] = useState<Record<Difficulty, boolean>>({
|
|
easy: false,
|
|
medium: false,
|
|
hard: false,
|
|
});
|
|
const startGame = useGameStore((s) => s.startGame);
|
|
const isValidating = useGameStore((s) => s.isValidating);
|
|
// Identity is all this page needs. Selecting the object would re-render and
|
|
// re-run the effect every time loadUser() rebuilds it.
|
|
const userId = useAuthStore((s) => s.user?.id);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
getTodaysChallenges()
|
|
.then((list) => {
|
|
// A refetch (auth resolving after a direct load) replaces whatever
|
|
// the previous attempt concluded.
|
|
setError(null);
|
|
setNotReady(false);
|
|
setChallenges(list);
|
|
|
|
// Check localStorage for anonymous replay prevention
|
|
const localCompleted: Record<string, boolean> = {};
|
|
for (const c of list) {
|
|
if (storage.isDailyCompleted(c.date, c.difficulty)) {
|
|
localCompleted[c.difficulty] = true;
|
|
}
|
|
}
|
|
setCompleted((prev) => ({ ...prev, ...localCompleted }));
|
|
|
|
// Check server for logged-in users (more reliable)
|
|
if (userId) {
|
|
checkDailyCompletion()
|
|
.then((status: DailyCompletionStatus) => {
|
|
setCompleted((prev) => ({
|
|
easy: prev.easy || status.easy,
|
|
medium: prev.medium || status.medium,
|
|
hard: prev.hard || status.hard,
|
|
}));
|
|
})
|
|
.catch(() => {
|
|
// Fall back to localStorage check only
|
|
});
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
// The API answers 404 when nothing has been generated for the day -
|
|
// a fresh install before the first midnight job, or a job that
|
|
// failed. That is an ordinary state, not an outage, and it used to
|
|
// read as one.
|
|
if (getErrorStatus(err) === 404) {
|
|
setError(null);
|
|
setNotReady(true);
|
|
} else {
|
|
setNotReady(false);
|
|
setError("Failed to load today's challenges.");
|
|
}
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [userId]);
|
|
|
|
const handlePlay = async (challenge: DailyChallengeType) => {
|
|
setError(null);
|
|
const result = await startGame(
|
|
{
|
|
movieA: { id: challenge.movieAId, title: challenge.movieATitle },
|
|
movieB: { id: challenge.movieBId, title: challenge.movieBTitle },
|
|
},
|
|
{ isDaily: true, dailyDate: challenge.date, dailyChallengeId: challenge.id, difficulty: challenge.difficulty },
|
|
);
|
|
// A failed start is reported, not thrown; navigating anyway bounced off
|
|
// /game's idle redirect with the store's message never shown.
|
|
if (result !== 'started') {
|
|
setError(
|
|
useGameStore.getState().validationError ??
|
|
'Could not start the challenge. Please try again.',
|
|
);
|
|
return;
|
|
}
|
|
navigate('/game');
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
timeZone: 'UTC',
|
|
});
|
|
};
|
|
|
|
const getLocalResetTime = () => {
|
|
const now = new Date();
|
|
const nextMidnightUtc = new Date(
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1),
|
|
);
|
|
return nextMidnightUtc.toLocaleTimeString([], {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
timeZoneName: 'short',
|
|
});
|
|
};
|
|
|
|
const firstChallenge = challenges[0];
|
|
const resetNote = `New puzzles at 00:00 UTC, which is ${getLocalResetTime()} for you.`;
|
|
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="Today's challenge"
|
|
description={
|
|
firstChallenge
|
|
? `${formatDate(firstChallenge.date)}. Three puzzles, the same for everyone. ${resetNote}`
|
|
: `Three puzzles a day, the same for everyone. ${resetNote}`
|
|
}
|
|
/>
|
|
|
|
{loading && (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
)}
|
|
|
|
{notReady && (
|
|
<Alert>
|
|
<AlertDescription>
|
|
Today's puzzles aren't ready yet. They're made at 00:00 UTC ({getLocalResetTime()});
|
|
check back then.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{challenges.length > 0 && (
|
|
<ul className="divide-y divide-border border-y border-border">
|
|
{challenges.map((challenge) => {
|
|
const diff = challenge.difficulty as Difficulty;
|
|
const isCompleted = completed[diff];
|
|
|
|
return (
|
|
<li
|
|
key={challenge.id}
|
|
className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 py-4 sm:grid-cols-[7rem_1fr_auto_auto] sm:gap-x-6"
|
|
>
|
|
<DifficultyBadge difficulty={challenge.difficulty} />
|
|
|
|
<div className="col-span-2 flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1 sm:col-span-1">
|
|
<span className="inline-flex items-center gap-1.5 font-medium">
|
|
<MovieMarker target="A" />
|
|
<span className="text-movie-a">{challenge.movieATitle}</span>
|
|
{challenge.movieAReleaseDate && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{releaseYear(challenge.movieAReleaseDate)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">to</span>
|
|
<span className="inline-flex items-center gap-1.5 font-medium">
|
|
<MovieMarker target="B" />
|
|
<span className="text-movie-b">{challenge.movieBTitle}</span>
|
|
{challenge.movieBReleaseDate && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{releaseYear(challenge.movieBReleaseDate)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
<span className="text-sm text-muted-foreground tabular-nums">
|
|
Par {challenge.par}
|
|
</span>
|
|
|
|
<Button
|
|
className="justify-self-end sm:min-w-24"
|
|
variant={isCompleted ? 'outline' : 'default'}
|
|
onClick={() => handlePlay(challenge)}
|
|
disabled={isValidating || isCompleted}
|
|
>
|
|
{isCompleted ? (
|
|
'Played'
|
|
) : isValidating ? (
|
|
<>
|
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
Starting…
|
|
</>
|
|
) : (
|
|
'Play'
|
|
)}
|
|
</Button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</PageLayout>
|
|
);
|
|
}
|