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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user