Files
movieloop-frontend/src/pages/DailyChallenge.tsx
T
TehRiehlDeal 8d0b7f39ee feat(ui): give the app one visual identity as Movie Loop
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".
2026-09-12 22:42:55 -07:00

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>
);
}