teecup/frontend/components/round-leaderboard.tsx
Erol Haagenrud 76fe6f6c2c Full V0-tro omskriving av rundeleaderboard, tab-navigasjonsbug, formatbryter
Brukeren testet en Slagspill-runde etter forrige leaderboard-utrulling og
så ingen visuell endring - forrige runde hadde gjenbrukt gamle håndkodede
komponenter for individuell/to-sidet visning i stedet for å implementere
V0s tegning fullt ut. Skrev om round-leaderboard.tsx fra bunnen mot V0s
faktiske komponenter, portet manuelt mot ekte API-data. Fant og rettet fem
reelle bugs underveis (courseHcp-matte, manglende hcpIndex, ødelagt
TeamFlightBoard-pille, CSS-trunkering i SideName, feil kildetekst for
matchoverskrift).

Fikset også: "Spillere og runde"-snarveien som ikke virket fra Score-fanen
(useEffect manglet på searchParams-endring), play-format-bryteren i
Rediger runde-panelet utvidet fra 3 til 16 formater, 8 nye statistikkrader
i scorekortet (Fairway/Putt/GIR/Innspill/Chip/Bunker/Straffeslag/Anyway).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:30:57 +02:00

1517 lines
61 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"
// Leaderboard for en frittstående runde (ADR-033).
//
// FULLSTENDIG OMBYGGET 2026-08-03 etter V0-eksport "zip 28": brukeren ba
// EKSPLISITT om V0 sin visuelle tolkning i FULL BREDDE, ikke bare de
// formatene som var reelt ødelagte/manglet ("Jeg vil ABSOLUTT at du
// implementerer V0 sin tolkning... Jeg vil ikke at du skal gjøre
// personlige endringer eller holde tilbake på noe i det hele tatt").
// Forrige runde samme dag portet kun High-low-high/BBB-Københavner/
// Shamble-Money-Ball (reelle bugs) og lot resten av det gamle,
// HÅNDKODEDE designet (2026-07-26, bygget FØR V0 da brukeren gikk tom
// for credits) stå urørt -- brukeren testet en vanlig slagspill-runde
// (det vanligste tilfellet) og så null visuell endring, med rette.
//
// Datalaget (typer, useLeaderboardData/useFormatResult/
// useFlightTeamStandings, rankEntries brukt av RoundLeaderboardMini) er
// UENDRET fra forrige runde -- kun det visuelle laget er nå V0 sin egen
// kode, portet mot ekte data i stedet for V0 sine mock-arrays (samme
// "aldri blind-kopier en fil"-disiplin som alltid, bare nå anvendt på
// ALLE komponentene eksporten rørte, ikke et utvalg).
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
type ApiLeaderboardHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
picked_up: boolean
strokes_received: number | null
}
type ApiLeaderboardEntry = {
participant_id: string
display_name: string
is_owner: boolean
holes_played: number
total_score: number | null
score_to_par: number | null
net_score_to_par: number | null
total_points: number | null
holes: ApiLeaderboardHole[]
}
type ApiLeaderboard = {
holes_planned: number
completed: boolean
entries: ApiLeaderboardEntry[]
}
type ApiRoundParticipant = {
id: string
user_id: string | null
round_side_id: string | null
handicap_index_snapshot: number | null
}
type ApiSide = {
id: string
label: string | null
}
type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
play_format: string
sides: ApiSide[]
participants: ApiRoundParticipant[]
}
// To-sidede formater har ingen individuell "rangering" -- to lag/spillere i
// en match har en STATUS (hvem leder, hull for hull), ikke en topp-3-liste.
// Delt-ball-formatene (foursome/greensome/scramble) har i tillegg ALDRI
// noen individuell round_hole-rad å rangere fra (ADR-039 Beslutning C: én
// score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville
// derfor vist en tom/meningsløs liste for dem uansett.
const TWO_SIDED_FORMATS = [
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman",
]
// High-low-high er et SÆRTILFELLE av de to-sidede formatene -- IKKE et
// hull-ledelse-konsept ("X UP"), se HighLowBoard.
const HIGH_LOW_FORMATS = new Set(["high_low_high"])
// Shamble/Money Ball -- HELE runden er ETT lag, ikke to sider -- lagets
// EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard.
const TEAM_RESULT_FORMATS = new Set(["shamble", "money_ball"])
// Bingo Bango Bongo/Københavner har egne poengsystem, uavhengig av rå
// slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk
// Stableford (backend regner den likt uansett format), FEIL for disse to.
const FORMAT_OWN_POINTS_FIELD: Partial<Record<string, "bbb_points" | "copenhagen_points">> = {
bbb: "bbb_points",
copenhagen: "copenhagen_points",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
// --- Rangering (brukt av RoundLeaderboardMini) ------------------------------
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiLeaderboardEntry & {
rank: number | null
tied: boolean
isSelf: boolean
}
function valueFor(entry: ApiLeaderboardEntry, mode: Mode): number | null {
if (mode === "gross") return entry.score_to_par
if (mode === "net") return entry.net_score_to_par
return entry.total_points
}
function rankEntries(entries: ApiLeaderboardEntry[], mode: Mode, viewerParticipantId: string | null): 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, isSelf: e.participant_id === viewerParticipantId })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false, isSelf: e.participant_id === viewerParticipantId })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
// ===========================================================================
// V0 sitt visuelle vokabular (zip 28, 2026-08-03) -- portet verbatim, kun
// HOLES/TOTAL_HOLES er ikke lenger en hardkodet modul-konstant (V0 sin
// egen mock-bane) men en `holes: Hole[]`/`totalHoles: number`-parameter
// bygget fra RUNDENS EGNE par/indeks (se buildHoles under) -- ellers
// samme kode, samme klassenavn, samme struktur.
// ===========================================================================
type Hole = { holeNumber: number; par: number; index: number }
const SIDE = {
a: { fill: "bg-primary", text: "text-primary-foreground", ink: "text-primary" },
b: { fill: "bg-brand-orange", text: "text-brand-orange-foreground", ink: "text-brand-orange" },
} as const
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
function classify(gross: number, par: number): ScoreClass {
const d = gross - par
if (d <= -2) return "eagle"
if (d === -1) return "birdie"
if (d === 0) return "par"
if (d === 1) return "bogey"
return "double"
}
function scoreClassLabel(gross: number, par: number): string {
const d = gross - par
if (d <= -1) return "under par"
if (d === 0) return "par"
return "over par"
}
function ScoreMark({ gross, par, size = "md" }: { gross: number; par: number; size?: "sm" | "md" }) {
const kind = classify(gross, par)
const dim = size === "sm" ? "size-8 text-sm" : "size-9 text-base"
const shared = cn("inline-flex items-center justify-center font-extrabold tabular-nums", dim)
let cls = ""
if (kind === "eagle") cls = "rounded-full bg-primary text-primary-foreground"
else if (kind === "birdie") cls = "rounded-full border-2 border-primary bg-primary/10 text-primary"
else if (kind === "bogey") cls = "rounded-[6px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange"
else if (kind === "double") cls = "rounded-[6px] bg-brand-orange text-brand-orange-foreground"
else cls = "text-foreground"
return (
<span className={cn(shared, cls)}>
{gross}
<span className="sr-only"> ({scoreClassLabel(gross, par)})</span>
</span>
)
}
function toParLabel(diff: number): string {
if (diff === 0) return "E"
return diff > 0 ? `+${diff}` : `${diff}`
}
function toParWords(diff: number): string {
if (diff === 0) return "likt med par"
return diff > 0 ? `${diff} over par` : `${Math.abs(diff)} under par`
}
const NF1 = new Intl.NumberFormat("no-NO", { maximumFractionDigits: 1 })
function Badge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "self" | "owner" }) {
const map = {
neutral: "bg-muted text-muted-foreground",
self: "bg-primary/15 text-primary",
owner: "bg-gold/25 text-gold-foreground",
} as const
return (
<span className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide", map[tone])}>
{children}
</span>
)
}
function MetricPill({
kind,
value,
unit,
size = "md",
}: {
kind: "topar" | "points"
value: number
unit?: string
size?: "md" | "lg"
}) {
const dims = size === "lg" ? "min-h-12 min-w-12 px-3 text-xl" : "min-h-11 min-w-11 px-2.5 text-lg"
const font = "font-extrabold tabular-nums"
if (kind === "points") {
return (
<span className={cn("inline-flex items-center justify-center gap-0.5 rounded-full bg-primary text-primary-foreground", dims, font)}>
{value}
{unit ? <span className="text-xs font-bold">{unit}</span> : null}
</span>
)
}
const label = toParLabel(value)
if (value === 0) {
return (
<span className={cn("inline-flex items-center justify-center text-foreground", dims, font)}>
{label}
<span className="sr-only"> {toParWords(value)}</span>
</span>
)
}
const under = value < 0
return (
<span
className={cn(
"inline-flex items-center justify-center",
dims,
font,
under ? "rounded-full bg-primary/15 text-primary" : "rounded-[8px] bg-brand-orange/15 text-brand-orange",
)}
>
{label}
<span className="sr-only"> {toParWords(value)}</span>
</span>
)
}
// ===========================================================================
// 1. INDIVIDUAL leaderboard (stroke / stableford / skins / flag / BBB / KBH)
// ===========================================================================
type MetricKey = "brutto" | "netto" | "stableford" | "points"
type IndividualPlayer = {
id: string
name: string
isSelf?: boolean
isOwner?: boolean
// Ekte per-hull mottatte slag (allerede korrekt utregnet av backend,
// samme allokering som resten av appen) -- IKKE en rå course handicap +
// en egen klientsidig strokes_received-utregning, som V0 sin mock-
// versjon gjorde uten et ekte backend å hente fra. `null` for et hull
// betyr "ingen HCP spores for denne spilleren", ikke "0 slag".
strokesReceived: (number | null)[]
hcpIndex: number | null
finished?: boolean
gross: (number | null)[]
holePoints?: (number | null)[]
}
function hasHcp(p: IndividualPlayer): boolean {
return p.strokesReceived.some((v) => v !== null)
}
const METRIC_LABEL: Record<MetricKey, string> = {
brutto: "Brutto",
netto: "Netto",
stableford: "Stableford",
points: "Poeng",
}
function playedHoles(p: IndividualPlayer): number {
return p.gross.filter((g) => g !== null).length
}
function grossToPar(p: IndividualPlayer, holes: Hole[]): number {
let diff = 0
p.gross.forEach((g, i) => {
if (g !== null) diff += g - holes[i].par
})
return diff
}
function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
if (!hasHcp(p)) return null
let diff = 0
p.gross.forEach((g, i) => {
const received = p.strokesReceived[i]
if (g !== null && received !== null) diff += g - received - holes[i].par
})
return diff
}
function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null {
if (!hasHcp(p)) return null
let pts = 0
p.gross.forEach((g, i) => {
const received = p.strokesReceived[i]
if (g !== null && received !== null) pts += Math.max(0, 2 + (holes[i].par - (g - received)))
})
return pts
}
function pointsTotal(p: IndividualPlayer): number {
return (p.holePoints ?? []).reduce<number>((s, v) => s + (v ?? 0), 0)
}
function evalMetric(
p: IndividualPlayer,
metric: MetricKey,
pointsUnit: string,
holes: Hole[],
): {
rankable: boolean
sortVal: number
higherWins: boolean
reason?: string
pill: React.ReactNode
} {
if (metric === "brutto") {
const v = grossToPar(p, holes)
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
}
if (metric === "netto") {
const v = nettoToPar(p, holes)
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
}
if (metric === "stableford") {
const v = stablefordTotal(p, holes)
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
return { rankable: true, sortVal: v, higherWins: true, pill: <MetricPill kind="points" value={v} unit="p" size="lg" /> }
}
const v = pointsTotal(p)
return { rankable: true, sortVal: v, higherWins: true, pill: <MetricPill kind="points" value={v} unit={pointsUnit} size="lg" /> }
}
function NoData() {
return (
<span className="inline-flex min-h-11 items-center justify-center rounded-full border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground">
<span className="sr-only">ingen rangering</span>
</span>
)
}
function IndividualBoard({
players,
metrics,
defaultMetric,
pointsUnit = "p",
holes,
}: {
players: IndividualPlayer[]
metrics: MetricKey[]
defaultMetric: MetricKey
pointsUnit?: string
holes: Hole[]
}) {
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
const [openId, setOpenId] = useState<string | null>(null)
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes) }))
const rankable = evaluated.filter((e) => e.rankable)
const unranked = evaluated.filter((e) => !e.rankable)
rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal))
const rankInfo: { rank: number; tied: boolean }[] = []
const counts: Record<number, number> = {}
rankable.forEach((e, i) => {
const rank = i > 0 && rankable[i - 1].sortVal === e.sortVal ? rankInfo[i - 1].rank : i + 1
rankInfo.push({ rank, tied: false })
counts[rank] = (counts[rank] ?? 0) + 1
})
rankInfo.forEach((x) => (x.tied = counts[x.rank] > 1))
return (
<div className="flex flex-col gap-3">
<MetricToggle metrics={metrics} value={metric} onChange={setMetric} pointsUnit={pointsUnit} />
<ol className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card">
{rankable.map((e, i) => (
<IndividualRow
key={e.player.id}
player={e.player}
rank={rankInfo[i].rank}
tied={rankInfo[i].tied}
isLeader={i === 0}
pill={e.pill}
metric={metric}
holes={holes}
open={openId === e.player.id}
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
/>
))}
{unranked.map((e) => (
<IndividualRow
key={e.player.id}
player={e.player}
rank={null}
tied={false}
isLeader={false}
pill={e.pill}
reason={e.reason}
metric={metric}
holes={holes}
open={openId === e.player.id}
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
/>
))}
</ol>
<Legend />
</div>
)
}
function MetricToggle({
metrics,
value,
onChange,
pointsUnit,
}: {
metrics: MetricKey[]
value: MetricKey
onChange: (m: MetricKey) => void
pointsUnit: string
}) {
if (metrics.length < 2) return null
return (
<div role="group" aria-label="Velg visning" className="flex gap-1 rounded-full border border-border bg-muted p-1">
{metrics.map((m) => {
const active = m === value
const label = m === "points" && pointsUnit !== "p" ? `${METRIC_LABEL[m]}` : METRIC_LABEL[m]
return (
<button
key={m}
type="button"
aria-pressed={active}
onClick={() => onChange(m)}
className={cn(
"min-h-11 flex-1 rounded-full px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
active ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
)
})}
</div>
)
}
function IndividualRow({
player,
rank,
tied,
isLeader,
pill,
reason,
metric,
holes,
open,
onToggle,
}: {
player: IndividualPlayer
rank: number | null
tied: boolean
isLeader: boolean
pill: React.ReactNode
reason?: string
metric: MetricKey
holes: Hole[]
open: boolean
onToggle: () => void
}) {
const played = playedHoles(player)
const totalHoles = holes.length
const progress = player.finished ? "Ferdig" : `${played}/${totalHoles} hull`
const panelId = `hbh-${player.id}`
return (
<li className={cn("border-b border-border last:border-b-0", isLeader && "bg-primary/5")}>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-controls={panelId}
className="flex min-h-16 w-full items-center gap-3 px-3 py-2.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="flex w-9 shrink-0 justify-center">
{rank === null ? (
<span className="text-sm font-bold text-muted-foreground" aria-hidden="true"></span>
) : (
<span className={cn("text-base font-extrabold tabular-nums", isLeader ? "text-primary" : "text-foreground")}>
{tied ? "T" : ""}
{rank}
</span>
)}
<span className="sr-only">{rank === null ? "Uten rangering" : `${tied ? "Delt " : ""}${rank}. plass`}</span>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2">
{isLeader && <Crown aria-hidden="true" className="size-4 shrink-0 text-gold-foreground" />}
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
{player.isSelf && <Badge tone="self">Deg</Badge>}
{player.isOwner && <Badge tone="owner">Eier</Badge>}
</span>
<span className="truncate text-sm text-muted-foreground">
{player.hcpIndex !== null ? `Hcp ${NF1.format(player.hcpIndex)} · ` : "Uten HCP · "}
{reason ?? progress}
</span>
</span>
<span className="shrink-0">{pill}</span>
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
{open && (
<div id={panelId} className="px-3 pb-3">
<HoleByHole player={player} metric={metric} holes={holes} />
</div>
)}
</li>
)
}
function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metric: MetricKey; holes: Hole[] }) {
const front = holes.slice(0, 9)
const back = holes.slice(9)
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
<p className="text-xs font-semibold text-muted-foreground">
Hull-for-hull · brutto (form) og netto (tall). Sirkel = under par, firkant = over par.
</p>
{[front, back].filter((set) => set.length > 0).map((set, si) => {
const offset = si === 0 ? 0 : front.length
return (
<div key={si} className="relative min-w-0 overflow-x-auto">
<table className="w-full min-w-[34rem] border-collapse text-center">
<thead>
<tr className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
<th scope="row" className="sticky left-0 bg-background pr-2 text-left font-bold">Hull</th>
{set.map((h) => (
<th key={h.holeNumber} scope="col" className="px-1 font-extrabold text-foreground">{h.holeNumber}</th>
))}
<th scope="col" className="px-1">{si === 0 ? "Ut" : "Inn"}</th>
</tr>
<tr className="text-[11px] text-muted-foreground">
<th scope="row" className="sticky left-0 bg-background pr-2 text-left font-semibold">Par · idx</th>
{set.map((h) => (
<td key={h.holeNumber} className="px-1">{h.par}·{h.index}</td>
))}
<td />
</tr>
</thead>
<tbody>
<tr>
<th scope="row" className="sticky left-0 bg-background pr-2 text-left text-xs font-bold text-muted-foreground">Brutto</th>
{set.map((h, i) => {
const g = player.gross[offset + i]
return (
<td key={h.holeNumber} className="px-1 py-1">
{g === null ? <span className="text-muted-foreground"></span> : <ScoreMark gross={g} par={h.par} size="sm" />}
</td>
)
})}
<td className="px-1 font-extrabold tabular-nums text-foreground">
{set.reduce((s, h, i) => s + (player.gross[offset + i] ?? 0), 0) || ""}
</td>
</tr>
{hasHcp(player) && (
<tr className="text-sm">
<th scope="row" className="sticky left-0 bg-background pr-2 text-left text-xs font-bold text-muted-foreground">Netto</th>
{set.map((h, i) => {
const g = player.gross[offset + i]
const received = player.strokesReceived[offset + i]
if (g === null || received === null) return <td key={h.holeNumber} className="px-1 text-muted-foreground"></td>
return <td key={h.holeNumber} className="px-1 font-semibold tabular-nums text-foreground">{g - received}</td>
})}
<td />
</tr>
)}
{metric === "points" && player.holePoints && (
<tr className="text-sm">
<th scope="row" className="sticky left-0 bg-background pr-2 text-left text-xs font-bold text-muted-foreground">Poeng</th>
{set.map((h, i) => {
const pt = player.holePoints?.[offset + i]
return <td key={h.holeNumber} className="px-1 font-bold tabular-nums text-primary">{pt ?? ""}</td>
})}
<td className="px-1 font-extrabold tabular-nums text-primary">
{set.reduce((s, h, i) => s + (player.holePoints?.[offset + i] ?? 0), 0)}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
})}
</div>
)
}
function Legend() {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="inline-flex size-5 items-center justify-center rounded-full border-2 border-primary bg-primary/10 text-[10px] font-bold text-primary">3</span>
Under par (sirkel)
</span>
<span className="flex items-center gap-1.5">
<span className="inline-flex size-5 items-center justify-center rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] font-bold text-brand-orange">5</span>
Over par (firkant)
</span>
<span className="flex items-center gap-1.5">
<Crown aria-hidden="true" className="size-4 text-gold-foreground" /> Leder
</span>
</div>
)
}
// ===========================================================================
// 2. TWO-SIDED match status (match / fourball / foursome / greensome / …)
// ===========================================================================
type SideTeamInfo = { id: "a" | "b"; name: string; players: string[] }
type HoleWinner = "a" | "b" | "halved" | null
// Statusteksten/lederen/dormie/avgjort kommer FERDIG REGNET fra backend
// (samme tall som allerede vises andre steder i appen, f.eks.
// round-detail.tsx sin FormatResultPanel) -- V0 sin egen `matchStatus()`
// regnet dette på nytt fra `results` klientsidig; her brukes heller de
// allerede korrekte/testede server-tallene, med SAMME visuelle "tug of
// war"-presentasjon V0 tegnet. Unngår to divergerende utregninger av
// samme ting.
function TwoSidedBoard({
sideA,
sideB,
results,
totalHoles,
formatName,
headline,
sub,
leader,
isDormie,
isClosed,
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
results: HoleWinner[]
totalHoles: number
formatName: string
headline: string
sub: string
leader: "a" | "b" | null
isDormie: boolean
isClosed: boolean
}) {
const holesPlayed = results.filter((r) => r !== null).length
let aWins = 0
let bWins = 0
results.forEach((r) => {
if (r === "a") aWins++
else if (r === "b") bWins++
})
const diff = Math.abs(aWins - bWins)
const maxUp = Math.max(3, diff)
const offsetPct = (diff / maxUp) * 50
const markerLeft = leader === "a" ? 50 - offsetPct : leader === "b" ? 50 + offsetPct : 50
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card p-5">
<span className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
{formatName}
{isClosed && <Badge tone="neutral">Avgjort</Badge>}
{isDormie && !isClosed && <Badge tone="neutral">Dormie</Badge>}
</span>
<div className="flex w-full items-center justify-between gap-3">
<SideName side="a" team={sideA} leading={leader === "a"} />
<div className="flex shrink-0 flex-col items-center">
<span className="text-2xl font-extrabold text-foreground">{headline}</span>
<span className="text-xs font-semibold text-muted-foreground">{sub}</span>
</div>
<SideName side="b" team={sideB} leading={leader === "b"} align="right" />
</div>
<div className="relative h-4 w-full overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div className="absolute inset-y-0 left-0 w-1/2" style={{ background: "color-mix(in oklch, var(--color-primary) 22%, transparent)" }} />
<div className="absolute inset-y-0 right-0 w-1/2" style={{ background: "color-mix(in oklch, var(--color-brand-orange) 22%, transparent)" }} />
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border" />
{leader && (
<span
className={cn("absolute inset-y-0 rounded-full", leader === "a" ? "bg-primary" : "bg-brand-orange")}
style={leader === "a" ? { left: `${markerLeft}%`, right: "50%" } : { left: "50%", right: `${100 - markerLeft}%` }}
/>
)}
<span className="absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-foreground" />
</div>
</div>
<HoleWinners sideA={sideA} sideB={sideB} results={results} totalHoles={totalHoles} />
</div>
)
}
function SideName({ side, team, leading, align = "left" }: { side: "a" | "b"; team: SideTeamInfo; leading: boolean; align?: "left" | "right" }) {
return (
<div className={cn("flex min-w-0 flex-1 flex-col gap-1", align === "right" && "items-end text-right")}>
<span className="flex min-w-0 items-center gap-1.5">
<span className={cn("inline-block size-3 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")} aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-extrabold text-foreground">{team.name}</span>
{leading && <FlagIcon aria-hidden="true" className="size-3.5 shrink-0 text-gold-foreground" />}
</span>
<span className="truncate text-xs text-muted-foreground">{team.players.join(" / ")}</span>
{leading && <span className="sr-only">leder</span>}
</div>
)
}
function HoleWinners({ sideA, sideB, results, totalHoles }: { sideA: SideTeamInfo; sideB: SideTeamInfo; results: HoleWinner[]; totalHoles: number }) {
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
<h3 className="px-1 text-sm font-bold text-foreground">Hull-for-hull</h3>
<div className="relative min-w-0 overflow-x-auto">
<div className="flex min-w-max gap-1.5">
{Array.from({ length: totalHoles }, (_, i) => i + 1).map((holeNumber, i) => {
const r = results[i] ?? null
const label = r === "a" ? sideA.name : r === "b" ? sideB.name : r === "halved" ? "delt" : "ikke spilt"
return (
<div key={holeNumber} className="flex w-9 flex-col items-center gap-1">
<span className="text-[11px] font-bold tabular-nums text-muted-foreground">{holeNumber}</span>
<span
className={cn(
"flex size-9 items-center justify-center rounded-md text-sm font-extrabold",
r === "a" && "bg-primary text-primary-foreground",
r === "b" && "bg-brand-orange text-brand-orange-foreground",
r === "halved" && "border border-border bg-muted text-muted-foreground",
r === null && "border border-dashed border-border text-muted-foreground",
)}
>
{r === "a" ? "A" : r === "b" ? "B" : r === "halved" ? "=" : ""}
<span className="sr-only">Hull {holeNumber}: {label}</span>
</span>
</div>
)
})}
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1 pt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-primary text-[10px] font-bold text-primary-foreground">A</span>{sideA.name}</span>
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-brand-orange text-[10px] font-bold text-brand-orange-foreground">B</span>{sideB.name}</span>
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded border border-border bg-muted text-[10px] font-bold">=</span>Delt</span>
</div>
</div>
)
}
// ===========================================================================
// 3. HIGH-LOW-HIGH -- to duller per hull, poeng per side (IKKE hull-opp)
// ===========================================================================
type Duel = "a" | "b" | "halved" | null
type HoleDuel = { low: Duel; high: Duel }
function HighLowBoard({
sideA,
sideB,
duels,
totalA,
totalB,
formatName,
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
duels: HoleDuel[]
totalA: number
totalB: number
formatName: string
}) {
const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null
const fmt = (n: number) => NF1.format(n)
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5">
<span className="text-center text-xs font-bold uppercase tracking-wide text-muted-foreground">{formatName} · totale poeng</span>
<div className="grid grid-cols-2 gap-3">
{(["a", "b"] as const).map((s) => {
const team = s === "a" ? sideA : sideB
const val = s === "a" ? totalA : totalB
const isLead = leader === s
return (
<div
key={s}
className={cn(
"flex flex-col items-center gap-1 rounded-xl border p-4",
isLead ? (s === "a" ? "border-primary bg-primary/5" : "border-brand-orange bg-brand-orange/5") : "border-border",
)}
>
<span className="flex items-center gap-1.5">
<span className={cn("inline-block size-3 rounded-full", s === "a" ? "bg-primary" : "bg-brand-orange")} aria-hidden="true" />
<span className="truncate text-sm font-extrabold text-foreground">{team.name}</span>
</span>
<span className={cn("text-4xl font-extrabold tabular-nums", s === "a" ? "text-primary" : "text-brand-orange")}>{fmt(val)}</span>
<span className="text-xs font-semibold text-muted-foreground">poeng {isLead ? "· leder" : ""}</span>
</div>
)
})}
</div>
<p className="text-center text-xs text-muted-foreground">
To uavhengige dueller per hull: laveste mot laveste og høyeste mot høyeste netto/Stableford-poeng. 02 poeng per hull.
</p>
</div>
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
<h3 className="px-1 text-sm font-bold text-foreground">Hull-for-hull · lav / høy</h3>
<div className="relative min-w-0 overflow-x-auto">
<div className="flex min-w-max flex-col gap-1.5">
<DuelRow label="Hull" cells={duels.map((_, i) => ({ text: String(i + 1), head: true }))} />
<DuelRow label="Lav" cells={duels.map((d) => duelCell(d.low))} />
<DuelRow label="Høy" cells={duels.map((d) => duelCell(d.high))} />
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1 pt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-primary text-[10px] font-bold text-primary-foreground">A</span>{sideA.name}</span>
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-brand-orange text-[10px] font-bold text-brand-orange-foreground">B</span>{sideB.name}</span>
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded border border-border bg-muted text-[10px] font-bold">=</span>Delt (½½)</span>
</div>
</div>
</div>
)
}
function duelCell(d: Duel): { text: string; tone: Duel } {
return { text: d === "a" ? "A" : d === "b" ? "B" : d === "halved" ? "=" : "", tone: d }
}
function DuelRow({ label, cells }: { label: string; cells: { text: string; head?: boolean; tone?: Duel }[] }) {
return (
<div className="flex items-center gap-1.5">
<span className="sticky left-0 w-12 shrink-0 bg-card text-xs font-bold text-muted-foreground">{label}</span>
{cells.map((c, i) => (
<span
key={i}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md text-sm font-extrabold",
c.head && "text-muted-foreground",
c.tone === "a" && "bg-primary text-primary-foreground",
c.tone === "b" && "bg-brand-orange text-brand-orange-foreground",
c.tone === "halved" && "border border-border bg-muted text-muted-foreground",
c.tone === null && !c.head && "border border-dashed border-border text-muted-foreground",
)}
>
{c.text}
</span>
))}
</div>
)
}
// Lav/høy-rollen per spiller per hull er ikke tagget eksplisitt av
// backend, men er alltid MIN/MAKS av de to lagkameratenes Stableford-
// poeng den hullet (samme regel som handicap_engine.py sin
// high_low_high_points_for_hole) -- utledes trygt her uten noen
// backend-endring.
function highLowDuelForHole(hole: ApiFormatHole): HoleDuel {
const aPoints = hole.entries.filter((e) => e.side === "a" && e.points !== null).map((e) => e.points as number)
const bPoints = hole.entries.filter((e) => e.side === "b" && e.points !== null).map((e) => e.points as number)
if (aPoints.length < 2 || bPoints.length < 2) return { low: null, high: null }
const lowA = Math.min(...aPoints)
const highA = Math.max(...aPoints)
const lowB = Math.min(...bPoints)
const highB = Math.max(...bPoints)
return {
low: lowA > lowB ? "a" : lowB > lowA ? "b" : "halved",
high: highA > highB ? "a" : highB > highA ? "b" : "halved",
}
}
// ===========================================================================
// 4. TEAM / FLIGHT (Shamble / Money Ball)
// ===========================================================================
type FlightTeam = { id: string; name: string; toPar: number; through: number; isThisTeam?: boolean }
function TeamFlightBoard({
teamName,
toPar,
through,
totalHoles,
flight,
members,
holes,
formatName,
}: {
teamName: string
toPar: number | null
through: number
totalHoles: number
flight: FlightTeam[]
members: IndividualPlayer[]
holes: Hole[]
formatName: string
}) {
const [showRaw, setShowRaw] = useState(false)
const ranked = [...flight].sort((a, b) => a.toPar - b.toPar)
const myRank = ranked.findIndex((t) => t.isThisTeam) + 1
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{formatName} · ditt lag</span>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-lg font-extrabold text-foreground">{teamName}</span>
<span className="text-sm text-muted-foreground">
{through === totalHoles ? "Ferdig" : `${through}/${totalHoles} hull`}
{flight.length > 1 && myRank > 0 ? ` · ${myRank}. i flighten` : ""}
</span>
</div>
{toPar !== null ? <MetricPill kind="topar" value={toPar} size="lg" /> : <span className="text-sm font-semibold text-muted-foreground">Ingen hull ferdig ennå</span>}
</div>
</div>
{ranked.length > 1 && (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
<h3 className="px-1 text-sm font-bold text-foreground">Flight-gruppe</h3>
<ol className="flex flex-col">
{ranked.map((t, i) => (
<li
key={t.id}
className={cn(
"flex min-h-14 items-center gap-3 rounded-xl px-3 py-2",
t.isThisTeam && "bg-primary/5 ring-1 ring-inset ring-primary/30",
)}
>
<span className="w-7 text-center text-base font-extrabold tabular-nums text-foreground">{i + 1}</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
{i === 0 && <Crown aria-hidden="true" className="size-4 shrink-0 text-gold-foreground" />}
{t.name}
{t.isThisTeam && <Badge tone="self">Ditt</Badge>}
</span>
<span className="text-sm text-muted-foreground">{t.through === totalHoles ? "Ferdig" : `${t.through}/${totalHoles} hull`}</span>
</span>
<MetricPill kind="topar" value={t.toPar} />
</li>
))}
</ol>
</div>
)}
<div className="rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setShowRaw((v) => !v)}
aria-expanded={showRaw}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="text-sm font-bold text-foreground">Spillernes råscore</span>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", showRaw && "rotate-180")} />
</button>
{showRaw && (
<ol className="flex flex-col border-t border-border">
{members.map((m) => (
<li key={m.id} className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-2 last:border-b-0">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
{m.name}
{m.isSelf && <Badge tone="self">Deg</Badge>}
</span>
<span className="text-sm text-muted-foreground">
{m.hcpIndex !== null ? `Hcp ${NF1.format(m.hcpIndex)}` : "Uten HCP"} · {playedHoles(m)}/{totalHoles} hull
</span>
</span>
<MetricPill kind="topar" value={grossToPar(m, holes)} />
</li>
))}
</ol>
)}
</div>
</div>
)
}
// ===========================================================================
// Datalag: fetch-hooks (uendret fra forrige runde)
// ===========================================================================
type ApiFormatHoleEntry = {
id: string
label: string
side: "a" | "b" | null
gross: number | null
net: number | null
strokes_received: number | null
counted: boolean
points: number | null
}
type ApiFormatHole = {
hole_number: number
par: number
stroke_index: number
entries: ApiFormatHoleEntry[]
result: string | null
}
type ApiFormatResult = {
play_format: string
ready: boolean
match_lead: number | null
match_holes_played: number | null
match_holes_remaining: number | null
match_is_closed: boolean | null
match_is_dormie: boolean | null
match_status_text: string | null
hole_results: string[] | null
copenhagen_points: Record<string, number> | null
bbb_points: Record<string, number> | null
shamble_team_score: number | null
money_ball_team_score: number | null
hlh_points_a: number | null
hlh_points_b: number | null
holes: ApiFormatHole[] | null
}
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFormatResult | null) => {
if (!cancelled) setResult(data)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId, refreshKey])
return result
}
type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number }
function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) {
const [standings, setStandings] = useState<FlightSibling[] | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const flightRes = await fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
if (!flightRes.ok) return
const flight: { flight_group_id: string | null; flights: { round_id: string; name: string | null; is_anchor: boolean }[] } =
await flightRes.json()
if (flight.flights.length < 2) {
if (!cancelled) setStandings(null)
return
}
const results = await Promise.all(
flight.flights.map(async (f) => {
const r = await fetch(`/rounds/${f.round_id}/format-result`, { credentials: "include" })
const data: ApiFormatResult | null = r.ok ? await r.json() : null
const teamScore = playFormat === "shamble" ? data?.shamble_team_score ?? null : data?.money_ball_team_score ?? null
const holes = data?.holes ?? []
const par = holes.length > 0 ? holes.reduce((s, h) => s + h.par, 0) : null
return { round_id: f.round_id, name: f.name, isAnchor: f.is_anchor, teamScore, par, through: holes.length }
}),
)
if (!cancelled) setStandings(results)
}
void load()
return () => {
cancelled = true
}
}, [roundId, playFormat, refreshKey])
return standings
}
function useLeaderboardData(roundId: string) {
const [round, setRound] = useState<ApiRoundHeader | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
fetch(`/rounds/${roundId}`, { credentials: "include" }),
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
])
if (roundRes.status === 403 || roundRes.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!roundRes.ok || !leaderboardRes.ok) {
if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.")
return
}
const roundData: ApiRoundHeader = await roundRes.json()
const leaderboardData: ApiLeaderboard = await leaderboardRes.json()
if (!cancelled) {
setRound(roundData)
setLeaderboard(leaderboardData)
}
}
void load()
return () => {
cancelled = true
}
}, [roundId, refreshKey])
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
return { round, leaderboard, viewerParticipantId, error, refreshKey }
}
// ===========================================================================
// Adaptere: ekte API-data -> V0 sine props (ingen mock-data noe sted)
// ===========================================================================
function buildHoleOrder(startHole: number, holesPlanned: number): number[] {
return Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1)
}
function buildHoles(entries: ApiLeaderboardEntry[], holeOrder: number[]): Hole[] {
const anyEntryWithHoles = entries.find((e) => e.holes.length > 0)
if (!anyEntryWithHoles) return []
return holeOrder
.map((n) => anyEntryWithHoles.holes.find((h) => h.hole_number === n))
.filter((h): h is ApiLeaderboardHole => h !== undefined)
.map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
}
function buildIndividualPlayers(
entries: ApiLeaderboardEntry[],
participants: ApiRoundParticipant[],
holeOrder: number[],
viewerParticipantId: string | null,
ownPointsField: "bbb_points" | "copenhagen_points" | undefined,
formatResult: ApiFormatResult | null,
): IndividualPlayer[] {
const pointsByHoleNumber = new Map<number, Map<string, number>>()
if (ownPointsField && formatResult?.holes) {
for (const h of formatResult.holes) {
const m = new Map<string, number>()
for (const e of h.entries) {
if (e.points !== null) m.set(e.id, e.points)
}
pointsByHoleNumber.set(h.hole_number, m)
}
}
const hcpByParticipant = new Map(participants.map((p) => [p.id, p.handicap_index_snapshot]))
return entries.map((e) => {
const gross = holeOrder.map((n) => {
const h = e.holes.find((x) => x.hole_number === n)
return h && h.played && h.score !== null ? h.score : null
})
// Ekte per-hull mottatte slag -- allerede korrekt utregnet av backend
// (samme allokering `allocate_strokes_by_index` som resten av appen
// bruker), null når HCP ikke spores for deltakeren. IKKE en egen
// klientsidig course-hcp+stroke-indeks-utregning (V0 sin mock-
// versjon måtte gjøre det siden den ikke hadde et ekte backend).
const strokesReceived = holeOrder.map((n) => {
const h = e.holes.find((x) => x.hole_number === n)
return h?.strokes_received ?? null
})
const holePoints = ownPointsField
? holeOrder.map((n) => pointsByHoleNumber.get(n)?.get(e.participant_id) ?? null)
: undefined
return {
id: e.participant_id,
name: e.display_name,
isSelf: e.participant_id === viewerParticipantId,
isOwner: e.is_owner,
strokesReceived,
hcpIndex: hcpByParticipant.get(e.participant_id) ?? null,
finished: false,
gross,
holePoints,
}
})
}
function buildSideTeams(round: ApiRoundHeader, entries: ApiLeaderboardEntry[]): [SideTeamInfo, SideTeamInfo] {
const nameByParticipant = new Map(entries.map((e) => [e.participant_id, e.display_name]))
const sideIds = round.sides.map((s) => s.id)
const namesFor = (sideId: string) =>
round.participants.filter((p) => p.round_side_id === sideId).map((p) => nameByParticipant.get(p.id) ?? "Medspiller")
return [
{ id: "a", name: round.sides[0]?.label?.trim() || "Side A", players: namesFor(sideIds[0]) },
{ id: "b", name: round.sides[1]?.label?.trim() || "Side B", players: namesFor(sideIds[1]) },
]
}
function buildHoleWinners(holeResults: string[] | null, totalHoles: number): HoleWinner[] {
const out: HoleWinner[] = Array.from({ length: totalHoles }, () => null)
if (!holeResults) return out
holeResults.forEach((r, i) => {
if (i < totalHoles && (r === "a" || r === "b" || r === "halved")) out[i] = r
})
return out
}
const METRICS_FOR_FORMAT: Record<string, { metrics: MetricKey[]; defaultMetric: MetricKey; pointsUnit: string }> = {
stroke: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" },
stableford: { metrics: ["stableford", "netto", "brutto"], defaultMetric: "stableford", pointsUnit: "p" },
skins: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" },
flag: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" },
bbb: { metrics: ["points", "brutto", "netto"], defaultMetric: "points", pointsUnit: "p" },
copenhagen: { metrics: ["points", "brutto", "netto"], defaultMetric: "points", pointsUnit: "p" },
}
// ===========================================================================
// Full leaderboard-side
// ===========================================================================
export function RoundLeaderboard({
roundId,
embedded = false,
}: {
roundId: string
embedded?: boolean
}) {
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
const formatResult = useFormatResult(roundId, refreshKey)
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey)
if (error) {
return (
<div className="flex min-h-dvh 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="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
</Link>
</div>
)
}
if (!round || !leaderboard) {
return (
<div className="flex min-h-dvh 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 holeOrder = buildHoleOrder(round.start_hole, round.holes_planned)
const holes = buildHoles(leaderboard.entries, holeOrder)
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
const isTeamResult = TEAM_RESULT_FORMATS.has(round.play_format)
const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format]
return (
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<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={`/my-rounds/${roundId}`}
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" />
Leaderboard
</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">
{round.name?.trim() || round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
</span>
</div>
{isTwoSided || isHighLow ? (
!formatResult ? null : !formatResult.ready ? (
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
Venter at begge sider er komplette og har beregnet handicap før stillingen kan vises.
</div>
) : isHighLow ? (
(() => {
const [sideA, sideB] = buildSideTeams(round, leaderboard.entries)
const duels = (formatResult.holes ?? []).map(highLowDuelForHole)
return (
<HighLowBoard
sideA={sideA}
sideB={sideB}
duels={duels}
totalA={formatResult.hlh_points_a ?? 0}
totalB={formatResult.hlh_points_b ?? 0}
formatName="High-low-high"
/>
)
})()
) : (
(() => {
const [sideA, sideB] = buildSideTeams(round, leaderboard.entries)
const results = buildHoleWinners(formatResult.hole_results, round.holes_planned)
const lead = formatResult.match_lead ?? 0
const leader: "a" | "b" | null = lead > 0 ? "a" : lead < 0 ? "b" : null
const holesPlayed = formatResult.match_holes_played ?? 0
const holesRemaining = formatResult.match_holes_remaining ?? 0
const diff = Math.abs(lead)
// V0 sin egen, korte headline-tekst ("2 opp"/"Dormie 2 opp"),
// IKKE backend sin `match_status_text` (som bevisst bygger inn
// side-NAVNET, "2 UP (Lag Bjørk)" -- designet for et sted i
// appen UTEN separate side-navn ved siden av, ulikt her hvor
// SideName allerede viser navnet på hver side. Gjenbrukt
// ordlyd, men mot EKTE tall (match_lead/-holes_played/
// -remaining/-is_dormie/-is_closed) -- ingen egen klientsidig
// utregning av selve stillingen.
const headline =
holesPlayed === 0
? "Ingen hull spilt ennå"
: formatResult.match_is_closed
? `Ferdig ${diff}&${holesRemaining}`
: holesRemaining === 0
? leader
? `Vant ${diff} opp`
: "Delt"
: !leader
? "Alt likt"
: formatResult.match_is_dormie
? `Dormie ${diff} opp`
: `${diff} opp`
const sub = holesPlayed > 0 ? `${holesPlayed} hull spilt${holesRemaining > 0 ? `, ${holesRemaining} igjen` : ""}` : ""
return (
<TwoSidedBoard
sideA={sideA}
sideB={sideB}
results={results}
totalHoles={round.holes_planned}
formatName="Matchstatus"
headline={headline}
sub={sub}
leader={leader}
isDormie={Boolean(formatResult.match_is_dormie)}
isClosed={Boolean(formatResult.match_is_closed)}
/>
)
})()
)
) : isTeamResult ? (
(() => {
const teamScore = round.play_format === "shamble" ? formatResult?.shamble_team_score ?? null : formatResult?.money_ball_team_score ?? null
const resultHoles = formatResult?.holes ?? []
const par = resultHoles.length > 0 ? resultHoles.reduce((s, h) => s + h.par, 0) : null
const toPar = teamScore !== null && par !== null ? teamScore - par : null
const flight: FlightTeam[] = (standings ?? [])
.filter((s) => s.teamScore !== null && s.par !== null)
.map((s) => ({
id: s.round_id,
name: s.name || round.course_name_snapshot,
toPar: (s.teamScore as number) - (s.par as number),
through: s.through,
isThisTeam: s.round_id === roundId,
}))
const members = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, undefined, null)
return (
<TeamFlightBoard
teamName={round.name?.trim() || round.course_name_snapshot}
toPar={toPar}
through={resultHoles.length}
totalHoles={round.holes_planned}
flight={flight}
members={members}
holes={holes}
formatName={round.play_format === "shamble" ? "Shamble" : "Money Ball"}
/>
)
})()
) : (
(() => {
const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult)
const config = METRICS_FOR_FORMAT[round.play_format] ?? METRICS_FOR_FORMAT.stroke
return (
<>
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<IndividualBoard
players={players}
metrics={config.metrics}
defaultMetric={config.defaultMetric}
pointsUnit={config.pointsUnit}
holes={holes}
/>
</>
)
})()
)}
</main>
</div>
)
}
// --- Kompakt mini-variant, til rundens egen detaljside --------------------
// Topp 3 (brutto) + "og N til", lenke til hele leaderboardet. Kun relevant
// for runder med mer enn én deltaker.
export function RoundLeaderboardMini({ roundId }: { roundId: string }) {
const { round, leaderboard, viewerParticipantId } = useLeaderboardData(roundId)
if (!round || !leaderboard || round.participants.length < 2) return null
// To-sidede formater (match/fourball/foursome/greensome/scramble/
// high_low_high) vises allerede av FormatResultPanel rett under denne
// komponenten sitt kallsted i round-detail.tsx -- ingen individuell
// brutto-rangering å vise her. Shamble/Money Ball har heller ingen
// meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er det
// som teller, vist i TeamFlightBoard på selve leaderboard-siden i
// stedet for en misvisende råscore-rangering her.
if (
TWO_SIDED_FORMATS.includes(round.play_format) ||
HIGH_LOW_FORMATS.has(round.play_format) ||
TEAM_RESULT_FORMATS.has(round.play_format)
) {
return null
}
const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId)
const top = ranked.slice(0, 3)
const restCount = ranked.length - top.length
return (
<Link
href={`/my-rounds/${roundId}/leaderboard`}
className="mb-5 flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/30"
>
<span className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
<Trophy aria-hidden="true" className="size-4 text-primary" />
Leaderboard
</span>
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</span>
<span className="flex flex-col gap-1">
{top.map((entry) => (
<span key={entry.participant_id} className="flex items-center gap-2 text-sm">
<span className="w-8 shrink-0 font-bold tabular-nums text-muted-foreground">
{entry.rank !== null ? (entry.tied ? `T-${entry.rank}` : `#${entry.rank}`) : ""}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-foreground">{entry.display_name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{entry.score_to_par !== null ? formatToPar(entry.score_to_par) : ""}
</span>
</span>
))}
</span>
{restCount > 0 && <span className="text-sm text-muted-foreground">og {restCount} til</span>}
</Link>
)
}