teecup/frontend/components/round-scorecard.tsx
Erol Haagenrud 968ea79184 Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)

Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx

Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx

Bingo Bango Bongo: format-valg + per-hull picker + poengtabell

Flaggturnering: format-valg + nedtelling/resultatvisning

Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)

Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)

High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)

Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)

Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)

Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00

1102 lines
43 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, ChevronRight } 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
}
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
}
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", ...SHARED_BALL_FORMATS]
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",
}
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, 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) -----------
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} 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>
)}
</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): 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
}
// --- 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 }
function MatchScorecardGrid({
round,
roundId,
viewerId,
refreshKey,
}: {
round: ApiRound
roundId: string
viewerId: string | null
refreshKey: number
}) {
const result = useFormatResult(roundId, refreshKey)
if (!result) return null
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
const rows: MatchRow[] = 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,
}))
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 }: { 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-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 }: { 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-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={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 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} />
)}
{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 && (
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
)}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
{/* Symmetrisk med round-stats.tsx sin "Se scorekort"-lenke tilbake
hit (2026-07-30, etterspurt av bruker -- manglet fra scorekortet
og andre veien). */}
<Link
href={`/my-rounds/${roundId}/stats`}
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground shadow-sm transition-colors hover:bg-accent/50"
>
Se full rundestatistikk
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</main>
</div>
)
}