Généralise Podium et computeRanks au-delà des points

computeRanksBy(items, comparator) remplace la logique de classement
"ex-aequo partagent le rang" en la rendant générique ; computeRanks
devient un simple appel avec le comparateur points desc, sans rien
changer pour l'appelant existant. Podium accepte maintenant un prop
renderValue (+ valueLabel optionnel) à la place du "points/gloires" en
dur, pour pouvoir afficher un classement par temps (Course du Char)
avec le même composant que le classement par points.
This commit is contained in:
Valentin ROBIN
2026-07-27 18:53:54 +02:00
parent 777d9ebaea
commit 9f54b459f3
3 changed files with 32 additions and 17 deletions
+2
View File
@@ -89,6 +89,8 @@ export function LeaderboardView({
<div> <div>
<Podium <Podium
profiles={ranked.filter((p) => p.rank <= 3)} profiles={ranked.filter((p) => p.rank <= 3)}
renderValue={(member) => member.points}
valueLabel="gloires"
isJudge={isJudge} isJudge={isJudge}
onApplyDelta={applyOptimisticDelta} onApplyDelta={applyOptimisticDelta}
/> />
+14 -7
View File
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls"; import { JudgePointControls } from "@/components/judge-point-controls";
import { LaurelWreath } from "@/components/laurel-wreath"; import { LaurelWreath } from "@/components/laurel-wreath";
import { toRoman, type RankedProfile } from "@/lib/ranking"; import { toRoman, type Ranked } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number }; type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null };
const SLOT_STYLES: Record< const SLOT_STYLES: Record<
number, number,
@@ -14,16 +15,20 @@ const SLOT_STYLES: Record<
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" }, 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" },
}; };
export function Podium({ export function Podium<T extends PodiumProfile>({
profiles, profiles,
renderValue,
valueLabel,
isJudge = false, isJudge = false,
onApplyDelta, onApplyDelta,
}: { }: {
profiles: RankedProfile<PodiumProfile>[]; profiles: Ranked<T>[];
renderValue: (member: Ranked<T>) => ReactNode;
valueLabel?: string;
isJudge?: boolean; isJudge?: boolean;
onApplyDelta?: (id: string, delta: number) => void; onApplyDelta?: (id: string, delta: number) => void;
}) { }) {
const byRank = new Map<number, RankedProfile<PodiumProfile>[]>(); const byRank = new Map<number, Ranked<T>[]>();
for (const profile of profiles) { for (const profile of profiles) {
const group = byRank.get(profile.rank) ?? []; const group = byRank.get(profile.rank) ?? [];
group.push(profile); group.push(profile);
@@ -77,9 +82,11 @@ export function Podium({
isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg" isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg"
}`} }`}
> >
{member.points} {renderValue(member)}
</span> </span>
<span className="-mt-1 text-[0.65rem] text-text-mut">gloires</span> {valueLabel && (
<span className="-mt-1 text-[0.65rem] text-text-mut">{valueLabel}</span>
)}
{isJudge && onApplyDelta && ( {isJudge && onApplyDelta && (
<JudgePointControls <JudgePointControls
memberId={member.id} memberId={member.id}
+16 -10
View File
@@ -1,21 +1,27 @@
export type RankedProfile<T extends { points: number }> = T & { rank: number }; export type Ranked<T> = T & { rank: number };
export type RankedProfile<T extends { points: number }> = Ranked<T>;
// Classement "compétition" : les ex-aequo partagent le même rang, et le // Classement "compétition" générique : les ex-aequo (selon le comparateur)
// rang suivant saute en conséquence (1, 2, 2, 4 — pas 1, 2, 2, 3). // partagent le même rang, et le rang suivant saute en conséquence
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] { // (1, 2, 2, 4 — pas 1, 2, 2, 3).
const sorted = [...profiles].sort((a, b) => b.points - a.points); export function computeRanksBy<T>(items: T[], compare: (a: T, b: T) => number): Ranked<T>[] {
let lastPoints: number | null = null; const sorted = [...items].sort(compare);
let lastItem: T | null = null;
let lastRank = 0; let lastRank = 0;
return sorted.map((profile, index) => { return sorted.map((item, index) => {
if (lastPoints === null || profile.points !== lastPoints) { if (lastItem === null || compare(item, lastItem) !== 0) {
lastRank = index + 1; lastRank = index + 1;
lastPoints = profile.points; lastItem = item;
} }
return { ...profile, rank: lastRank }; return { ...item, rank: lastRank };
}); });
} }
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] {
return computeRanksBy(profiles, (a, b) => b.points - a.points);
}
const ROMAN_DIGITS: [number, string][] = [ const ROMAN_DIGITS: [number, string][] = [
[10, "X"], [10, "X"],
[9, "IX"], [9, "IX"],