"use client" // Rundestatistikk -- dypdykk-skjerm for én frittstående runde (ADR-033), // inspirert av (ikke kopiert fra) en skjermopptaksvideo brukeren delte av en // konkurrent-app 2026-07-25 -- se ARCHITECTURE_DECISIONS.md for hvilke // bevisste designvalg som skiller denne fra referansen. Presentasjon fra V0 // (zip 14), datalag skrevet om fra mock til ekte fetch mot samme // `/rounds/{id}/participants/{id}/holes`-endepunkt som round-detail.tsx // allerede bruker -- ingen ny backend nødvendig. import type React from "react" import { useEffect, useState } from "react" import Link from "next/link" import { ArrowLeft, BarChart3, ChevronDown, ChevronRight, Flag, Info, MoreHorizontal, MoveHorizontal, Target, Wind, Circle, Waves, } from "lucide-react" import { cn } from "@/lib/utils" // --- Helpers --------------------------------------------------------------- // Signed value with two decimals, Norwegian comma. e.g. 0.89 -> "+0,89" function signed(n: number): string { const sign = n > 0 ? "+" : n < 0 ? "\u2212" : "\u00B1" return `${sign}${Math.abs(n).toFixed(2).replace(".", ",")}` } function pct(n: number): string { return `${Math.round(n)} %` } // Chart color variables (good -> bad scale defined in globals.css) const C = { eagle: "var(--chart-1)", birdie: "var(--chart-2)", par: "var(--chart-3)", bogey: "var(--chart-4)", dobbel: "var(--chart-5)", verre: "var(--chart-6)", hit: "var(--chart-2)", miss: "var(--chart-5)", neutral: "var(--muted-foreground)", } as const // --- Collapsible card ------------------------------------------------------ function StatCard({ title, icon: Icon, // Alle seksjonene starter kollapset (etterspurt av bruker 2026-07-25: // "trekkspillet skal vises sammenslått") -- må trykkes på hver enkelt // for å se innholdet, i stedet for at hele siden lastes åpen. defaultOpen = false, children, }: { title: string icon: typeof BarChart3 defaultOpen?: boolean children: React.ReactNode }) { const [open, setOpen] = useState(defaultOpen) const bodyId = `sect-${title.replace(/\s+/g, "-").toLowerCase()}` return (

