léché
This commit is contained in:
parent
b890cdd24f
commit
aa3b5e6ae3
@ -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" />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user