import type { ReactNode } from 'react' import { cn } from '../../lib/cn' import { Avatar } from '../Avatar' import { VisuallyHidden } from '../VisuallyHidden' export interface LeaderboardEntry { id: string name: string score: number /** Avatar image. Initials are drawn without it. */ avatar?: string /** Secondary line under the name — a team, a region. */ detail?: ReactNode /** Last period's rank, for the movement arrow. `'new'` for a first appearance. */ previousRank?: number | 'new' } export interface LeaderboardProps { entries: LeaderboardEntry[] /** How many ranks to list. */ limit?: number /** The reader's own entry: highlighted, and pinned under the list when it falls outside `limit`. */ currentUserId?: string /** Raise the top three onto a podium above the list. */ podium?: boolean /** Formats a score. Defaults to grouped digits. */ formatScore?: (score: number) => string /** What a score counts, for screen readers — "points", "commits". */ unit?: string /** Accessible name for the ranking. */ label: string /** Merged last, so it wins. */ className?: string } interface LeaderboardRanked extends LeaderboardEntry { rank: number } function Movement({ entry }: { entry: LeaderboardRanked }) { const { previousRank, rank } = entry if (previousRank === undefined) return null if (previousRank === 'new') { return New } const delta = previousRank - rank if (delta === 0) { return ( no change ) } const up = delta > 0 return ( {up ? 'up' : 'down'} {Math.abs(delta)} {Math.abs(delta) === 1 ? 'place' : 'places'} ) } const PODIUM = [ { place: 2, height: 'h-16', order: 'order-1' }, { place: 1, height: 'h-24', order: 'order-2' }, { place: 3, height: 'h-12', order: 'order-3' }, ] /** * Who is ahead, and where you are — for sales contests, streak boards, the * weekly top contributors. * * Ties share a rank (1, 2, 2, 4), because putting one of two equal scores * above the other is a judgement the data did not make. Movement since last * period is an arrow and a number, with the words behind them for screen * readers. * * The reader's own row is the one they look for first. When it is outside the * top N it is pinned under the list after a gap, so it is visible without * scrolling and without pretending they are tenth. * * The podium is the same ordered list in the same DOM order, rearranged 2–1–3 * with CSS, so it reads first, second, third. */ export function Leaderboard({ entries, limit = 10, currentUserId, podium = false, formatScore = (score) => score.toLocaleString(), unit = 'points', label, className, }: LeaderboardProps) { const sorted = [...entries].sort((a, b) => b.score - a.score) const ranked: LeaderboardRanked[] = sorted.map((entry, index) => ({ ...entry, rank: index > 0 && sorted[index - 1].score === entry.score ? 0 : index + 1, })) ranked.forEach((entry, index) => { if (entry.rank === 0) entry.rank = ranked[index - 1].rank }) const top = ranked.slice(0, limit) const onPodium = podium ? top.slice(0, 3) : [] const listed = podium ? top.slice(onPodium.length) : top const me = currentUserId ? ranked.find((entry) => entry.id === currentUserId) : undefined const pinned = me && !top.includes(me) ? me : undefined const row = (entry: LeaderboardRanked) => { const mine = entry.id === currentUserId return (
  • Rank {entry.rank} {entry.name} {mine && (you)} {entry.detail && {entry.detail}} {formatScore(entry.score)} {unit}
  • ) } return (
    {onPodium.length > 0 && (
      {onPodium.map((entry, index) => { const slot = PODIUM.find((item) => item.place === index + 1)! const mine = entry.id === currentUserId return (
    1. Rank {entry.rank}, {entry.name} {formatScore(entry.score)} {unit}
    2. ) })}
    )} {listed.length > 0 && (
      {listed.map(row)}
    )} {pinned && (
      {row(pinned)}
    )}
    ) }