{open && (
{children}
)}
) } // --- Donut (conic-gradient) ------------------------------------------------ type Segment = { label: string; value: number; color: string } function Donut({ segments, centerTop, centerBottom, size = 168, }: { segments: Segment[] centerTop: string centerBottom: string size?: number }) { const total = segments.reduce((s, x) => s + x.value, 0) || 1 let acc = 0 const stops = segments .map((seg) => { const start = (acc / total) * 100 acc += seg.value const end = (acc / total) * 100 return `${seg.color} ${start}% ${end}%` }) .join(", ") return (
`${s.label}: ${s.value}`).join(", ")} >
{centerTop} {centerBottom}
) } function Legend({ items }: { items: Array<{ label: string; color: string; value: string }> }) { return ( ) } // --- Category bar (percentage + raw count) --------------------------------- function CategoryBar({ label, count, percentage, color, max, }: { label: string count: number percentage: number color: string max: number }) { const width = max > 0 ? (percentage / max) * 100 : 0 return (
{label} {pct(percentage)} · {count}
0 ? 6 : 0)}%`, backgroundColor: color }} />
) } // --- Deviation bar (signed, diverging from a center 0 line) ---------------- function DeviationBar({ label, value, max = 2 }: { label: string; value: number; max?: number }) { const clamped = Math.max(-max, Math.min(max, value)) const halfPct = (Math.abs(clamped) / max) * 50 const positive = value > 0 // positive = over par (worse) -> orange; negative = under par (better) -> green const color = value > 0 ? C.dobbel : value < 0 ? C.birdie : C.neutral return (
{label}
) } // --- Horizontal progress bar (single value %) ------------------------------ function ProgressRow({ label, percentage, color = "var(--primary)", suffix, }: { label: string percentage: number color?: string suffix?: string }) { return (
{label}
{suffix ?? pct(percentage)}
) } // --- Paired stat tiles ----------------------------------------------------- function StatTilePair({ a, b, }: { a: { label: string; value: string; hint?: string } b: { label: string; value: string; hint?: string } }) { return (
{[a, b].map((t) => (
{t.value} {t.label}
))}
) } // --- Big number stat with info toggle -------------------------------------- function InfoStat({ label, value, explanation, }: { label: string value: string explanation: string }) { const [showInfo, setShowInfo] = useState(false) return (
{value} {showInfo && (

{explanation}

)}
) } // --- Mini distribution (small labelled bars) ------------------------------- function MiniDistribution({ title, data, color, }: { title: string data: Array<{ label: string; value: number }> color: string }) { const max = Math.max(...data.map((d) => d.value), 1) return (
{title}
{data.map((d) => (
{d.label}
{d.value}
))}
) } // --- API-typer (samme form som round-detail.tsx allerede bruker) ----------- type ApiParticipant = { id: string user_id: string | null guest_name: string | null display_name: string is_owner: boolean } type ApiRound = { id: string name: string | null course_name_snapshot: string tee_name_snapshot: string played_at: string start_hole: number holes_planned: number play_format: string participants: ApiParticipant[] } // Delt-ball-formater (foursome/greensome/scramble, ADR-039 Beslutning C) // lagrer ÉN score PER SIDE per hull -- ingen individuell round_hole-rad // eksisterer i det hele tatt for noen deltaker her, så all statistikken på // denne siden (fairway/GIR/putt-fordeling osv., som forutsetter individuell // slag-for-slag-registrering) er strukturelt umulig å vise for disse // formatene. Vist som en forklarende melding i stedet for en stille tom/ // misvisende side (2026-07-28, funnet ved simulering av alle spillformater). const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"] const FORMAT_LABELS: Record = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", } type ApiHole = { hole_number: number par: number played: boolean score: number | null putts: number | null tee_shot_result: "fairway" | "left" | "right" | null approach_result: "hit" | "long" | "short" | "left" | "right" | null chip_count: number | null bunker_shot_count: number | null penalty_strokes: number | null first_putt_distance_bucket: "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+" | null anyway_strokes: number | null strokes_received: number | null } const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) const PUTT_BUCKETS = ["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] as const function avg(values: number[]): number | null { if (values.length === 0) return null return values.reduce((s, v) => s + v, 0) / values.length } // --- Alle utledede tall for statistikk-skjermen, regnet ut ÉN gang fra // rå hull-data. Ingenting her lagres -- alt beregnes ved lesing, samme // prinsipp som "Så langt i runden" i round-detail.tsx. function computeStats(holes: ApiHole[]) { const played = holes.filter((h) => h.played && h.score !== null) const diff = (h: ApiHole) => (h.score as number) - h.par const categories = [ { label: "Eagle+", test: (d: number) => d <= -2, color: C.eagle }, { label: "Birdie", test: (d: number) => d === -1, color: C.birdie }, { label: "Par", test: (d: number) => d === 0, color: C.par }, { label: "Bogey", test: (d: number) => d === 1, color: C.bogey }, { label: "Dobbel bogey", test: (d: number) => d === 2, color: C.dobbel }, { label: "Verre", test: (d: number) => d >= 3, color: C.verre }, ].map((c) => { const count = played.filter((h) => c.test(diff(h))).length const percentage = played.length > 0 ? (count / played.length) * 100 : 0 return { label: c.label, count, percentage, color: c.color } }) const avgToPar = avg(played.map(diff)) const byPar = (parValue: number) => avg(played.filter((h) => h.par === parValue).map(diff)) const fairwayTracked = holes.filter((h) => h.par !== 3 && h.tee_shot_result !== null) const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway") const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left") const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right") const fwTotal = fairwayTracked.length || 1 const avgToParFairwayHit = avg(fairwayHit.filter((h) => h.played && h.score !== null).map(diff)) const avgToParFairwayMiss = avg( [...fairwayLeft, ...fairwayRight].filter((h) => h.played && h.score !== null).map(diff), ) // GIR: samme formel som round-detail.tsx sin showGir -- krever kjent putt-tall. const girEligible = played.filter((h) => h.putts !== null) const isGir = (h: ApiHole) => (h.score as number) - (h.putts as number) <= h.par - 2 const girHit = girEligible.filter(isGir) const girPct = girEligible.length > 0 ? (girHit.length / girEligible.length) * 100 : null const girByPar = (parValue: number) => { const subset = girEligible.filter((h) => h.par === parValue) return subset.length > 0 ? (subset.filter(isGir).length / subset.length) * 100 : null } const girWithFairway = (want: "hit" | "miss") => { const subset = girEligible.filter((h) => want === "hit" ? h.tee_shot_result === "fairway" : h.tee_shot_result === "left" || h.tee_shot_result === "right", ) return subset.length > 0 ? (subset.filter(isGir).length / subset.length) * 100 : null } const avgToParWithGir = avg(girEligible.filter(isGir).map(diff)) const avgToParWithoutGir = avg(girEligible.filter((h) => !isGir(h)).map(diff)) const missedGreen = holes.filter((h) => h.approach_result !== null && h.approach_result !== "hit") const missTotal = missedGreen.length || 1 const missDir = (dir: "long" | "short" | "left" | "right") => (missedGreen.filter((h) => h.approach_result === dir).length / missTotal) * 100 const puttsEligible = played.filter((h) => h.putts !== null) const totalPutts = puttsEligible.reduce((s, h) => s + (h.putts as number), 0) const puttCategories = [ { label: "1-putt", test: (p: number) => p === 1, color: C.birdie }, { label: "2-putt", test: (p: number) => p === 2, color: C.par }, { label: "3-putt+", test: (p: number) => p >= 3, color: C.dobbel }, ].map((c) => { const count = puttsEligible.filter((h) => c.test(h.putts as number)).length const percentage = puttsEligible.length > 0 ? (count / puttsEligible.length) * 100 : 0 return { label: c.label, count, percentage, color: c.color } }) const puttsByPar = (parValue: number) => avg(puttsEligible.filter((h) => h.par === parValue).map((h) => h.putts as number)) const avgPuttsWithGir = avg(puttsEligible.filter(isGir).map((h) => h.putts as number)) const avgPuttsWithoutGir = avg(puttsEligible.filter((h) => h.putts !== null && !isGir(h)).map((h) => h.putts as number)) const onePuttPctByBucket = PUTT_BUCKETS.map((bucket) => { const subset = puttsEligible.filter((h) => h.first_putt_distance_bucket === bucket) return { label: bucket, percentage: subset.length > 0 ? (subset.filter((h) => h.putts === 1).length / subset.length) * 100 : 0 } }) const lengthByGir = (want: boolean) => PUTT_BUCKETS.map((bucket) => ({ label: bucket, value: girEligible.filter((h) => h.first_putt_distance_bucket === bucket && isGir(h) === want).length, })) const chipEligible = played.filter((h) => h.chip_count !== null) const avgChipsPerHole = avg(chipEligible.map((h) => h.chip_count as number)) const chipCategories = [ { label: "0 chip", test: (c: number) => c === 0, color: C.birdie }, { label: "1 chip", test: (c: number) => c === 1, color: C.bogey }, { label: "2+ chip", test: (c: number) => c >= 2, color: C.verre }, ].map((c) => { const count = chipEligible.filter((h) => c.test(h.chip_count as number)).length const percentage = chipEligible.length > 0 ? (count / chipEligible.length) * 100 : 0 return { label: c.label, count, percentage, color: c.color } }) const girMissed = girEligible.filter((h) => !isGir(h)) const scramblingPct = girMissed.length > 0 ? (girMissed.filter((h) => diff(h) <= 0).length / girMissed.length) * 100 : null const sandEligible = girMissed.filter((h) => h.bunker_shot_count !== null && h.bunker_shot_count >= 1) const sandSavePct = sandEligible.length > 0 ? (sandEligible.filter((h) => diff(h) <= 0).length / sandEligible.length) * 100 : null const bunkerEligible = played.filter((h) => h.bunker_shot_count !== null) const bunkerPerRound = bunkerEligible.reduce((s, h) => s + (h.bunker_shot_count as number), 0) const penaltyEligible = played.filter((h) => h.penalty_strokes !== null) const penaltyPerRound = penaltyEligible.reduce((s, h) => s + (h.penalty_strokes as number), 0) const avgToParWithBunker = avg(bunkerEligible.filter((h) => (h.bunker_shot_count as number) >= 1).map(diff)) const avgToParWithoutBunker = avg(bunkerEligible.filter((h) => (h.bunker_shot_count as number) === 0).map(diff)) const pctHolesWithPenalty = penaltyEligible.length > 0 ? (penaltyEligible.filter((h) => (h.penalty_strokes as number) >= 1).length / penaltyEligible.length) * 100 : null const anywayEligible = played.filter((h) => h.anyway_strokes !== null) const anywayPerRound = anywayEligible.length > 0 ? anywayEligible.reduce((s, h) => s + (h.anyway_strokes as number), 0) : null const pctHolesWithAnyway = anywayEligible.length > 0 ? (anywayEligible.filter((h) => (h.anyway_strokes as number) >= 1).length / anywayEligible.length) * 100 : null return { playedCount: played.length, totalStrokes: played.reduce((s, h) => s + (h.score as number), 0), totalToPar: played.reduce((s, h) => s + diff(h), 0), categories, avgToPar, byPar, fairwayTracked, fairwayHit, fairwayLeft, fairwayRight, fwTotal, avgToParFairwayHit, avgToParFairwayMiss, girEligible, girHit, girPct, girByPar, girWithFairway, avgToParWithGir, avgToParWithoutGir, missedGreen, missDir, totalPutts, puttCategories, puttsByPar, avgPuttsWithGir, avgPuttsWithoutGir, onePuttPctByBucket, lengthByGir, avgChipsPerHole, chipCategories, scramblingPct, sandSavePct, bunkerPerRound, penaltyPerRound, avgToParWithBunker, avgToParWithoutBunker, pctHolesWithPenalty, anywayPerRound, pctHolesWithAnyway, } } // 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 } // --- Screen ---------------------------------------------------------------- export function RoundStats({ 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) // 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) 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, refreshKey]) useEffect(() => { // Delt-ball-formater har ingen individuell round_hole-rad å hente -- // se guarden under, som viser en forklarende melding i stedet. if (!participantId || (round && SHARED_BALL_FORMATS.includes(round.play_format))) 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, round, refreshKey]) if (error) { return (

{error}

Tilbake til egne runder
) } if (!round) { return (
) } if (SHARED_BALL_FORMATS.includes(round.play_format)) { return (
Detaljert statistikk (fairwaytreff, GIR, putt-fordeling m.m.) er ikke tilgjengelig for{" "} {FORMAT_LABELS[round.play_format] ?? round.play_format} -- disse formatene registrerer kun ETT felles slagtall per side, ikke individuelle slag-for-slag-detaljer.{" "} Se scorekortet {" "} for hull-for-hull-forløpet i stedet.
) } if (!holes) { return (
) } const s = computeStats(holes) const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1) 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))}
{s.totalStrokes || "–"} {s.playedCount > 0 && ( {signed(s.totalToPar)} til par )}
{/* Selve hull-for-hull-scorekortet flyttet til en egen, dedikert horisontal side 2026-07-25 (erstatter en tidligere vertikal tabell her på statistikk-siden -- se ARCHITECTURE_DECISIONS.md ADR-033) -- denne siden forblir aggregert/utledet statistikk. */} Se scorekort
) } // --- Compass cell ---------------------------------------------------------- function CompassCell({ label, percentage }: { label: string; percentage: number }) { return (
{pct(percentage)} {label}
) }