teecup/frontend/components/round-leaderboard.tsx
2026-08-07 22:02:56 +02:00

1883 lines
75 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, MessageSquare, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
import { MatchScorecardGrid } from "@/components/round-scorecard"
// --- 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
// Felt under er allerede returnert av samme `${apiPrefix}/{roundId}`-
// endepunkt (samme JSON som round-scorecard.tsx sin `ApiParticipant`
// fetcher, se der) -- bare ikke tidligere typet her, siden de ikke
// trengtes før MatchScorecardGrid-gjenbruken (ny utvid-seksjon i
// TwoSidedBoard/HighLowBoard). `guest_name` er valgfritt -- fraværende i
// publicMode (PublicRoundParticipantOut har ikke feltet), defaultes til
// null der (se toGridRound under) -- ubrukt av MatchScorecardGrid uansett
// (den leser kun display_name).
guest_name?: string | null
display_name: string
is_owner: boolean
playing_handicap: number | null
}
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
// Samme "allerede i JSON-en, bare ikke tidligere typet"-begrunnelse som
// ApiRoundParticipant sine nye felt over.
tee_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",
// scramble_solo_match (migrasjon 057) -- ekte hull-for-hull matchspill
// (compute_match_state på backend-siden), samme generiske match_lead/
// match_holes_played/-remaining/is_closed/is_dormie-felter som resten
// av denne listen. Asymmetrisk lag-mot-individuell-struktur (IKKE
// fast antall spillere per side), men TwoSidedBoard/buildSideTeams
// grener allerede på FAKTISK antall spillere per side, ikke et fast
// tall -- bekreftet trygt å gjenbruke uendret. scramble_solo (stroke-
// varianten, migrasjon 056) er BEVISST IKKE her, se SCRAMBLE_SOLO_
// FORMATS under.
"scramble_solo_match",
]
// 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"])
// Scramble mot enkeltspiller (migrasjon 056) -- BEVISST IKKE i
// TWO_SIDED_FORMATS: dette er netto SLAGSPILL-sammenligning
// (net_stroke_play_margin), ikke matchspill/hull-ledelse -- egen enkel
// visning, se ScrambleSoloResultView.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo"])
// 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",
}
// Egen lokal kopi per prosjektets konvensjon (liten duplisering fremfor
// cross-file-import, se samme mønster i round-detail.tsx/round-scorecard.tsx).
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
match: "Match",
skins: "Skins",
fourball: "Fourball",
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
chapman: "Chapman",
copenhagen: "Københavner",
bbb: "Bingo Bango Bongo",
flag: "Flaggturnering",
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
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,
commentsHref,
}: {
players: IndividualPlayer[]
metrics: MetricKey[]
defaultMetric: MetricKey
pointsUnit?: string
holes: Hole[]
commentsHref: string
}) {
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))}
commentsHref={commentsHref}
/>
))}
{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))}
commentsHref={commentsHref}
/>
))}
</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,
commentsHref,
}: {
player: IndividualPlayer
rank: number | null
tied: boolean
isLeader: boolean
pill: React.ReactNode
reason?: string
metric: MetricKey
holes: Hole[]
open: boolean
onToggle: () => void
commentsHref: string
}) {
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} rank={rank} tied={tied} commentsHref={commentsHref} />
</div>
)}
</li>
)
}
function HoleByHole({
player,
metric,
holes,
rank,
tied,
commentsHref,
}: {
player: IndividualPlayer
metric: MetricKey
holes: Hole[]
rank: number | null
tied: boolean
commentsHref: string
}) {
const front = holes.slice(0, 9)
const back = holes.slice(9)
const totalPar = holes.reduce((s, h) => s + h.par, 0)
const totalGross = player.gross.reduce<number>((s, g) => s + (g ?? 0), 0)
const holesWithScore = player.gross.filter((g) => g !== null).length
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 className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-border pt-2">
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
Par {totalPar} · Score {holesWithScore > 0 ? totalGross : ""}
{rank !== null ? ` · ${tied ? "Delt " : ""}${rank}. plass` : ""}
</span>
<Link
href={commentsHref}
className="inline-flex h-11 items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
>
<MessageSquare aria-hidden="true" className="size-4" />
Kommentarer
</Link>
</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,
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
results: HoleWinner[]
totalHoles: number
formatName: string
headline: string
sub: string
leader: "a" | "b" | null
isDormie: boolean
isClosed: boolean
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
const [showDetail, setShowDetail] = useState(false)
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} />
<MatchDetailSection
showDetail={showDetail}
onToggle={() => setShowDetail((v) => !v)}
round={round}
roundId={roundId}
viewerId={viewerId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
</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>
)
}
// Delt av TwoSidedBoard/HighLowBoard (ADR-047) -- ÉN utvidbar detaljseksjon
// for HELE kampen (ikke et per-rad-mønster, siden formatet kun har 2 sider,
// ikke en spillerliste). Viser faktiske hull-for-hull-SCORER (ikke bare
// hvem som vant hullet, som HoleWinners/DuelRow allerede gjør statisk over)
// via den allerede eksisterende, nå eksporterte MatchScorecardGrid --
// samme scorekort som `/my-rounds/{id}/scorecard` viser, ingen ny visuell
// design. `MatchScorecardGrid` gjør sin egen `useFormatResult`-henting
// internt, så dette henter kun ved faktisk utvidelse, ikke ved mount.
// `MatchScorecardGrid` (round-scorecard.tsx) sin `ApiParticipant.guest_name`
// er required (string|null), men `publicMode` sin `PublicRoundParticipantOut`
// har ikke feltet i det hele tatt (undefined ved kjøretid) -- defaultes til
// null her. Trygt: MatchScorecardGrid leser aldri guest_name (kun
// display_name via participantLabel), så dette endrer ingen visning.
function toGridRound(round: ApiRoundHeader) {
return {
...round,
participants: round.participants.map((p) => ({ ...p, guest_name: p.guest_name ?? null })),
}
}
function MatchDetailSection({
showDetail,
onToggle,
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
}: {
showDetail: boolean
onToggle: () => void
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
return (
<div className="rounded-2xl border border-border bg-card">
<button
type="button"
onClick={onToggle}
aria-expanded={showDetail}
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">Vis hull-for-hull med score</span>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", showDetail && "rotate-180")} />
</button>
{showDetail && (
<div className="flex flex-col gap-3 border-t border-border p-3">
<MatchScorecardGrid round={toGridRound(round)} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
<Link
href={commentsHref}
className="inline-flex h-11 w-fit items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
>
<MessageSquare aria-hidden="true" className="size-4" />
Kommentarer
</Link>
</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,
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
duels: HoleDuel[]
totalA: number
totalB: number
formatName: string
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
const [showDetail, setShowDetail] = useState(false)
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>
<MatchDetailSection
showDetail={showDetail}
onToggle={() => setShowDetail((v) => !v)}
round={round}
roundId={roundId}
viewerId={viewerId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
</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,
commentsHref,
}: {
teamName: string
toPar: number | null
through: number
totalHoles: number
flight: FlightTeam[]
members: IndividualPlayer[]
holes: Hole[]
formatName: string
commentsHref: string
}) {
const [showRaw, setShowRaw] = useState(false)
const [openMemberId, setOpenMemberId] = useState<string | null>(null)
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) => (
<TeamMemberRow
key={m.id}
member={m}
holes={holes}
totalHoles={totalHoles}
open={openMemberId === m.id}
onToggle={() => setOpenMemberId((cur) => (cur === m.id ? null : m.id))}
commentsHref={commentsHref}
/>
))}
</ol>
)}
</div>
</div>
)
}
// Utvidbar rad for ETT medlem av flighten (ADR-047) -- samme mønster
// (klikk → hull-for-hull via HoleByHole) som IndividualRow, men uten
// rangeringskolonnen (et lags medlemmer rangeres ikke seg imellom her,
// kun laget som helhet gjør, vist i flight-gruppen over).
function TeamMemberRow({
member,
holes,
totalHoles,
open,
onToggle,
commentsHref,
}: {
member: IndividualPlayer
holes: Hole[]
totalHoles: number
open: boolean
onToggle: () => void
commentsHref: string
}) {
const panelId = `tfm-${member.id}`
return (
<li className="border-b border-border last:border-b-0">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-controls={panelId}
className="flex min-h-14 w-full items-center gap-3 px-4 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
{member.name}
{member.isSelf && <Badge tone="self">Deg</Badge>}
</span>
<span className="text-sm text-muted-foreground">
{member.hcpIndex !== null ? `Hcp ${NF1.format(member.hcpIndex)}` : "Uten HCP"} · {playedHoles(member)}/{totalHoles} hull
</span>
</span>
<MetricPill kind="topar" value={grossToPar(member, holes)} />
<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-4 pb-3">
<HoleByHole player={member} metric="brutto" holes={holes} rank={null} tied={false} commentsHref={commentsHref} />
</div>
)}
</li>
)
}
// ===========================================================================
// 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
// Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
// side "a" = laget, side "b" = individualspilleren (fast, se
// _build_scramble_solo_result). margin > 0 = laget leder.
scramble_solo_team_net: number | null
scramble_solo_individual_net: number | null
scramble_solo_margin: number | null
holes: ApiFormatHole[] | null
}
function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
fetch(`${apiPrefix}/${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, apiPrefix])
return result
}
// Bygger ScrambleSoloResultView sin props-form fra det generiske
// ApiFormatResult-svaret. Hull-entryen sitt `label`-felt (satt av
// _build_scramble_solo_result til deltakerens/lagets faktiske
// visningsnavn) foretrekkes over sidens EGEN label (som bare er den
// faste "Laget"/"Individuell motstander"-teksten satt ved opprettelse,
// se new-round.tsx sin submitWizard) -- faller tilbake til sidens label
// når ingen hull er spilt ennå.
function buildScrambleSoloResult(round: ApiRoundHeader, formatResult: ApiFormatResult | null): ScrambleSoloResult {
const teamSide = round.sides.find((s) => s.side_role === "team")
const individualSide = round.sides.find((s) => s.side_role === "individual")
const firstHoleEntries = formatResult?.holes?.[0]?.entries ?? []
const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
const individualLabel =
firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"
const holes: ScrambleSoloResult["holes"] = (formatResult?.holes ?? []).map((h) => {
const teamEntry = h.entries.find((e) => e.side === "a")
const individualEntry = h.entries.find((e) => e.side === "b")
const result: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
return {
holeNumber: h.hole_number,
par: h.par,
strokeIndex: h.stroke_index,
result,
team: {
gross: teamEntry?.gross ?? 0,
net: teamEntry?.net ?? 0,
strokesReceived: teamEntry?.strokes_received ?? 0,
},
individual: {
gross: individualEntry?.gross ?? 0,
net: individualEntry?.net ?? 0,
strokesReceived: individualEntry?.strokes_received ?? 0,
},
}
})
return {
ready: formatResult?.ready ?? false,
teamLabel,
individualLabel,
teamNet: formatResult?.scramble_solo_team_net ?? null,
individualNet: formatResult?.scramble_solo_individual_net ?? null,
margin: formatResult?.scramble_solo_margin ?? null,
holes,
}
}
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,
apiPrefix: string = "/rounds",
) {
const [standings, setStandings] = useState<FlightSibling[] | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const flightRes = await fetch(`${apiPrefix}/${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(`${apiPrefix}/${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, apiPrefix])
return standings
}
function useLeaderboardData(roundId: string, apiPrefix: string = "/rounds") {
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)
const publicMode = apiPrefix !== "/rounds"
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 wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId, publicMode])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
fetch(`${apiPrefix}/${roundId}`, { credentials: "include" }),
fetch(`${apiPrefix}/${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, apiPrefix])
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,
publicMode = false,
}: {
roundId: string
embedded?: boolean
// ADR-044 (tilskuer-visning, 2026-08-06) -- samme mønster som
// `RoundScorecard` sin `publicMode`: bytter alle `/rounds/*`-kall til
// `/public/rounds/*` og WS-kanalen til den offentlige. INGEN endring
// i eiersidens oppførsel når denne er `false` (standard).
publicMode?: boolean
}) {
const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId, apiPrefix)
const formatResult = useFormatResult(roundId, refreshKey, apiPrefix)
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey, apiPrefix)
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={publicMode ? "/dashboard" : "/my-rounds"}
className="text-base font-semibold text-primary underline underline-offset-2"
>
{publicMode ? "Tilbake til dashbordet" : "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>
)
}
// Delt kommentartråd (ADR-047) -- round_message-reaksjoner/-kommentarer
// fester seg til ETT spesifikt innlegg, ikke runden som helhet, så
// hver utvidet rad/kamp-seksjon lenker til den SAMME, allerede
// eksisterende kommentarseksjonen på selve rundesiden (anker-id
// "kommentarer" i round-detail.tsx/watch-round.tsx) i stedet for å late
// som om det finnes en egen tråd per spiller/kamp.
const commentsHref = publicMode ? `/watch/${roundId}#kommentarer` : `/my-rounds/${roundId}#kommentarer`
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 isScrambleSolo = SCRAMBLE_SOLO_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={publicMode ? `/watch/${roundId}` : `/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="flex flex-wrap items-center gap-2">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</span>
{round.play_format !== "stroke" && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
{FORMAT_LABELS[round.play_format] ?? round.play_format}
</span>
)}
</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"
round={round}
roundId={roundId}
viewerId={viewerParticipantId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
)
})()
) : (
(() => {
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)}
round={round}
roundId={roundId}
viewerId={viewerParticipantId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
)
})()
)
) : 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"}
commentsHref={commentsHref}
/>
)
})()
) : isScrambleSolo ? (
!formatResult ? null : <ScrambleSoloResultView result={buildScrambleSoloResult(round, formatResult)} />
) : (
(() => {
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}
commentsHref={commentsHref}
/>
</>
)
})()
)}
</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/scramble_solo_match) 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. scramble_solo
// (stroke-varianten) har av samme grunn ingen meningsfull individuell
// rangering -- lag-mot-individuell netto-sammenligning vises av
// ScrambleSoloResultView i stedet (fikset her, opprinnelig glemt da
// scramble_solo ble bygget 2026-08-05).
if (
TWO_SIDED_FORMATS.includes(round.play_format) ||
HIGH_LOW_FORMATS.has(round.play_format) ||
TEAM_RESULT_FORMATS.has(round.play_format) ||
SCRAMBLE_SOLO_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>
)
}