teecup/frontend/components/round-scorecard.tsx
Erol Haagenrud 071874e8c8 Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00

894 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
// Horisontalt scorekort for en frittstående runde (ADR-033) -- egen,
// dedikert side (ikke en del av round-stats.tsx sin aggregerte statistikk).
// Presentasjon fra V0 (zip 17), datalag skrevet om fra mock til ekte fetch
// mot samme `/rounds/{id}/participants/{id}/holes`-endepunkt som
// round-detail.tsx og round-stats.tsx allerede bruker.
//
// Bevisst forskjell fra V0s eksport: V0s egen `strokesReceived()` var en
// generisk modulo-formel -- byttet ut med backend sin ALLEREDE beregnede
// `strokes_received` per hull (samme `allocate_strokes_by_index()`-
// algoritme som resten av appen), for å unngå to ulike implementasjoner av
// samme HCP-slagfordeling.
//
// Format-bevissthet lagt til 2026-07-28: brukeren rapporterte at siden kun
// noensinne viste brutto/netto/stableford, uansett spilleform -- bekreftet
// ved simulering at (a) foursome/greensome/scramble var HELT BLANKE her
// (delt-ball-formater lagrer score PER SIDE, ikke per deltaker -- se
// ADR-039 -- og denne siden spurte kun mot deltaker-endepunktet), og (b)
// match/fourball/skins viste riktige rå tall, men aldri HVORFOR et hull
// ble avgjort som det ble (brutto vs. netto, hvem sitt netto talte for
// siden i fourball, hvem som vant/hvilket hull som rullet videre i skins).
// Fikset ved å: (1) la delt-ball-formater bruke SIDER som "enhet" i stedet
// for deltakere (samme visuelle ScoreBlock-tabell, bare på
// `/rounds/{id}/sides/{id}/holes`, som fikk et nytt `strokes_received`-felt
// samtidig), og (2) legge til en egen "Matchforløp"/"Skins hull for hull"-
// seksjon drevet av `/rounds/{id}/format-result` sitt nye `holes`-felt
// (brutto/netto/slag mottatt PER enhet PER hull, pluss hvem som vant og
// hvorfor).
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
display_name: string
is_owner: boolean
round_side_id: string | null
}
type ApiSide = {
id: string
label: string | null
}
type ApiRound = {
name: string | null
id: string
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
play_format: string
participants: ApiParticipant[]
sides: ApiSide[]
}
type ApiHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
strokes_received: number | null
}
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"]
const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS]
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
match: "Match",
skins: "Skins",
fourball: "Fourball",
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
// begrunnelse i round-detail.tsx sin playerLabel().
function participantLabel(p: ApiParticipant, viewerId: string | null): string {
return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name
}
// --- Scoring helpers -------------------------------------------------------
function netScore(h: ApiHole): number | null {
return h.played && h.score !== null && h.strokes_received !== null ? h.score - h.strokes_received : null
}
// Stableford: 2 pts for net par, +/- 1 per stroke, floored at 0.
function stablefordPoints(h: ApiHole): number | null {
const net = netScore(h)
if (net === null) return null
return Math.max(0, h.par - net + 2)
}
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
function classify(gross: number, par: number): ScoreClass {
const diff = gross - par
if (diff <= -2) return "eagle"
if (diff === -1) return "birdie"
if (diff === 0) return "par"
if (diff === 1) return "bogey"
return "double"
}
function sum(values: number[]): number {
return values.reduce((a, b) => a + b, 0)
}
function signedToPar(score: number, par: number): string {
const diff = score - par
if (diff === 0) return "E"
return diff > 0 ? `+${diff}` : `${Math.abs(diff)}`
}
// --- Score mark (the visual language for the Score row) --------------------
// Shape encodes direction (circle = under par, square = over par) so meaning
// survives without color; fill encodes magnitude (filled = 2+ strokes off).
function ScoreMark({ gross, par }: { gross: number | null; par: number }) {
const shared =
"flex size-6 items-center justify-center text-sm font-extrabold tabular-nums sm:size-7"
if (gross === null) {
return <span className={cn(shared, "text-muted-foreground")}>{""}</span>
}
const kind = classify(gross, par)
if (kind === "eagle") {
return (
<span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{gross}</span>
)
}
if (kind === "birdie") {
return (
<span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>
{gross}
</span>
)
}
if (kind === "bogey") {
return (
<span
className={cn(
shared,
"rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{gross}
</span>
)
}
if (kind === "double") {
return (
<span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>
{gross}
</span>
)
}
// par
return <span className={cn(shared, "font-bold text-foreground")}>{gross}</span>
}
// --- One nine-hole block (a real table for row/column semantics) -----------
function ScoreBlock({
holes,
summaryLabel,
showStableford,
}: {
holes: ApiHole[]
summaryLabel: string
showStableford: boolean
}) {
const parSum = sum(holes.map((h) => h.par))
const playedHoles = holes.filter((h) => h.played && h.score !== null)
const grossSum = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null
const nettoValues = holes.map(netScore).filter((n): n is number => n !== null)
const nettoSum = nettoValues.length > 0 ? sum(nettoValues) : null
const pointsValues = holes.map(stablefordPoints).filter((n): n is number => n !== null)
const pointsSum = pointsValues.length > 0 ? sum(pointsValues) : null
const cell = "border border-border text-center tabular-nums"
const sumCell = "border border-border bg-muted text-center font-extrabold tabular-nums"
const rowLabel =
"border border-border px-1 text-left text-xs font-bold text-muted-foreground"
return (
<table className="w-full table-fixed border-collapse">
<caption className="sr-only">
Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number}
</caption>
<colgroup>
<col className="w-9" />
{holes.map((h) => (
<col key={h.hole_number} />
))}
<col className="w-[11%]" />
</colgroup>
<thead>
<tr>
<th scope="col" className="border border-border bg-muted px-1 py-1 text-left">
<span className="sr-only">Rad</span>
<span aria-hidden="true" className="text-xs font-extrabold text-muted-foreground">
Hull
</span>
</th>
{holes.map((h) => (
<th
key={h.hole_number}
scope="col"
className="border border-border bg-foreground py-1 text-center text-xs font-extrabold tabular-nums text-background"
>
{h.hole_number}
</th>
))}
<th
scope="col"
className="border border-border bg-primary py-1 text-center text-xs font-extrabold uppercase text-primary-foreground"
>
{summaryLabel}
</th>
</tr>
</thead>
<tbody>
{/* Hcp (stroke index) */}
<tr>
<th scope="row" className={rowLabel}>
Hcp
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.stroke_index}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-muted-foreground")} aria-hidden="true">
{""}
</td>
</tr>
{/* Par */}
<tr>
<th scope="row" className={rowLabel}>
Par
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.par}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{parSum}</td>
</tr>
{/* Score — the hero row: highlighted band + shape marks */}
<tr className="bg-primary/5">
<th scope="row" className={cn(rowLabel, "text-sm text-foreground")}>
Score
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "px-0.5 py-1")}>
<span className="flex items-center justify-center">
<ScoreMark gross={h.played ? h.score : null} par={h.par} />
</span>
</td>
))}
<td className={cn(sumCell, "py-1 text-base text-foreground")}>{grossSum ?? ""}</td>
</tr>
{/* Netto */}
<tr>
<th scope="row" className={cn(rowLabel, "text-foreground")}>
Netto
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-bold text-foreground")}>
{netScore(h) ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{nettoSum ?? ""}</td>
</tr>
{/* Stableford (only when relevant) */}
{showStableford && (
<tr>
<th scope="row" className={rowLabel} title="Stableford-poeng">
Poeng
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{stablefordPoints(h) ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{pointsSum ?? ""}</td>
</tr>
)}
</tbody>
</table>
)
}
// --- Legend ----------------------------------------------------------------
function Legend() {
return (
<div
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-border bg-card px-4 py-3"
aria-label="Symbolforklaring"
>
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full border-2 border-primary bg-primary/10 text-xs font-extrabold text-primary">
3
</span>
<span className="text-sm font-semibold text-foreground">Under par (sirkel)</span>
</span>
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-xs font-extrabold text-brand-orange">
5
</span>
<span className="text-sm font-semibold text-foreground">Over par (firkant)</span>
</span>
<span className="text-sm font-medium text-muted-foreground">Fylt symbol = 2 slag eller mer</span>
</div>
)
}
// --- Combined total --------------------------------------------------------
function TotalTile({ label, value, tone }: { label: string; value: string; tone?: "orange" | "green" }) {
return (
<div className="flex flex-col items-center gap-0.5 rounded-2xl border border-border bg-card px-2 py-3">
<span
className={cn(
"text-2xl font-extrabold tabular-nums",
tone === "orange" ? "text-brand-orange" : tone === "green" ? "text-primary" : "text-foreground",
)}
>
{value}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
</div>
)
}
// --- Matchforløp / skins-forløp (2026-07-28) ---------------------------------
// Avledet ved LESING fra GET .../format-result -- viser HVORFOR et hull ble
// avgjort som det ble (brutto vs. netto, slag mottatt, hvem sitt netto
// talte for siden i fourball, hvem som vant/hvilket hull som rullet videre
// i skins) -- ikke bare et vinn/tap-merke.
type ApiFormatHoleEntry = {
id: string
label: string
side: "a" | "b" | null
gross: number | null
net: number | null
strokes_received: number | null
counted: boolean
}
type ApiFormatHoleOut = {
hole_number: number
par: number
stroke_index: number
entries: ApiFormatHoleEntry[]
result: string | null
winner_ids: string[] | null
carried: boolean | null
}
type ApiFormatResult = {
play_format: string
ready: boolean
match_status_text: string | null
skins_won: Record<string, number> | null
holes: ApiFormatHoleOut[] | 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
}
function ResultChip({ result }: { result: string | null }) {
if (!result) return <span className="text-sm text-muted-foreground"></span>
if (result === "halved") {
return (
<span
aria-label="Hullet ble delt"
className="flex size-7 items-center justify-center rounded-full border border-border bg-muted text-xs font-extrabold text-muted-foreground"
>
½
</span>
)
}
return (
<span
aria-label={result === "a" ? "Side A vant hullet" : "Side B vant hullet"}
className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
result === "a" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
)}
>
{result.toUpperCase()}
</span>
)
}
// Én kolonne-celle for en side -- viser hver enhets brutto → netto, med den
// enheten som IKKE talte for siden (fourball: partneren med høyest netto)
// tonet ned i stedet for fjernet, slik at "hvorfor" forblir synlig.
function SideEntryCell({ entries }: { entries: ApiFormatHoleEntry[] }) {
if (entries.length === 0) return <span className="text-sm text-muted-foreground"></span>
return (
<div className="flex flex-col gap-1">
{entries.map((e) => (
<div
key={e.id}
className={cn(
"flex items-baseline justify-between gap-2",
entries.length > 1 && !e.counted && "opacity-40",
)}
>
{entries.length > 1 && (
<span className="min-w-0 truncate text-[11px] font-semibold text-muted-foreground">{e.label}</span>
)}
<span className={cn("flex items-baseline gap-1 tabular-nums", entries.length === 1 && "ml-0")}>
<span className="text-sm font-extrabold text-foreground">{e.gross ?? ""}</span>
{e.net !== null && e.net !== e.gross && (
<span className="text-[11px] font-semibold text-muted-foreground"> {e.net}</span>
)}
</span>
</div>
))}
</div>
)
}
function MatchProgressTable({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) {
const result = useFormatResult(roundId, refreshKey)
if (!result) return null
const sideALabel = sides[0]?.label?.trim() || "Side A"
const sideBLabel = sides[1]?.label?.trim() || "Side B"
if (!result.ready || !result.holes) {
return (
<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 matchforløpet kan vises.
</div>
)
}
if (result.holes.length === 0) {
return (
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har registrert.
</div>
)
}
return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Matchforløp</h2>
{result.match_status_text && (
<span className="text-sm font-extrabold tabular-nums text-foreground">{result.match_status_text}</span>
)}
</div>
<div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4">
<table className="w-full min-w-[440px] table-fixed border-collapse text-sm">
<colgroup>
<col className="w-10" />
<col className="w-9" />
<col />
<col />
<col className="w-10" />
</colgroup>
<thead>
<tr>
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-left text-xs font-extrabold text-muted-foreground">
Hull
</th>
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground">
Par
</th>
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
{sideALabel}
</th>
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
{sideBLabel}
</th>
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground">
Res.
</th>
</tr>
</thead>
<tbody>
{result.holes.map((h) => (
<tr key={h.hole_number}>
<td className="border border-border px-1 py-1.5 text-xs font-semibold text-muted-foreground">{h.hole_number}</td>
<td className="border border-border px-1 py-1.5 text-center text-xs text-muted-foreground">{h.par}</td>
<td className="border border-border px-2 py-1.5">
<SideEntryCell entries={h.entries.filter((e) => e.side === "a")} />
</td>
<td className="border border-border px-2 py-1.5">
<SideEntryCell entries={h.entries.filter((e) => e.side === "b")} />
</td>
<td className="border border-border px-1 py-1.5">
<span className="flex items-center justify-center">
<ResultChip result={h.result} />
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
Netto = brutto minus slag mottatt (kombinert match-hcp den dagen) -- vist med "→" der det avviker fra brutto.
{result.holes.some((h) => h.entries.length > 2) &&
" Nedtonet spiller sitt tall talte ikke for siden det hullet (laveste netto teller)."}
</p>
</div>
)
}
function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
const result = useFormatResult(roundId, refreshKey)
if (!result || !result.holes || result.holes.length === 0) return null
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4">
<h2 className="text-base font-bold text-foreground">Skins, hull for hull</h2>
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{result.holes.map((h) => (
<div key={h.hole_number} className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-bold text-muted-foreground">
Hull {h.hole_number} · Par {h.par}
</span>
{h.carried ? (
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground">
Uavgjort rullet videre
</span>
) : h.winner_ids && h.winner_ids.length > 0 ? (
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground">
{h.winner_ids.length === 1
? `${h.entries.find((e) => e.id === h.winner_ids?.[0])?.label ?? "?"} vant skinnet`
: `Delt: ${h.winner_ids.map((id) => h.entries.find((e) => e.id === id)?.label ?? "?").join(", ")}`}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{h.entries.map((e) => {
const isWinner = h.winner_ids?.includes(e.id) ?? false
const shown = hasNet && e.net !== null ? e.net : e.gross
return (
<span
key={e.id}
className={cn(
"flex items-baseline gap-1 text-sm",
isWinner ? "font-extrabold text-primary" : "text-foreground",
)}
>
{e.label}: <span className="tabular-nums">{shown ?? ""}</span>
{hasNet && e.net !== null && e.gross !== null && e.net !== e.gross && (
<span className="text-xs font-normal text-muted-foreground">({e.gross} brutto)</span>
)}
</span>
)
})}
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{hasNet ? "Skins avgjøres på netto (brutto vist i parentes)." : "Skins avgjøres på brutto."} Et uavgjort hull
ruller potten videre til neste hull.
</p>
</div>
)
}
// --- Screen ----------------------------------------------------------------
export function RoundScorecard({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [unitId, setUnitId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null)
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
// signal over WebSocket -- bumper denne, som er med i begge fetch-
// effektene under, slik at de henter på nytt akkurat som ved
// førstegangslasting (samme mønster som public-live.tsx).
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 res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!res.ok) {
if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.")
return
}
const data: ApiRound = await res.json()
if (cancelled) return
setRound(data)
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
setUnitId((prev) => {
if (prev) return prev
if (isSharedBall) {
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
}
// Standardvalg: SIN EGEN rad hvis man er en lenket medspiller,
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen).
return (
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
data.participants.find((p) => p.is_owner)?.id ??
data.participants[0]?.id ??
null
)
})
}
void load()
return () => {
cancelled = true
}
}, [roundId, viewerId, refreshKey])
useEffect(() => {
if (!unitId || !round) return
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
const endpoint = isSharedBall
? `/rounds/${roundId}/sides/${unitId}/holes`
: `/rounds/${roundId}/participants/${unitId}/holes`
let cancelled = false
async function loadHoles() {
const res = await fetch(endpoint, { credentials: "include" })
if (!res.ok) return
const data: ApiHole[] = await res.json()
if (!cancelled) setHoles(data)
}
void loadHoles()
return () => {
cancelled = true
}
}, [roundId, unitId, 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) {
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 isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
const isSkins = round.play_format === "skins"
const formatLabel = FORMAT_LABELS[round.play_format] ?? round.play_format
// Delt-ball-formater trenger sidene opprettet (via runde-siden sin
// "Spillere og runde"-fane) FØR noe scorekort gir mening her -- vis en
// forklarende melding i stedet for en evig lastespinner.
if (isSharedBall && round.sides.length < 2) {
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/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" />
Scorekort
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene selve
runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.
</div>
</main>
</div>
)
}
const units: { id: string; label: string }[] = isSharedBall
? round.sides.map((s, idx) => ({ id: s.id, label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B") }))
: round.participants.map((p) => ({ id: p.id, label: participantLabel(p, viewerId) }))
if (!holes) {
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>
)
}
// Samme sirkulære start_hole-rekkefølge som round-detail.tsx/round-stats.tsx
// -- "Ut"/"Inn" er dermed første/andre halvdel AV SPILLEREKKEFØLGEN, ikke
// nødvendigvis fysisk hull 1-9/10-18 (relevant kun for en 18-hulls runde
// med et annet starthull enn 1, uvanlig men strukturelt tillatt).
const holeOrder = Array.from(
{ length: round.holes_planned },
(_, i) => ((round.start_hole - 1 + i) % 18) + 1,
)
const orderedHoles = holeOrder
.map((n) => holes.find((h) => h.hole_number === n))
.filter((h): h is ApiHole => h !== undefined)
const front = orderedHoles.slice(0, 9)
const back = orderedHoles.slice(9, 18)
const showStableford = holes.some((h) => h.strokes_received !== null)
const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null)
// Full rundens par (statisk referanse, f.eks. "Par 72") -- ALDRI brukt til
// en til-par-utregning, kun til "Par"-feltet nederst som viser hele
// runden uansett hvor langt man har kommet.
const totalPar = sum(orderedHoles.map((h) => h.par))
// Til-par MÅ regnes mot paret for kun de SPILTE hullene -- ellers blir
// "til par" meningsløst midt i runden (f.eks. 19 slag på 4 hull ville vist
// "53 til par" mot en 72-par bane i stedet for korrekt "+2", funnet ved
// faktisk nettleser-testing 2026-07-27).
const playedPar = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.par)) : 0
const totalGross = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null
const totalPointsValues = orderedHoles.map(stablefordPoints).filter((n): n is number => n !== null)
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
return (
<div className="min-h-dvh bg-background">
{/* Header */}
<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" />
Scorekort
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip */}
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex flex-col">
<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">
{formatLabel}
</span>
)}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "}
{dateFormatter.format(new Date(round.played_at))}
</span>
</div>
{totalGross !== null && (
<div className="flex flex-col items-end">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{totalGross}</span>
<span className="text-sm font-bold text-brand-orange">{signedToPar(totalGross, playedPar)} til par</span>
</div>
)}
</div>
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */}
{units.length > 1 && (
<div role="tablist" aria-label={isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
<div className="flex gap-2">
{units.map((u) => {
const active = u.id === unitId
return (
<button
key={u.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setHoles(null)
setUnitId(u.id)
}}
className={cn(
"flex min-h-11 shrink-0 items-center rounded-2xl border px-4 text-base font-bold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{u.label}
</button>
)
})}
</div>
</div>
)}
{/* Legend */}
<Legend />
{/* Scorecard blocks */}
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4">
{front.length > 0 && (
<ScoreBlock holes={front} summaryLabel={back.length === 0 ? "Tot" : "Ut"} showStableford={showStableford} />
)}
{back.length > 0 && <ScoreBlock holes={back} summaryLabel="Inn" showStableford={showStableford} />}
</div>
{/* Combined total */}
<div className={cn("grid gap-2", showStableford ? "grid-cols-4" : "grid-cols-3")}>
<TotalTile label="Par" value={String(totalPar)} />
<TotalTile label="Score" value={totalGross !== null ? String(totalGross) : ""} />
<TotalTile
label="Til par"
value={totalGross !== null ? signedToPar(totalGross, playedPar) : ""}
tone="orange"
/>
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : ""} tone="green" />}
</div>
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
{isTwoSided && round.sides.length === 2 && (
<MatchProgressTable roundId={roundId} sides={round.sides} refreshKey={refreshKey} />
)}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
</main>
</div>
)
}