"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 (
setOpen((v) => !v)}
aria-expanded={open}
aria-controls={bodyId}
className="flex min-h-14 w-full items-center gap-3 bg-primary px-4 py-3 text-left text-primary-foreground"
>
{title}
{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 (
{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 (
)
}
// --- 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}
setShowInfo((v) => !v)}
aria-expanded={showInfo}
className="flex min-h-11 items-center gap-1.5 text-left text-base font-bold text-foreground"
>
{label}
Vis forklaring
{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 (
)
}
// --- API-typer (samme form som round-detail.tsx allerede bruker) -----------
type ApiParticipant = {
id: string
guest_name: string | null
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
participants: ApiParticipant[]
}
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,
}
}
function participantLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
}
// --- 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)
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(data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
}
void load()
return () => {
cancelled = true
}
}, [roundId])
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 (
)
}
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
{/* Spillervelger, kun når runden har flere deltakere */}
{round.participants.length > 1 && (
{round.participants.map((p) => {
const active = p.id === participantId
return (
{
setHoles(null)
setParticipantId(p.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",
)}
>
{participantLabel(p)}
)
})}
)}
{s.playedCount === 0 ? (
Ingen hull registrert ennå -- statistikk vises etter hvert som hull spilles.
) : (
<>
{/* 1. Scorer */}
{s.categories.map((c) => (
))}
{s.avgToPar !== null && (
Snitt til par
{signed(s.avgToPar)}
per hull
)}
{(s.byPar(3) !== null || s.byPar(4) !== null || s.byPar(5) !== null) && (
Snitt til par per hulltype
{s.byPar(3) !== null && }
{s.byPar(4) !== null && }
{s.byPar(5) !== null && }
)}
{/* 2. Fairway */}
{s.fairwayTracked.length > 0 && (
{s.fairwayLeft.length > 0 && (
{pct((s.fairwayLeft.length / s.fwTotal) * 100)}
)}
{s.fairwayHit.length > 0 && (
{pct((s.fairwayHit.length / s.fwTotal) * 100)}
)}
{s.fairwayRight.length > 0 && (
{pct((s.fairwayRight.length / s.fwTotal) * 100)}
)}
Bom venstre
Truffet
Bom høyre
{(s.avgToParFairwayHit !== null || s.avgToParFairwayMiss !== null) && (
)}
)}
{/* 3. Greentreff (GIR) */}
{s.girPct !== null && (
{(s.girByPar(3) !== null || s.girByPar(4) !== null || s.girByPar(5) !== null) && (
GIR per hulltype
{s.girByPar(3) !== null &&
}
{s.girByPar(4) !== null &&
}
{s.girByPar(5) !== null &&
}
)}
{(s.girWithFairway("hit") !== null || s.girWithFairway("miss") !== null) && (
)}
{(s.avgToParWithGir !== null || s.avgToParWithoutGir !== null) && (
)}
)}
{/* 4. Bom-retning på green */}
{s.missedGreen.length > 0 && (
Retning når greenen ble bommet ({s.missedGreen.length} hull).
)}
{/* 5. Putter */}
{s.puttCategories.some((c) => c.count > 0) && (
({ label: c.label, value: c.count, color: c.color }))}
centerTop={String(s.totalPutts)}
centerBottom="putter/runde"
/>
({
label: c.label,
color: c.color,
value: `${c.count} · ${pct(c.percentage)}`,
}))}
/>
{(s.puttsByPar(3) !== null || s.puttsByPar(4) !== null || s.puttsByPar(5) !== null) && (
Snitt putter per hull
{[3, 4, 5].map(
(parValue) =>
s.puttsByPar(parValue) !== null && (
),
)}
)}
{(s.avgPuttsWithGir !== null || s.avgPuttsWithoutGir !== null) && (
)}
)}
{/* 6. Putt-lengde */}
{s.onePuttPctByBucket.some((b) => b.percentage > 0) && (
Andel én-putt etter lengde på første putt
{s.onePuttPctByBucket.map((b, i) => (
))}
)}
{/* 7. Chip, bunker og straffeslag */}
{(s.scramblingPct !== null || s.sandSavePct !== null) && (
)}
{s.chipCategories.some((c) => c.count > 0) && (
({ label: c.label, value: c.count, color: c.color }))}
centerTop={s.avgChipsPerHole !== null ? s.avgChipsPerHole.toFixed(2).replace(".", ",") : "–"}
centerBottom="chip/hull"
/>
({
label: c.label,
color: c.color,
value: `${c.count} · ${pct(c.percentage)}`,
}))}
/>
)}
{(s.avgToParWithBunker !== null || s.avgToParWithoutBunker !== null) && (
)}
{s.pctHolesWithPenalty !== null && (
{pct(s.pctHolesWithPenalty)}
Andel hull med minst ett straffeslag
)}
{/* 8. Annet -- egen seksjon for rundens anywayslag, ETTER
chip/bunker/straffeslag (rettet 2026-07-25 -- feilaktig slått
sammen med den seksjonen i forrige runde). Notatfelt kommer
trolig hit senere. */}
{s.anywayPerRound !== null && (
)}
>
)}
)
}
// --- Compass cell ----------------------------------------------------------
function CompassCell({ label, percentage }: { label: string; percentage: number }) {
return (
{pct(percentage)}
{label}
)
}