"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. 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 } type ApiRound = { name: string | null id: string course_name_snapshot: string tee_name_snapshot: string played_at: string start_hole: number holes_planned: number participants: ApiParticipant[] } type ApiHole = { hole_number: number par: number stroke_index: number played: boolean score: number | null strokes_received: number | null } 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}
) } // --- Screen ---------------------------------------------------------------- export function RoundScorecard({ roundId }: { roundId: string }) { const [round, setRound] = useState(null) const [participantId, setParticipantId] = useState(null) const [holes, setHoles] = useState(null) const [error, setError] = useState(null) const [viewerId, setViewerId] = useState(null) 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(() => { 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) // Standardvalg: SIN EGEN rad hvis man er en lenket medspiller, // ellers eieren (samme som før ADR-036 fase 3-utvidelsen). setParticipantId( (prev) => prev ?? (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]) useEffect(() => { if (!participantId) return let cancelled = false async function loadHoles() { const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" }) if (!res.ok) return const data: ApiHole[] = await res.json() if (!cancelled) setHoles(data) } void loadHoles() return () => { cancelled = true } }, [roundId, participantId]) if (error) { return (

{error}

Tilbake til egne runder
) } if (!round || !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) const totalPar = sum(orderedHoles.map((h) => h.par)) 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.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, totalPar)} til par
)}
{/* Spillervelger, kun når runden har flere deltakere */} {round.participants.length > 1 && (
{round.participants.map((p) => { const active = p.id === participantId return ( ) })}
)} {/* Legend */} {/* Scorecard blocks */}
{front.length > 0 && ( )} {back.length > 0 && }
{/* Combined total */}
{showStableford && }
) }