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 (
{Math.abs(delta)}
{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 (