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

1469 lines
61 KiB
TypeScript
Raw Permalink 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, ArrowUp, ArrowDown, ArrowRight, ArrowUpLeft, ArrowUpRight, Check, Target } 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
playing_handicap: number | null
}
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type 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
picked_up: boolean
strokes_received: number | null
// Detaljfelt (2026-08-03) -- allerede returnert av samme
// RoundHoleOut/`/rounds/.../holes`-endepunkt denne siden lenge har brukt,
// bare ikke tidligere lest her. Presentert som egne, forkortede rader i
// ScoreBlock i stedet for kun å ligge tilgjengelig i round-detail.tsx sin
// "Så langt i runden"-seksjon.
putts: number | null
tee_shot_result: "fairway" | "left" | "right" | null
approach_result: "hit" | "short" | "long" | "left" | "right" | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
anyway_strokes: number | null
}
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
// scramble_solo_match (migrasjon 057) lagt til her -- KUN denne bruken
// (isTwoSided) gater MatchScorecardGrid, som får sin egen dedikerte
// scramble-solo-familie-gren for å unngå dupliserte rader (se der).
// SHARED_BALL_FORMATS/isSharedBall RØRES bevisst ikke -- se
// SCRAMBLE_SOLO_FORMATS under for den egne, riktige klassifiseringen.
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", "scramble_solo_match", ...SHARED_BALL_FORMATS]
// Begge scramble-mot-enkeltspiller-variantene deler NØYAKTIG samme
// asymmetriske side-/enhetsstruktur (laget = side, individualspilleren =
// participant) -- kun selve resultat-sammenligningen skiller dem. Én
// samlet klassifisering brukt for alt UNNTATT selve resultatvisningen.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
match: "Match",
skins: "Skins",
fourball: "Fourball",
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
chapman: "Chapman",
copenhagen: "Københavner",
bbb: "Bingo Bango Bongo",
flag: "Flaggturnering",
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
// 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)
}
// Samme GIR-formel som round-stats.tsx sin isGir/round-detail.tsx sin
// GIR-auto-inferens: slag minus putt garanterer green i regulering når
// resultatet er par-2 eller bedre. Krever kjent slag+putt-tall.
function isGir(h: ApiHole): boolean {
return h.score !== null && h.putts !== null && h.score - h.putts <= h.par - 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, pickedUp }: { gross: number | null; par: number; pickedUp?: boolean }) {
const shared =
"flex size-6 items-center justify-center text-sm font-extrabold tabular-nums sm:size-7"
// "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
// vises som "PU" i stedet for det underliggende Net Double Bogey-tallet.
if (pickedUp) {
return (
<span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange")}>
PU
</span>
)
}
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) -----------
// Kompakte retnings-/treff-ikoner for de nye detaljradene (2026-08-03) --
// samme ikonvalg som DirectionCross (round-detail.tsx sin registrerings-
// veiviser) allerede bruker, kun skalert ned for en tett tabellcelle.
// Bøyde piler for Fairway (utslaget DRIVER av til en side, ulikt et
// innspill som bommer rett til siden -- derfor ArrowUpLeft/-Right her,
// ikke de rette ArrowLeft/-Right Innspill-raden bruker).
function FairwayIcon({ result }: { result: ApiHole["tee_shot_result"] }) {
if (result === null) return null
if (result === "fairway") return <Target aria-hidden="true" className="mx-auto size-3.5 text-primary" />
if (result === "left") return <ArrowUpLeft aria-hidden="true" className="mx-auto size-3.5 text-brand-orange" />
return <ArrowUpRight aria-hidden="true" className="mx-auto size-3.5 text-brand-orange" />
}
function ApproachIcon({ result }: { result: ApiHole["approach_result"] }) {
if (result === null) return null
const cls = "mx-auto size-3.5"
if (result === "hit") return <Target aria-hidden="true" className={cn(cls, "text-primary")} />
if (result === "long") return <ArrowUp aria-hidden="true" className={cn(cls, "text-brand-orange")} />
if (result === "short") return <ArrowDown aria-hidden="true" className={cn(cls, "text-brand-orange")} />
if (result === "left") return <ArrowLeft aria-hidden="true" className={cn(cls, "text-brand-orange")} />
return <ArrowRight aria-hidden="true" className={cn(cls, "text-brand-orange")} />
}
function ScoreBlock({
holes,
summaryLabel,
showStableford,
showFairway,
showPutts,
showGir,
showApproach,
showChip,
showBunker,
showPenalty,
showAnyway,
}: {
holes: ApiHole[]
summaryLabel: string
showStableford: boolean
showFairway: boolean
showPutts: boolean
showGir: boolean
showApproach: boolean
showChip: boolean
showBunker: boolean
showPenalty: boolean
showAnyway: 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
// Fairwaytreff spores kun på ikke-par-3-hull (samme regel som
// round-detail.tsx sin "Så langt i runden"), summen teller kun "Blink".
const fairwayEligible = holes.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
const fairwayHitSum = fairwayEligible.length > 0 ? fairwayEligible.filter((h) => h.tee_shot_result === "fairway").length : null
const puttsValues = holes.filter((h) => h.putts !== null).map((h) => h.putts as number)
const puttsSum = puttsValues.length > 0 ? sum(puttsValues) : null
const girEligible = holes.filter((h) => h.score !== null && h.putts !== null)
const girSum = girEligible.length > 0 ? girEligible.filter(isGir).length : null
const chipValues = holes.filter((h) => h.chip_count !== null).map((h) => h.chip_count as number)
const chipSum = chipValues.length > 0 ? sum(chipValues) : null
const bunkerValues = holes.filter((h) => h.bunker_shot_count !== null).map((h) => h.bunker_shot_count as number)
const bunkerSum = bunkerValues.length > 0 ? sum(bunkerValues) : null
const penaltyValues = holes.filter((h) => h.penalty_strokes !== null).map((h) => h.penalty_strokes as number)
const penaltySum = penaltyValues.length > 0 ? sum(penaltyValues) : null
const anywayValues = holes.filter((h) => h.anyway_strokes !== null).map((h) => h.anyway_strokes as number)
const anywaySum = anywayValues.length > 0 ? sum(anywayValues) : null
const cell = "border border-border text-center tabular-nums"
const sumCell = "border border-border bg-muted text-center font-extrabold tabular-nums"
// whitespace-nowrap + overflow-hidden (2026-08-03, rapportert av bruker --
// "Score"/"Netto" bløuted ut over kolonnegrensen) -- w-11 gir litt mer
// rom enn tidligere w-9, men de VIRKELIG korte etikettene (forkortelser)
// under er den egentlige fiksen, ikke bare bredden alene.
const rowLabel =
"border border-border px-1 text-left text-xs font-bold text-muted-foreground whitespace-nowrap overflow-hidden"
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-11" />
{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} pickedUp={h.picked_up} />
</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>
)}
{/* Fairway -- ikon per hull (bøyd pil = utslaget drev til siden,
blink = fairwaytreff), sum = antall fairwaytreff. */}
{showFairway && (
<tr>
<th scope="row" className={rowLabel} title="Fairwaytreff">
Fw
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1")}>
{h.par !== 3 ? <FairwayIcon result={h.tee_shot_result} /> : <span aria-hidden="true"></span>}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{fairwayHitSum ?? ""}</td>
</tr>
)}
{/* Putter -- antall per hull, summert. */}
{showPutts && (
<tr>
<th scope="row" className={rowLabel} title="Putter">
Putt
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.putts ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{puttsSum ?? ""}</td>
</tr>
)}
{/* GIR -- sjekkmerke når hullet er nådd i regulering, summert. */}
{showGir && (
<tr>
<th scope="row" className={rowLabel} title="Green i regulering">
GIR
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1")}>
{h.score !== null && h.putts !== null ? (
isGir(h) ? (
<Check aria-hidden="true" className="mx-auto size-3.5 text-primary" />
) : (
<span aria-hidden="true" className="text-muted-foreground">
</span>
)
) : (
<span aria-hidden="true"></span>
)}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{girSum ?? ""}</td>
</tr>
)}
{/* Innspill -- 5-veis retning, ingen meningsfull sum (retning, ikke
et tall). */}
{showApproach && (
<tr>
<th scope="row" className={rowLabel} title="Innspill">
Appr.
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1")}>
<ApproachIcon result={h.approach_result} />
{h.approach_result === null && <span aria-hidden="true"></span>}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-muted-foreground")} aria-hidden="true">
</td>
</tr>
)}
{/* Chip -- antall per hull, summert. */}
{showChip && (
<tr>
<th scope="row" className={rowLabel} title="Chip">
Chip
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.chip_count ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{chipSum ?? ""}</td>
</tr>
)}
{/* Bunkerslag -- antall per hull, summert. */}
{showBunker && (
<tr>
<th scope="row" className={rowLabel} title="Bunkerslag">
Bnk
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.bunker_shot_count ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{bunkerSum ?? ""}</td>
</tr>
)}
{/* Straffeslag -- antall per hull, summert. */}
{showPenalty && (
<tr>
<th scope="row" className={rowLabel} title="Straffeslag">
Str
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.penalty_strokes ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{penaltySum ?? ""}</td>
</tr>
)}
{/* Anywayslag -- antall per hull, summert. */}
{showAnyway && (
<tr>
<th scope="row" className={rowLabel} title="Anywayslag">
Any.
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.anyway_strokes ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{anywaySum ?? ""}</td>
</tr>
)}
</tbody>
</table>
)
}
// --- 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, apiPrefix: string = "/rounds"): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
fetch(`${apiPrefix}/${roundId}/format-result`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFormatResult | null) => {
if (!cancelled) setResult(data)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId, refreshKey, apiPrefix])
return result
}
// --- Matchforløp: horisontalt scorekort med løpende stilling ---------------
// Egen visuell utforming (ikke en kopi av noe referansebilde) -- gjenbruker
// appens etablerte vinn/tap-språk (grønn=side A, oransje=side B, nøytral=delt,
// se ScoreMark/ResultChip andre steder) fremfor referansens rød/blå, og
// erstatter den tidligere vertikale hull-for-hull-listen med et horisontalt
// rutenett (Hull/Par/enhet-rader/Stilling) + en identitets-/resultatbanner --
// samme informasjon (hvem vant hvilket hull, AS/X UP/X&Y), annen presentasjon.
type RunningState = {
lead: number
label: string
tone: "a" | "b" | "neutral"
isClosed: boolean
}
// Speiler handicap_engine.py sin compute_match_state()/describe() presist,
// men regner ett PREFIKS om gangen slik at hvert hull får sin egen løpende
// stilling (backend cacher i dag kun SLUTT-tilstanden -- se ADR-039-notatet).
function computeRunning(results: ("a" | "b" | "halved")[], totalHoles: number): RunningState[] {
const out: RunningState[] = []
let lead = 0
for (let i = 0; i < results.length; i++) {
lead += results[i] === "a" ? 1 : results[i] === "b" ? -1 : 0
const holesPlayed = i + 1
const holesRemaining = totalHoles - holesPlayed
const isClosed = Math.abs(lead) > holesRemaining
const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining
const margin = Math.abs(lead)
let label: string
if (isClosed) {
label = holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}`
} else if (lead === 0) {
label = "AS"
} else {
label = isDormie ? `Dormie ${margin}` : `${margin} UP`
}
out.push({ lead, label, tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", isClosed })
}
return out
}
type HeaderIdentity = { name: string; hcp: number | null }
// Territorium-bar (2026-07-29, brukerens eget forslag: den ledende sidens
// sone strekker seg proporsjonalt forbi midtlinjen inn på motstanderens
// halvdel, i stedet for en statisk 50/50-boks med tekst i midten). Ved AS
// eller ingen hull avgjort ennå er begge soner like store og nøytralt
// farget. Egen lokal kopi av samme idé som round-detail.tsx sin
// FormatResultPanel, per prosjektets "én fil, én kopi"-konvensjon.
function leadZoneFraction(lead: number, totalHoles: number): number {
if (totalHoles <= 0) return 0.5
const fraction = 0.5 + (lead / totalHoles) * 0.5
return Math.min(0.82, Math.max(0.18, fraction))
}
function LeadZone({ identity, side, dominant }: { identity: HeaderIdentity; side: "a" | "b"; dominant: boolean }) {
const textTone = dominant ? (side === "a" ? "text-primary-foreground" : "text-brand-orange-foreground") : "text-foreground"
const subTone = dominant
? side === "a"
? "text-primary-foreground/75"
: "text-brand-orange-foreground/75"
: "text-muted-foreground"
// Boksen STREKKER SEG (ingen items-start/items-end) -- ellers sizes den
// etter innholdets egen bredde, ikke sonens faktiske tildelte bredde, og
// kan visuelt lekke inn i midt-kolonnen på smale skjermer. Justering skjer
// med justify-start/justify-end + text-align inni en boks som alltid har
// full, korrekt bredde -- truncate virker da presist.
return (
<div className="flex h-full min-w-0 w-full flex-col justify-center gap-0.5 px-3">
<div
className={cn(
"flex min-w-0 items-center gap-1.5",
side === "a" ? "justify-start" : "justify-end",
side === "b" && "flex-row-reverse",
)}
>
{!dominant && (
<span
aria-hidden="true"
className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
/>
)}
<span className={cn("min-w-0 truncate text-sm font-extrabold", textTone)}>{identity.name}</span>
</div>
{identity.hcp !== null && (
<span
className={cn(
"truncate text-xs font-bold tabular-nums",
subTone,
side === "a" ? "text-left" : "text-right",
)}
>
HCP {identity.hcp}
</span>
)}
</div>
)
}
function LeadBar({
identityA,
identityB,
state,
holesPlayed,
totalHoles,
}: {
identityA: HeaderIdentity
identityB: HeaderIdentity
state: RunningState | null
holesPlayed: number
totalHoles: number
}) {
const lead = state?.lead ?? 0
const tone = state?.tone ?? "neutral"
const widthA = state ? leadZoneFraction(lead, totalHoles) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return (
// CSS Grid, ikke absolutt overlegg (2026-07-29-fiks, se round-detail.tsx
// sin FormatResultPanel for full begrunnelse) -- midt-kolonnen (status)
// er `auto`-bredde, de to sonene `minmax(0, X fr)` -- reserverer alltid
// nøyaktig plass til statusboksen, kan aldri overlappe et langt navn.
<div
className="grid h-[76px] w-full overflow-hidden rounded-2xl sm:h-20"
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
>
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
<LeadZone identity={identityA} side="a" dominant={tone === "a"} />
</div>
<div className="flex h-full items-center justify-center px-2">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{state ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{state.label}
</span>
{state.isClosed ? (
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
Ferdig
</span>
) : (
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">{holesPlayed} hull spilt</span>
)}
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
<LeadZone identity={identityB} side="b" dominant={tone === "b"} />
</div>
</div>
)
}
type MatchRow = { id: string; side: "a" | "b"; label: string; hcp: number | null }
export function MatchScorecardGrid({
round,
roundId,
viewerId,
refreshKey,
apiPrefix = "/rounds",
}: {
round: ApiRound
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix?: string
}) {
const result = useFormatResult(roundId, refreshKey, apiPrefix)
if (!result) return null
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
// scramble_solo_match (migrasjon 057) -- ÉN rad per SIDE (som
// delt-ball-grenen under), IKKE én per deltaker, siden laget kan ha
// vilkårlig mange medlemmer som alle deler samme TeamAverage-verdi --
// uten denne egne grenen ville "else"-grenen under vist N dupliserte
// rader for laget. Individualsidens rad viser spillerens EGET navn
// (ikke bare sidens "Individuell motstander"-label) siden den siden
// alltid har nøyaktig én deltaker.
const isScrambleSoloMatch = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
const rows: MatchRow[] = isScrambleSoloMatch
? round.sides.map((s, idx) => {
// side="a"/"b" er POSISJONELL (idx===0 -> "a"), samme sorterte-
// etter-id-konvensjon som backend sin _build_scramble_solo_match_
// result bruker (round.sides hentes ALLTID `ORDER BY id`, samme
// sortering backend legger til grunn for "a"/"b"-tildelingen --
// IKKE en fast lag=a/individuell=b-tildeling, som ville gitt feil
// ledelsesfarge når laget ikke er den lavest-sorterte UUID-en).
// row.id må for individualsiden være DELTAKERENS id (ikke sidens),
// siden FormatHoleEntry.id for den siden er individual_
// participant["id"] på backend-siden -- ellers finner
// h.entries.find(e => e.id === row.id) under aldri en match.
const sideParticipants = round.participants.filter((p) => p.round_side_id === s.id)
const isTeam = s.side_role === "team"
const label =
!isTeam && sideParticipants.length === 1
? participantLabel(sideParticipants[0], viewerId)
: s.label?.trim() || (isTeam ? "Laget" : "Individuell motstander")
return {
id: isTeam ? s.id : (sideParticipants[0]?.id ?? s.id),
side: idx === 0 ? "a" : ("b" as const),
label,
hcp: sideParticipants[0]?.playing_handicap ?? null,
}
})
: isSharedBall
? round.sides.map((s, idx) => ({
id: s.id,
side: idx === 0 ? "a" : ("b" as const),
label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B"),
hcp: round.participants.find((p) => p.round_side_id === s.id)?.playing_handicap ?? null,
}))
: round.participants
.filter((p) => p.round_side_id)
.map((p) => ({
id: p.id,
side: (p.round_side_id === round.sides[0]?.id ? "a" : "b") as "a" | "b",
label: participantLabel(p, viewerId),
hcp: p.playing_handicap,
}))
// Lagvis gruppering (2026-08-02, "det store grepet") -- individuell-ball-
// formater (match/fourball) bygde tidligere rader i ren
// innsettingsrekkefølge (round.participants sin egen orden), ikke lagvis.
// Stabil sortering på side holder original rekkefølge INNAD i hver
// gruppe. Delt-ball-formater har allerede nøyaktig én rad per side
// (rows[0]="a", rows[1]="b" fra konstruksjonen over) -- no-op der.
rows.sort((a, b) => (a.side === b.side ? 0 : a.side === "a" ? -1 : 1))
function headerIdentity(side: "a" | "b"): HeaderIdentity {
const sideRows = rows.filter((r) => r.side === side)
if (sideRows.length === 1) return { name: sideRows[0].label, hcp: sideRows[0].hcp }
const idx = side === "a" ? 0 : 1
return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null }
}
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>
)
}
const decidedHoles = result.holes
const running = computeRunning(decidedHoles.map((h) => h.result as "a" | "b" | "halved"), round.holes_planned)
const lastState = running.length > 0 ? running[running.length - 1] : null
return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
<LeadBar
identityA={headerIdentity("a")}
identityB={headerIdentity("b")}
state={lastState}
holesPlayed={decidedHoles.length}
totalHoles={round.holes_planned}
/>
{decidedHoles.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har
registrert.
</p>
) : (
<div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4">
<table
className="w-full border-collapse text-sm"
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
>
<colgroup>
<col className="w-16" />
{decidedHoles.map((h) => (
<col key={h.hole_number} className="w-10" />
))}
</colgroup>
<thead>
<tr>
<th
scope="col"
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
>
Hull
</th>
{decidedHoles.map((h) => (
<th
key={h.hole_number}
scope="col"
className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
>
{h.hole_number}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<th
scope="row"
className="border border-border px-2 py-1.5 text-left text-xs font-bold text-muted-foreground"
>
Par
</th>
{decidedHoles.map((h) => (
<td
key={h.hole_number}
className="border border-border py-1.5 text-center text-xs font-semibold text-muted-foreground"
>
{h.par}
</td>
))}
</tr>
{rows.map((row) => (
<tr key={row.id}>
<th scope="row" className="border border-border px-2 py-1.5 text-left">
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className={cn(
"size-2 shrink-0 rounded-full",
row.side === "a" ? "bg-primary" : "bg-brand-orange",
)}
/>
<span className="min-w-0 truncate text-xs font-bold text-foreground">{row.label}</span>
</span>
</th>
{decidedHoles.map((h) => {
const entry = h.entries.find((e) => e.id === row.id)
const won = !!entry && h.result === row.side && entry.counted !== false
const shown = entry ? (entry.net ?? entry.gross) : null
return (
<td key={h.hole_number} className="border border-border p-0.5 text-center">
<span
aria-label={
entry
? `${row.label}: ${shown} netto${entry.gross !== null && entry.gross !== shown ? `, ${entry.gross} brutto` : ""}${won ? " -- vant hullet" : ""}`
: undefined
}
className={cn(
"mx-auto flex size-7 items-center justify-center rounded-full text-xs font-extrabold tabular-nums",
won
? row.side === "a"
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: entry && entry.counted === false
? "text-muted-foreground/50"
: "text-foreground",
)}
>
{shown ?? ""}
</span>
</td>
)
})}
</tr>
))}
<tr className="bg-muted/30">
<th scope="row" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
Stilling
</th>
{running.map((s, i) => (
<td
key={decidedHoles[i].hole_number}
className={cn(
"border border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-muted-foreground",
)}
>
{s.label}
</td>
))}
</tr>
</tbody>
</table>
</div>
)}
<p className="text-xs text-muted-foreground">
Fylt sirkel = vunnet hull (grønn = {headerIdentity("a").name}, oransje = {headerIdentity("b").name}). Nedtonet
tall talte ikke for siden det hullet (laveste netto teller, gjelder fourball).
</p>
</div>
)
}
function SkinsProgressTable({
roundId,
refreshKey,
apiPrefix = "/rounds",
}: {
roundId: string
refreshKey: number
apiPrefix?: string
}) {
const result = useFormatResult(roundId, refreshKey, apiPrefix)
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-md shadow-black/8 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,
embedded = false,
publicMode = false,
}: {
roundId: string
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
// slått sammen til ÉN side. `embedded` dropper denne komponentens egen
// sticky header (den delte `RoundHeader`, wired inn av
// `RoundPageShell`, dekker det nå) når den rendres som øverste halvdel
// av den slåtte sammen siden, i stedet for standalone.
embedded?: boolean
// ADR-044 (tilskuer-visning, 2026-08-06) -- gjenbruker DENNE komponenten
// uendret for `/watch/{id}/scorecard`, kun med annen datakilde: alle
// `/rounds/*`-kall byttes til `/public/rounds/*` (samme respons-form,
// `_can_view_round`-autorisert i stedet for eier/medspiller-only), og
// WS-kanalen byttes til den offentlige. INGEN visuell/atferdsmessig
// endring når `publicMode` er `false` (standard) -- eiersiden er
// uberørt.
publicMode?: boolean
}) {
const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
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 wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId, publicMode])
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`${apiPrefix}/${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
// scramble_solo-familien (migrasjon 056/057) -- blandet enhetstype
// (laget = en SIDE, individualspilleren = en PARTICIPANT, se
// units-bygging under). Standardvalg: individualspillerens egen
// rad hvis DET er den spørrende brukeren, ellers lagsiden.
if (SCRAMBLE_SOLO_FORMATS.has(data.play_format)) {
const individualSide = data.sides.find((s) => s.side_role === "individual")
const individualParticipant = individualSide
? data.participants.find((p) => p.round_side_id === individualSide.id)
: undefined
if (viewerId && individualParticipant?.user_id === viewerId) return individualParticipant.id
const teamSide = data.sides.find((s) => s.side_role === "team")
return teamSide?.id ?? data.sides[0]?.id ?? null
}
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, apiPrefix])
useEffect(() => {
if (!unitId || !round) return
// scramble_solo (migrasjon 056) har BLANDEDE enhetstyper i samme
// fane-liste (laget = side, individualspilleren = participant) -- slå
// opp faktisk type fra selve unitId i stedet for et format-bredt
// isSharedBall-flagg, som ikke kan uttrykke en blanding. Denne
// oppslags-tilnærmingen er samtidig korrekt uendret for de rene
// delt-ball-/individuell-ball-formatene (alle enheter er da alltid
// den ene eller den andre typen).
const isSideUnit = round.sides.some((s) => s.id === unitId)
const endpoint = isSideUnit
? `${apiPrefix}/${roundId}/sides/${unitId}/holes`
: `${apiPrefix}/${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, apiPrefix])
if (error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link
href={publicMode ? "/dashboard" : "/my-rounds"}
className="text-base font-semibold text-primary underline underline-offset-2"
>
{publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
</Link>
</div>
)
}
if (!round) {
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 isScrambleSolo = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
const formatLabel = FORMAT_LABELS[round.play_format] ?? round.play_format
// Delt-ball-formater OG scramble_solo 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 || isScrambleSolo) && round.sides.length < 2) {
return (
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
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">
{isScrambleSolo
? `${formatLabel} krever at både laget og individualspilleren er satt opp som egne sider. Opprett dem på selve runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.`
: `${formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.`}
</div>
</main>
</div>
)
}
// scramble_solo (migrasjon 056) -- blandet enhetsliste: laget er en
// SIDE-fane (delt ball), individualspilleren er en PARTICIPANT-fane
// (egen ball) -- IKKE lagt til SHARED_BALL_FORMATS, det ville feilaktig
// behandlet BEGGE fanene som sider (se hull-hente-effekten over, som
// slår opp faktisk type fra unitId).
const units: { id: string; label: string }[] = isScrambleSolo
? (() => {
const teamSide = round.sides.find((s) => s.side_role === "team")
const individualSide = round.sides.find((s) => s.side_role === "individual")
const individualParticipant = individualSide
? round.participants.find((p) => p.round_side_id === individualSide.id)
: undefined
const list: { id: string; label: string }[] = []
if (teamSide) list.push({ id: teamSide.id, label: teamSide.label?.trim() || "Laget" })
if (individualParticipant) list.push({ id: individualParticipant.id, label: participantLabel(individualParticipant, viewerId) })
return list
})()
: 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)
// Detaljrader (2026-08-03) -- vis kun rader som faktisk har data et sted
// i runden (samme "vis kun det som faktisk finnes"-prinsipp som resten
// av appen), regnet ut fra HELE runden (ikke bare front/back for seg)
// slik at begge blokkene konsekvent viser/skjuler de samme radene.
const showFairway = holes.some((h) => h.par !== 3 && h.tee_shot_result !== null)
const showPutts = holes.some((h) => h.putts !== null)
const showGir = holes.some((h) => h.score !== null && h.putts !== null)
const showApproach = holes.some((h) => h.approach_result !== null)
const showChip = holes.some((h) => h.chip_count !== null)
const showBunker = holes.some((h) => h.bunker_shot_count !== null)
const showPenalty = holes.some((h) => h.penalty_strokes !== null)
const showAnyway = holes.some((h) => h.anyway_strokes !== 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={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
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-md shadow-black/8">
<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={isScrambleSolo ? "Velg lag eller motstander" : 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>
)}
{/* Scorecard blocks */}
<div className="flex flex-col gap-3 overflow-x-auto rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
{front.length > 0 && (
<ScoreBlock
holes={front}
summaryLabel={back.length === 0 ? "Tot" : "Ut"}
showStableford={showStableford}
showFairway={showFairway}
showPutts={showPutts}
showGir={showGir}
showApproach={showApproach}
showChip={showChip}
showBunker={showBunker}
showPenalty={showPenalty}
showAnyway={showAnyway}
/>
)}
{back.length > 0 && (
<ScoreBlock
holes={back}
summaryLabel="Inn"
showStableford={showStableford}
showFairway={showFairway}
showPutts={showPutts}
showGir={showGir}
showApproach={showApproach}
showChip={showChip}
showBunker={showBunker}
showPenalty={showPenalty}
showAnyway={showAnyway}
/>
)}
</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 && (
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
)}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} apiPrefix={apiPrefix} />}
</main>
</div>
)
}