"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 = { 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 {"–"} } const kind = classify(gross, par) if (kind === "eagle") { return ( {gross} ) } if (kind === "birdie") { return ( {gross} ) } if (kind === "bogey") { return ( {gross} ) } if (kind === "double") { return ( {gross} ) } // par return {gross} } // --- 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 ( {holes.map((h) => ( ))} {holes.map((h) => ( ))} {/* Hcp (stroke index) */} {holes.map((h) => ( ))} {/* Par */} {holes.map((h) => ( ))} {/* Score — the hero row: highlighted band + shape marks */} {holes.map((h) => ( ))} {/* Netto */} {holes.map((h) => ( ))} {/* Stableford (only when relevant) */} {showStableford && ( {holes.map((h) => ( ))} )}
Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number}
Rad {h.hole_number} {summaryLabel}
Hcp {h.stroke_index}
Par {h.par} {parSum}
Score {grossSum ?? "–"}
Netto {netScore(h) ?? "–"} {nettoSum ?? "–"}
Poeng {stablefordPoints(h) ?? "–"} {pointsSum ?? "–"}
) } // --- Legend ---------------------------------------------------------------- function Legend() { return (
3 Under par (sirkel) 5 Over par (firkant) Fylt symbol = 2 slag eller mer
) } // --- Combined total -------------------------------------------------------- function TotalTile({ label, value, tone }: { label: string; value: string; tone?: "orange" | "green" }) { return (
{value} {label}
) } // --- 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 | null holes: ApiFormatHoleOut[] | null } function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null { const [result, setResult] = useState(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 if (result === "halved") { return ( ½ ) } return ( {result.toUpperCase()} ) } // É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 return (
{entries.map((e) => (
1 && !e.counted && "opacity-40", )} > {entries.length > 1 && ( {e.label} )} {e.gross ?? "–"} {e.net !== null && e.net !== e.gross && ( → {e.net} )}
))}
) } 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 (
Venter på at begge sider er komplette og har beregnet handicap før matchforløpet kan vises.
) } if (result.holes.length === 0) { return (
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har registrert.
) } return (

Matchforløp

{result.match_status_text && ( {result.match_status_text} )}
{result.holes.map((h) => ( ))}
Hull Par {sideALabel} {sideBLabel} Res.
{h.hole_number} {h.par} e.side === "a")} /> e.side === "b")} />

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)."}

) } 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 (

Skins, hull for hull

{result.holes.map((h) => (
Hull {h.hole_number} · Par {h.par} {h.carried ? ( Uavgjort — rullet videre ) : h.winner_ids && h.winner_ids.length > 0 ? ( {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(", ")}`} ) : null}
{h.entries.map((e) => { const isWinner = h.winner_ids?.includes(e.id) ?? false const shown = hasNet && e.net !== null ? e.net : e.gross return ( {e.label}: {shown ?? "–"} {hasNet && e.net !== null && e.gross !== null && e.net !== e.gross && ( ({e.gross} brutto) )} ) })}
))}

{hasNet ? "Skins avgjøres på netto (brutto vist i parentes)." : "Skins avgjøres på brutto."} Et uavgjort hull ruller potten videre til neste hull.

) } // --- Screen ---------------------------------------------------------------- export function RoundScorecard({ roundId }: { roundId: string }) { const [round, setRound] = useState(null) const [unitId, setUnitId] = useState(null) const [holes, setHoles] = useState(null) const [error, setError] = useState(null) const [viewerId, setViewerId] = useState(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 (

{error}

Tilbake til egne runder
) } if (!round) { return (
) } 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 (
{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.
) } 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 (
) } // 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 (
{/* Header */}
{/* Round summary strip */}
{round.name?.trim() || round.course_name_snapshot} {round.play_format !== "stroke" && ( {formatLabel} )} {round.name?.trim() ? `${round.course_name_snapshot} · ` : ""} {round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "} {dateFormatter.format(new Date(round.played_at))}
{totalGross !== null && (
{totalGross} {signedToPar(totalGross, playedPar)} til par
)}
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */} {units.length > 1 && (
{units.map((u) => { const active = u.id === unitId return ( ) })}
)} {/* Legend */} {/* Scorecard blocks */}
{front.length > 0 && ( )} {back.length > 0 && }
{/* Combined total */}
{showStableford && }
{/* 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 && ( )} {isSkins && }
) }