teecup/frontend/components/session-individual-leaderboard.tsx
Erol Haagenrud bcb712810f
All checks were successful
Backend-tester / test (push) Successful in 46s
Hastecommit
2026-08-24 09:55:04 +02:00

320 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Individuell rangering PER ØKT i en org-turnering (FEATURE_BACKLOG.md
// "Leaderboard for runder og turneringer", avklart 2026-07-28 -- omfang
// bevisst AVGRENSET til én økt, ikke summert på tvers av turneringen, se
// CLAUDE.md for begrunnelse). Ved siden av, ikke i stedet for, det
// eksisterende lag-poeng-leaderboardet (tournament-leaderboard.tsx) -- KUN
// meningsfullt for scoring_mode='stroke'-økter i format singles/fourball,
// backend avviser (400) ellers.
//
// Gjenbruker samme rangering/mode-toggle/verdi-merke-mønster som
// round-leaderboard.tsx (brutto/netto/poeng, "form + farge"-språket, delt
// plassering "T-N") -- egen, enklere lokal versjon her siden denne
// leaderboardet ikke har noen hull-for-hull-data å utvide til (backend
// returnerer kun aggregerte tall per deltaker, ikke per hull).
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Target, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
type ApiEntry = {
participant_id: string
display_name: string
team_id: string
team_name: string
team_color: string | null
thru: number
gross_score: number | null
gross_to_par: number | null
net_score: number | null
net_to_par: number | null
total_points: number | null
}
type ApiLeaderboard = {
session_id: string
format: string
entries: ApiEntry[]
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- backend har
// allerede byttet net_score/net_to_par til hunt-semantikk når aktiv,
// ingen klientsidig utregning nødvendig her (ulikt round-leaderboard.tsx,
// som regner netto selv). Kun brukt til å vise en forklarende banner.
net_display_style: "standard" | "hunt"
}
const FORMAT_LABELS: Record<string, string> = {
singles: "Singles",
fourball: "Fourball",
}
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
function valueFor(entry: ApiEntry, mode: Mode): number | null {
if (mode === "gross") return entry.gross_to_par
if (mode === "net") return entry.net_to_par
return entry.total_points
}
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
if (value === 0) {
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
}
const under = value < 0
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
under ? "rounded-full" : "rounded-[6px]",
emphasize
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary bg-primary/10 text-primary"
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{formatToPar(value)}
</span>
)
}
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
)}
>
{value}
</span>
)
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
aria-hidden="true"
>
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : ""}
</span>
)
}
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const options: { value: Mode; label: string }[] = [
{ value: "gross", label: "Brutto" },
{ value: "net", label: "Netto" },
{ value: "points", label: "Poeng" },
]
return (
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={mode === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
)
}
export function SessionIndividualLeaderboard({
organizationId,
tournamentId,
sessionId,
tournamentName,
}: {
organizationId: string
tournamentId: string
sessionId: string
tournamentName: string
}) {
const [data, setData] = useState<ApiLeaderboard | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>("gross")
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/individual-leaderboard`, {
credentials: "include",
})
if (res.status === 400) {
if (!cancelled)
setError("Denne økten støtter ikke individuell rangering -- kun slagspill i format singles eller fourball.")
return
}
if (res.status === 404) {
if (!cancelled) setError("Økten finnes ikke.")
return
}
if (!res.ok) {
if (!cancelled) setError("Klarte ikke å hente rangeringen. Prøv igjen om litt.")
return
}
const parsed: ApiLeaderboard = await res.json()
if (!cancelled) setData(parsed)
}
void load()
return () => {
cancelled = true
}
}, [organizationId, sessionId])
const backHref = `/tournaments/${tournamentId}/sessions/${sessionId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
if (error) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href={backHref} className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til økten
</Link>
</div>
)
}
if (!data) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
const ranked = rankEntries(data.entries, mode)
return (
<div className="min-h-[100dvh] bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={backHref}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Individuell rangering
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span>
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
</div>
{data.net_display_style === "hunt" && (
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
Jakter Jesper: alle starter minus spillehandicapet sitt -- lavest sluttsum vinner.
</p>
</div>
)}
{data.entries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
</p>
) : (
<>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry) => {
const value = valueFor(entry, mode)
const isLeader = entry.rank === 1
return (
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: entry.team_color ?? "var(--muted-foreground)" }}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{entry.display_name}</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{entry.team_name} · {entry.thru > 0 ? `${entry.thru} hull spilt` : "Ikke startet"}
</span>
</span>
{isLeader && entry.rank === 1 && <Trophy aria-hidden="true" className="size-4 shrink-0 text-primary" />}
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
</li>
)
})}
</ul>
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
<p className="text-sm text-muted-foreground">
Spillere uten {mode === "net" ? "netto-tall" : "poengsum"} (ingen handicap beregnet ennå) vises uten
rangering.
</p>
)}
</>
)}
</main>
</div>
)
}