teecup/frontend/components/round-leaderboard.tsx

1919 lines
77 KiB
TypeScript
Raw Normal View History

"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"
2026-08-24 09:55:04 +02:00
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Target, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
2026-08-07 22:02:56 +02:00
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[]
2026-08-24 09:55:04 +02:00
// "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av
// huntNettoToPar() til å regne ut hunt-visningens tall klientsidig.
course_handicap: number | null
}
type ApiLeaderboard = {
holes_planned: number
completed: boolean
entries: ApiLeaderboardEntry[]
2026-08-24 09:55:04 +02:00
net_display_style: "standard" | "hunt"
}
type ApiRoundParticipant = {
id: string
user_id: string | null
round_side_id: string | null
handicap_index_snapshot: number | null
2026-08-07 22:02:56 +02:00
// 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
2026-08-07 22:02:56 +02:00
// 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)[]
2026-08-24 09:55:04 +02:00
// "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av
// huntNettoToPar() (i stedet for strokesReceived, som er gradvis/
// partial-allokert). Samme null-betydning som hcpIndex/strokesReceived.
courseHandicap: 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
}
2026-08-24 09:55:04 +02:00
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- trekker fra HELE
// spillehandicapet med én gang i stedet for kun de slagene som er "tjent
// inn" for de spilte hullene (nettoToPar over). Konvergerer til NØYAKTIG
// samme tall som nettoToPar når alle 18 hull er spilt.
function huntNettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
if (p.courseHandicap === null) return null
let diff = 0
p.gross.forEach((g, i) => {
if (g !== null) diff += g - holes[i].par
})
return diff - p.courseHandicap
}
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[],
2026-08-24 09:55:04 +02:00
netDisplayStyle: "standard" | "hunt" = "standard",
): {
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") {
2026-08-24 09:55:04 +02:00
const v = netDisplayStyle === "hunt" ? huntNettoToPar(p, holes) : 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,
2026-08-07 22:02:56 +02:00
commentsHref,
2026-08-24 09:55:04 +02:00
netDisplayStyle = "standard",
}: {
players: IndividualPlayer[]
metrics: MetricKey[]
defaultMetric: MetricKey
pointsUnit?: string
holes: Hole[]
2026-08-07 22:02:56 +02:00
commentsHref: string
2026-08-24 09:55:04 +02:00
// "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style.
netDisplayStyle?: "standard" | "hunt"
}) {
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
const [openId, setOpenId] = useState<string | null>(null)
2026-08-24 09:55:04 +02:00
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes, netDisplayStyle) }))
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))}
2026-08-07 22:02:56 +02:00
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))}
2026-08-07 22:02:56 +02:00
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,
2026-08-07 22:02:56 +02:00
commentsHref,
}: {
player: IndividualPlayer
rank: number | null
tied: boolean
isLeader: boolean
pill: React.ReactNode
reason?: string
metric: MetricKey
holes: Hole[]
open: boolean
onToggle: () => void
2026-08-07 22:02:56 +02:00
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">
2026-08-07 22:02:56 +02:00
<HoleByHole player={player} metric={metric} holes={holes} rank={rank} tied={tied} commentsHref={commentsHref} />
</div>
)}
</li>
)
}
2026-08-07 22:02:56 +02:00
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)
2026-08-07 22:02:56 +02:00
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>
)
})}
2026-08-07 22:02:56 +02:00
<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,
2026-08-07 22:02:56 +02:00
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
2026-08-07 22:02:56 +02:00
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
2026-08-07 22:02:56 +02:00
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} />
2026-08-07 22:02:56 +02:00
<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>
)
}
2026-08-07 22:02:56 +02:00
// 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,
2026-08-07 22:02:56 +02:00
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
2026-07-29 12:44:51 +02:00
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
duels: HoleDuel[]
totalA: number
totalB: number
formatName: string
2026-08-07 22:02:56 +02:00
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
2026-07-29 12:44:51 +02:00
}) {
2026-08-07 22:02:56 +02:00
const [showDetail, setShowDetail] = useState(false)
const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null
const fmt = (n: number) => NF1.format(n)
2026-07-29 12:44:51 +02:00
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>
2026-07-29 12:44:51 +02:00
</div>
</div>
2026-08-07 22:02:56 +02:00
<MatchDetailSection
showDetail={showDetail}
onToggle={() => setShowDetail((v) => !v)}
round={round}
roundId={roundId}
viewerId={viewerId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
2026-07-29 12:44:51 +02:00
</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,
2026-08-07 22:02:56 +02:00
commentsHref,
}: {
teamName: string
toPar: number | null
through: number
totalHoles: number
flight: FlightTeam[]
members: IndividualPlayer[]
holes: Hole[]
formatName: string
2026-08-07 22:02:56 +02:00
commentsHref: string
}) {
const [showRaw, setShowRaw] = useState(false)
2026-08-07 22:02:56 +02:00
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) => (
2026-08-07 22:02:56 +02:00
<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>
)
}
2026-08-07 22:02:56 +02:00
// 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
}
2026-08-07 22:02:56 +02:00
function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
2026-08-07 22:02:56 +02:00
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
}
2026-08-07 22:02:56 +02:00
}, [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 }
2026-08-07 22:02:56 +02:00
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() {
2026-08-07 22:02:56 +02:00
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) => {
2026-08-07 22:02:56 +02:00
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
}
2026-08-07 22:02:56 +02:00
}, [roundId, playFormat, refreshKey, apiPrefix])
return standings
}
2026-08-07 22:02:56 +02:00
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)
2026-08-07 22:02:56 +02:00
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:"
2026-08-07 22:02:56 +02:00
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()
2026-08-07 22:02:56 +02:00
}, [roundId, publicMode])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
2026-08-07 22:02:56 +02:00
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
}
2026-08-07 22:02:56 +02:00
}, [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,
2026-08-24 09:55:04 +02:00
courseHandicap: e.course_handicap,
}
})
}
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
// ===========================================================================
2026-08-02 16:34:54 +02:00
export function RoundLeaderboard({
roundId,
embedded = false,
2026-08-07 22:02:56 +02:00
publicMode = false,
2026-08-02 16:34:54 +02:00
}: {
roundId: string
embedded?: boolean
2026-08-07 22:02:56 +02:00
// 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
2026-08-02 16:34:54 +02:00
}) {
2026-08-07 22:02:56 +02:00
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>
2026-08-07 22:02:56 +02:00
<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>
)
}
2026-08-07 22:02:56 +02:00
// 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 (
2026-08-02 16:34:54 +02:00
<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
2026-08-07 22:02:56 +02:00
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
2026-08-02 16:34:54 +02:00
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">
2026-07-29 12:44:51 +02:00
<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"
2026-08-07 22:02:56 +02:00
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)}
2026-08-07 22:02:56 +02:00
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"}
2026-08-07 22:02:56 +02:00
commentsHref={commentsHref}
2026-07-29 12:44:51 +02:00
/>
)
})()
) : 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>
2026-08-24 09:55:04 +02:00
{leaderboard.net_display_style === "hunt" && (
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
Jakter Jesper: alle starter minus spillehandicapet sitt -- lavest sluttsum vinner.
</p>
</div>
)}
<IndividualBoard
players={players}
metrics={config.metrics}
defaultMetric={config.defaultMetric}
pointsUnit={config.pointsUnit}
holes={holes}
2026-08-07 22:02:56 +02:00
commentsHref={commentsHref}
2026-08-24 09:55:04 +02:00
netDisplayStyle={leaderboard.net_display_style}
/>
</>
)
})()
)}
</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>
)
}