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