This commit is contained in:
Louis Mazin 2026-10-05 19:04:11 +02:00
parent b890cdd24f
commit aa3b5e6ae3

View File

@ -8,7 +8,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { timeApi } from '@/api/time'; import { timeApi } from '@/api/time';
import { formatMinutes, personLabel } from '@/components/planning/utils'; import { formatMinutes } from '@/components/planning/utils';
const RACE_DURATION_MS = 8000; const RACE_DURATION_MS = 8000;
@ -30,6 +30,14 @@ const dateLabel = (value) => {
const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const firstNameLabel = (participant) => {
const firstName = String(participant?.first_name || '').trim();
if (firstName) return firstName;
const username = String(participant?.username || '').trim();
return username || 'Utilisateur';
};
export default function MonthlyHoursRace() { export default function MonthlyHoursRace() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [race, setRace] = useState(null); const [race, setRace] = useState(null);
@ -40,6 +48,40 @@ export default function MonthlyHoursRace() {
const frameRef = useRef(null); const frameRef = useRef(null);
const replayFrameRef = useRef(null); const replayFrameRef = useRef(null);
const participants = useMemo(
() => (race?.participants || []).filter((participant) => Number(participant.total_minutes || 0) > 0),
[race],
);
const participantLabels = useMemo(() => {
const firstNameCounts = new Map();
participants.forEach((participant) => {
const firstName = firstNameLabel(participant);
const key = firstName.toLocaleLowerCase('fr-FR');
firstNameCounts.set(key, (firstNameCounts.get(key) || 0) + 1);
});
return new Map(participants.map((participant) => {
const firstName = firstNameLabel(participant);
const key = firstName.toLocaleLowerCase('fr-FR');
let label = firstName;
if ((firstNameCounts.get(key) || 0) > 1) {
const lastName = String(participant.last_name || '').trim();
const initial = lastName.charAt(0).toLocaleUpperCase('fr-FR');
if (initial) label = `${firstName} ${initial}.`;
}
return [String(participant.id), label];
}));
}, [participants]);
const displayName = useCallback(
(participant) => participantLabels.get(String(participant?.id)) || firstNameLabel(participant),
[participantLabels],
);
const loadRace = useCallback(async () => { const loadRace = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@ -90,7 +132,7 @@ export default function MonthlyHoursRace() {
}; };
useEffect(() => { useEffect(() => {
if (!open || !race || loading || !race.participants?.length) return undefined; if (!open || !race || loading || !participants.length) return undefined;
let startTime = null; let startTime = null;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
@ -116,9 +158,8 @@ export default function MonthlyHoursRace() {
if (frameRef.current) cancelAnimationFrame(frameRef.current); if (frameRef.current) cancelAnimationFrame(frameRef.current);
frameRef.current = null; frameRef.current = null;
}; };
}, [open, race, loading, runId]); }, [open, race, loading, runId, participants.length]);
const participants = race?.participants || [];
const daysCount = race?.days_count || participants[0]?.daily_minutes?.length || 0; const daysCount = race?.days_count || participants[0]?.daily_minutes?.length || 0;
const maxFinalMinutes = useMemo( const maxFinalMinutes = useMemo(
() => Math.max(0, ...participants.map((row) => Number(row.total_minutes || 0))), () => Math.max(0, ...participants.map((row) => Number(row.total_minutes || 0))),
@ -126,8 +167,8 @@ export default function MonthlyHoursRace() {
); );
const laneRows = useMemo(() => ( const laneRows = useMemo(() => (
[...participants].sort((a, b) => personLabel(a).localeCompare(personLabel(b), 'fr', { sensitivity: 'base' })) [...participants].sort((a, b) => displayName(a).localeCompare(displayName(b), 'fr', { sensitivity: 'base' }))
), [participants]); ), [participants, displayName]);
const currentState = useMemo(() => { const currentState = useMemo(() => {
if (!daysCount) return { dayIndex: 0, dayFraction: 0, currentDate: race?.start || null }; if (!daysCount) return { dayIndex: 0, dayFraction: 0, currentDate: race?.start || null };
@ -160,6 +201,43 @@ export default function MonthlyHoursRace() {
const podium = [...participants].sort((a, b) => Number(a.rank) - Number(b.rank)).slice(0, 3); const podium = [...participants].sort((a, b) => Number(a.rank) - Number(b.rank)).slice(0, 3);
const winner = podium[0]; const winner = podium[0];
const finalRanking = finished && podium.length > 0 && (
<section className="space-y-4 rounded-xl border bg-muted/20 p-4" aria-label="Podium final">
{winner && (
<div className="relative overflow-hidden rounded-xl border bg-primary/5 px-4 py-5 text-center">
<div className="absolute left-4 top-3 animate-bounce text-xl motion-reduce:animate-none" aria-hidden="true">🎉</div>
<div className="absolute right-4 top-5 animate-pulse text-xl motion-reduce:animate-none" aria-hidden="true">✨</div>
<div className="text-5xl animate-bounce motion-reduce:animate-none" aria-hidden="true">🏆</div>
<p className="mt-2 text-sm text-muted-foreground">Premier du mois</p>
<p className="mt-1 text-xl font-semibold">{displayName(winner)}</p>
<p className="mt-1 font-medium tabular-nums">{formatMinutes(winner.total_minutes)}</p>
</div>
)}
<div className="flex items-center gap-2">
<Trophy className="size-4 text-primary" aria-hidden="true" />
<h3 className="font-semibold">Podium</h3>
</div>
<div className="grid grid-cols-3 items-end gap-2 sm:gap-3">
{[1, 0, 2].map((index) => {
const participant = podium[index];
if (!participant) return <div key={index} />;
const rank = index + 1;
const height = rank === 1 ? 'min-h-36' : rank === 2 ? 'min-h-28' : 'min-h-24';
return (
<div key={participant.id} className={`flex ${height} min-w-0 flex-col justify-end rounded-t-xl border border-b-0 bg-background p-2 text-center sm:p-3`}>
<div className={rank === 1 ? 'animate-bounce text-2xl motion-reduce:animate-none' : 'text-xl'} aria-hidden="true">
{rank === 1 ? '🥇' : rank === 2 ? '🥈' : '🥉'}
</div>
<p className="mt-1 truncate text-sm font-semibold">{displayName(participant)}</p>
<p className="text-xs tabular-nums text-muted-foreground">{formatMinutes(participant.total_minutes)}</p>
</div>
);
})}
</div>
</section>
);
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" onClick={openRace}> <Button type="button" variant="outline" onClick={openRace}>
@ -185,7 +263,7 @@ export default function MonthlyHoursRace() {
<p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p> <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>
)} )}
{!loading && !error && race && (participants.length === 0 || maxFinalMinutes === 0) && ( {!loading && !error && race && participants.length === 0 && (
<p className="py-10 text-center text-sm text-muted-foreground">Aucune heure pointée sur le mois précédent.</p> <p className="py-10 text-center text-sm text-muted-foreground">Aucune heure pointée sur le mois précédent.</p>
)} )}
@ -203,18 +281,21 @@ export default function MonthlyHoursRace() {
</div> </div>
</div> </div>
{finalRanking}
<div className="overflow-x-auto pb-1" aria-label="Course des heures du mois précédent"> <div className="overflow-x-auto pb-1" aria-label="Course des heures du mois précédent">
<div className="flex min-w-max items-end justify-center gap-1.5 px-2 pt-6 sm:gap-2"> <div className="flex min-w-max items-end justify-center gap-1.5 px-2 pt-6 sm:gap-2">
{animatedRows.map((participant) => { {animatedRows.map((participant) => {
const barHeight = maxFinalMinutes > 0 const barHeight = maxFinalMinutes > 0
? clamp((participant.animatedMinutes / maxFinalMinutes) * 100, 0, 100) ? clamp((participant.animatedMinutes / maxFinalMinutes) * 100, 0, 100)
: 0; : 0;
const name = displayName(participant);
return ( return (
<div <div
key={participant.id} key={participant.id}
className="flex w-14 shrink-0 flex-col items-center sm:w-16" className="flex w-14 shrink-0 flex-col items-center sm:w-16"
title={`${personLabel(participant)} — ${formatMinutes(Math.round(participant.animatedMinutes))}`} title={`${name} — ${formatMinutes(Math.round(participant.animatedMinutes))}`}
> >
<div className="mb-1 h-5 text-center text-xs font-medium tabular-nums text-muted-foreground"> <div className="mb-1 h-5 text-center text-xs font-medium tabular-nums text-muted-foreground">
{formatMinutes(Math.round(participant.animatedMinutes))} {formatMinutes(Math.round(participant.animatedMinutes))}
@ -232,8 +313,8 @@ export default function MonthlyHoursRace() {
</div> </div>
<div className="mt-2 w-full min-w-0 text-center"> <div className="mt-2 w-full min-w-0 text-center">
<p className="truncate text-xs font-medium" title={personLabel(participant)}> <p className="truncate text-xs font-medium" title={name}>
{personLabel(participant)} {name}
</p> </p>
</div> </div>
</div> </div>
@ -241,47 +322,10 @@ export default function MonthlyHoursRace() {
})} })}
</div> </div>
</div> </div>
{finished && podium.length > 0 && (
<section className="space-y-4 rounded-xl border bg-muted/20 p-4" aria-label="Podium final">
{winner && Number(winner.total_minutes || 0) > 0 && (
<div className="relative overflow-hidden rounded-xl border bg-primary/5 px-4 py-5 text-center">
<div className="absolute left-4 top-3 animate-bounce text-xl motion-reduce:animate-none" aria-hidden="true">🎉</div>
<div className="absolute right-4 top-5 animate-pulse text-xl motion-reduce:animate-none" aria-hidden="true">✨</div>
<div className="text-5xl animate-bounce motion-reduce:animate-none" aria-hidden="true">🏆</div>
<p className="mt-2 text-sm text-muted-foreground">Premier du mois</p>
<p className="mt-1 text-xl font-semibold">{personLabel(winner)}</p>
<p className="mt-1 font-medium tabular-nums">{formatMinutes(winner.total_minutes)}</p>
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-2 sm:gap-0">
<Trophy className="size-4 text-primary" aria-hidden="true" />
<h3 className="font-semibold">Podium</h3>
</div>
<div className="grid grid-cols-3 items-end gap-2 sm:gap-3">
{[1, 0, 2].map((index) => {
const participant = podium[index];
if (!participant) return <div key={index} />;
const rank = index + 1;
const height = rank === 1 ? 'min-h-36' : rank === 2 ? 'min-h-28' : 'min-h-24';
return (
<div key={participant.id} className={`flex ${height} min-w-0 flex-col justify-end rounded-t-xl border border-b-0 bg-background p-2 text-center sm:p-3`}>
<div className={rank === 1 ? 'animate-bounce text-2xl motion-reduce:animate-none' : 'text-xl'} aria-hidden="true">
{rank === 1 ? '🥇' : rank === 2 ? '🥈' : '🥉'}
</div>
<p className="mt-1 truncate text-sm font-semibold">{personLabel(participant)}</p>
<p className="text-xs tabular-nums text-muted-foreground">{formatMinutes(participant.total_minutes)}</p>
</div>
);
})}
</div>
</section>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 gap-2 sm:gap-0">
{race && participants.length > 0 && maxFinalMinutes > 0 && !loading && !error && ( {race && participants.length > 0 && maxFinalMinutes > 0 && !loading && !error && (
<Button type="button" variant="outline" onClick={replay}> <Button type="button" variant="outline" onClick={replay}>
<RotateCcw className="size-4" aria-hidden="true" /> <RotateCcw className="size-4" aria-hidden="true" />