teecup/frontend/components/round-stats.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

1224 lines
50 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"
// 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 (
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
<h2>
<button
type="button"
onClick={() => 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"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary-foreground/20">
<Icon aria-hidden="true" className="size-5" />
</span>
<span className="flex-1 text-lg font-extrabold tracking-tight">{title}</span>
<ChevronDown
aria-hidden="true"
className={cn("size-6 shrink-0 transition-transform", open && "rotate-180")}
/>
</button>
</h2>
{open && (
<div id={bodyId} className="flex flex-col gap-6 p-4 sm:p-5">
{children}
</div>
)}
</section>
)
}
// --- 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 (
<div
className="relative shrink-0"
style={{ width: size, height: size }}
role="img"
aria-label={segments.map((s) => `${s.label}: ${s.value}`).join(", ")}
>
<div
className="size-full rounded-full"
style={{ background: `conic-gradient(${stops})` }}
/>
<div className="absolute inset-[18%] flex flex-col items-center justify-center rounded-full bg-card text-center">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{centerTop}</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{centerBottom}
</span>
</div>
</div>
)
}
function Legend({ items }: { items: Array<{ label: string; color: string; value: string }> }) {
return (
<ul className="flex flex-col gap-2">
{items.map((it) => (
<li key={it.label} className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="size-4 shrink-0 rounded-md border border-black/10"
style={{ backgroundColor: it.color }}
/>
<span className="flex-1 text-base font-semibold text-foreground">{it.label}</span>
<span className="text-base font-bold tabular-nums text-foreground">{it.value}</span>
</li>
))}
</ul>
)
}
// --- 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 (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<span className="text-sm font-bold tabular-nums text-muted-foreground">
{pct(percentage)} <span className="text-foreground">· {count}</span>
</span>
</div>
<div className="h-6 overflow-hidden rounded-lg bg-muted">
<div
className="h-full rounded-lg"
style={{ width: `${Math.max(width, count > 0 ? 6 : 0)}%`, backgroundColor: color }}
/>
</div>
</div>
)
}
// --- 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 (
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 text-base font-semibold text-foreground">{label}</span>
<div className="relative h-6 flex-1 rounded-lg bg-muted">
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border" aria-hidden="true" />
<div
className="absolute inset-y-0 rounded-lg"
style={{
backgroundColor: color,
width: `${halfPct}%`,
left: positive ? "50%" : undefined,
right: !positive ? "50%" : undefined,
}}
/>
</div>
<span className="w-16 shrink-0 text-right text-base font-bold tabular-nums text-foreground">
{signed(value)}
</span>
</div>
)
}
// --- Horizontal progress bar (single value %) ------------------------------
function ProgressRow({
label,
percentage,
color = "var(--primary)",
suffix,
}: {
label: string
percentage: number
color?: string
suffix?: string
}) {
return (
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 text-base font-semibold text-foreground">{label}</span>
<div className="h-6 flex-1 overflow-hidden rounded-lg bg-muted">
<div
className="h-full rounded-lg"
style={{ width: `${percentage}%`, backgroundColor: color }}
/>
</div>
<span className="w-20 shrink-0 text-right text-base font-bold tabular-nums text-foreground">
{suffix ?? pct(percentage)}
</span>
</div>
)
}
// --- Paired stat tiles -----------------------------------------------------
function StatTilePair({
a,
b,
}: {
a: { label: string; value: string; hint?: string }
b: { label: string; value: string; hint?: string }
}) {
return (
<div className="grid grid-cols-2 gap-3">
{[a, b].map((t) => (
<div
key={t.label}
className="flex flex-col gap-1 rounded-2xl border border-border bg-muted/40 p-4"
>
<span className="text-3xl font-extrabold tabular-nums text-foreground">{t.value}</span>
<span className="text-sm font-semibold leading-snug text-muted-foreground text-pretty">
{t.label}
</span>
</div>
))}
</div>
)
}
// --- Big number stat with info toggle --------------------------------------
function InfoStat({
label,
value,
explanation,
}: {
label: string
value: string
explanation: string
}) {
const [showInfo, setShowInfo] = useState(false)
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-4xl font-extrabold tabular-nums text-foreground">{value}</span>
<button
type="button"
onClick={() => 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}
<Info aria-hidden="true" className="size-4 text-primary" />
<span className="sr-only">Vis forklaring</span>
</button>
{showInfo && (
<p className="rounded-xl bg-card p-3 text-sm leading-relaxed text-muted-foreground text-pretty">
{explanation}
</p>
)}
</div>
)
}
// --- 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 (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-muted/40 p-3">
<span className="text-sm font-bold text-foreground text-pretty">{title}</span>
<div className="flex flex-col gap-1.5">
{data.map((d) => (
<div key={d.label} className="flex items-center gap-2">
<span className="w-10 shrink-0 text-xs font-semibold tabular-nums text-muted-foreground">
{d.label}
</span>
<div className="h-4 flex-1 overflow-hidden rounded bg-muted">
<div
className="h-full rounded"
style={{ width: `${(d.value / max) * 100}%`, backgroundColor: color }}
/>
</div>
<span className="w-6 shrink-0 text-right text-xs font-bold tabular-nums text-foreground">
{d.value}
</span>
</div>
))}
</div>
</div>
)
}
// --- 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
// ADR-039 -- hvilken side deltakeren er på, null for slagspill/skins.
round_side_id: string | null
}
type ApiSide = {
id: string
label: string | null
}
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[]
sides: ApiSide[]
}
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
// (migrasjon 036, FEATURE_BACKLOG.md "Scramble: statistikk over utslag
// brukt per spiller"). Samme rad-form som round-detail.tsx sin ApiSideHole.
type ApiSideHole = {
hole_number: number
played: boolean
score: number | null
selected_participant_id: string | null
}
// 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", "chapman"]
const FORMAT_LABELS: Record<string, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
chapman: "Chapman",
}
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 ----------------------------------------------------------------
// Scramble/greensome: "utslag brukt" per spiller (migrasjon 036,
// FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per spiller").
// Ren klientside-telling fra allerede hentet round_hole-data (samme
// "regn ut ved lesing, ikke lagre et eget tall"-prinsipp som resten av
// denne siden) -- vises kun de hullene som faktisk har et registrert valg,
// ingen antakelse om at ALLE hull må ha ett (helt valgfritt å registrere).
function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHoles: Record<string, ApiSideHole[]> }) {
const rows = round.sides.map((side) => {
const holes = sideHoles[side.id] ?? []
const holesWithSelection = holes.filter((h) => h.selected_participant_id !== null)
const counts = new Map<string, number>()
for (const h of holesWithSelection) {
const pid = h.selected_participant_id as string
counts.set(pid, (counts.get(pid) ?? 0) + 1)
}
const sideParticipants = round.participants.filter((p) => p.round_side_id === side.id)
const playedCount = holes.filter((h) => h.played && h.score !== null).length
return {
side,
playedCount,
registeredCount: holesWithSelection.length,
entries: sideParticipants
.map((p) => ({ name: p.display_name, count: counts.get(p.id) ?? 0 }))
.sort((a, b) => b.count - a.count),
}
})
if (rows.every((r) => r.registeredCount === 0)) {
return (
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen har registrert hvem sitt utslag som ble brukt noe hull ennå -- valgfritt felt i
scoreregistreringen for {FORMAT_LABELS[round.play_format] ?? round.play_format}.
</div>
)
}
return (
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Utslag brukt</h2>
{rows.map(({ side, playedCount, registeredCount, entries }, idx) => (
<div key={side.id} className="flex flex-col gap-2">
{rows.length > 1 && (
<span className="text-sm font-semibold text-muted-foreground">
{side.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`}
</span>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{entries.map((e) => (
<li key={e.name} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{e.name}</span>
<span className="text-base font-extrabold tabular-nums text-foreground">{e.count} utslag</span>
</li>
))}
</ul>
<span className="text-xs text-muted-foreground">
Registrert {registeredCount} av {playedCount} spilte hull.
</span>
</div>
))}
</div>
)
}
export function RoundStats({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [participantId, setParticipantId] = 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)
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])
// Scramble/greensome: hent hver sides hull for "Utslag brukt"-sammendraget
// under (migrasjon 036) -- samme endepunkt round-detail.tsx allerede bruker
// for selve registreringen.
const [sideHoles, setSideHoles] = useState<Record<string, ApiSideHole[]>>({})
useEffect(() => {
if (!round || !SHARED_BALL_FORMATS.includes(round.play_format)) return
let cancelled = false
async function loadSideHoles() {
const entries = await Promise.all(
round!.sides.map(async (s) => {
const res = await fetch(`/rounds/${roundId}/sides/${s.id}/holes`, { credentials: "include" })
const data: ApiSideHole[] = res.ok ? await res.json() : []
return [s.id, data] as const
}),
)
if (!cancelled) setSideHoles(Object.fromEntries(entries))
}
void loadSideHoles()
return () => {
cancelled = true
}
}, [round, roundId, 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 (
<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>
)
}
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
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" />
Statistikk
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
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.{" "}
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="font-semibold text-primary underline underline-offset-2"
>
Se scorekortet
</Link>{" "}
for hull-for-hull-forløpet i stedet.
</div>
</main>
</div>
)
}
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>
)
}
const s = computeStats(holes)
const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1)
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" />
Rundestatistikk
</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="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</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>
<div className="flex flex-col items-end">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{s.totalStrokes || ""}</span>
{s.playedCount > 0 && (
<span className="text-sm font-bold text-brand-orange">{signed(s.totalToPar)} til par</span>
)}
</div>
</div>
{/* 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. */}
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
{/* Spillervelger, kun når runden har flere deltakere */}
{round.participants.length > 1 && (
<div role="tablist" aria-label="Velg spiller" className="-mx-4 overflow-x-auto px-4">
<div className="flex gap-2">
{round.participants.map((p) => {
const active = p.id === participantId
return (
<button
key={p.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
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, viewerId)}
</button>
)
})}
</div>
</div>
)}
{s.playedCount === 0 ? (
<p className="rounded-3xl border border-border bg-card p-6 text-center text-base font-semibold text-muted-foreground">
Ingen hull registrert ennå -- statistikk vises etter hvert som hull spilles.
</p>
) : (
<>
{/* 1. Scorer */}
<StatCard title="Scorer" icon={BarChart3}>
<div className="flex flex-col gap-3">
{s.categories.map((c) => (
<CategoryBar key={c.label} label={c.label} count={c.count} percentage={c.percentage} color={c.color} max={maxCatPct} />
))}
</div>
{s.avgToPar !== null && (
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Snitt til par</span>
<span className="text-5xl font-extrabold tabular-nums text-brand-orange">{signed(s.avgToPar)}</span>
<span className="text-sm font-semibold text-muted-foreground">per hull</span>
</div>
)}
{(s.byPar(3) !== null || s.byPar(4) !== null || s.byPar(5) !== null) && (
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">Snitt til par per hulltype</span>
{s.byPar(3) !== null && <DeviationBar label="Par 3" value={s.byPar(3)!} />}
{s.byPar(4) !== null && <DeviationBar label="Par 4" value={s.byPar(4)!} />}
{s.byPar(5) !== null && <DeviationBar label="Par 5" value={s.byPar(5)!} />}
</div>
)}
</StatCard>
{/* 2. Fairway */}
{s.fairwayTracked.length > 0 && (
<StatCard title="Fairway" icon={MoveHorizontal}>
<div className="flex flex-col gap-2">
<div className="flex h-10 overflow-hidden rounded-2xl">
{s.fairwayLeft.length > 0 && (
<div
className="flex items-center justify-center text-sm font-bold text-white"
style={{ width: `${(s.fairwayLeft.length / s.fwTotal) * 100}%`, backgroundColor: C.miss }}
>
{pct((s.fairwayLeft.length / s.fwTotal) * 100)}
</div>
)}
{s.fairwayHit.length > 0 && (
<div
className="flex items-center justify-center text-sm font-bold text-white"
style={{ width: `${(s.fairwayHit.length / s.fwTotal) * 100}%`, backgroundColor: C.hit }}
>
{pct((s.fairwayHit.length / s.fwTotal) * 100)}
</div>
)}
{s.fairwayRight.length > 0 && (
<div
className="flex items-center justify-center text-sm font-bold text-white"
style={{ width: `${(s.fairwayRight.length / s.fwTotal) * 100}%`, backgroundColor: C.bogey }}
>
{pct((s.fairwayRight.length / s.fwTotal) * 100)}
</div>
)}
</div>
<div className="flex justify-between text-sm font-semibold text-foreground">
<span>Bom venstre</span>
<span>Truffet</span>
<span>Bom høyre</span>
</div>
</div>
{(s.avgToParFairwayHit !== null || s.avgToParFairwayMiss !== null) && (
<StatTilePair
a={{ label: "Snitt til par når fairway truffet", value: s.avgToParFairwayHit !== null ? signed(s.avgToParFairwayHit) : "" }}
b={{ label: "Snitt til par når fairway bommet", value: s.avgToParFairwayMiss !== null ? signed(s.avgToParFairwayMiss) : "" }}
/>
)}
</StatCard>
)}
{/* 3. Greentreff (GIR) */}
{s.girPct !== null && (
<StatCard title="Greentreff (GIR)" icon={Target}>
<div className="flex flex-col items-center gap-5 sm:flex-row">
<Donut
segments={[
{ label: "Truffet green", value: s.girHit.length, color: C.hit },
{ label: "Bommet green", value: s.girEligible.length - s.girHit.length, color: C.miss },
]}
centerTop={pct(s.girPct)}
centerBottom="GIR"
/>
<div className="w-full flex-1">
<Legend
items={[
{ label: "Truffet green", color: C.hit, value: `${s.girHit.length} · ${pct(s.girPct)}` },
{
label: "Bommet green",
color: C.miss,
value: `${s.girEligible.length - s.girHit.length} · ${pct(100 - s.girPct)}`,
},
]}
/>
</div>
</div>
{(s.girByPar(3) !== null || s.girByPar(4) !== null || s.girByPar(5) !== null) && (
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">GIR per hulltype</span>
{s.girByPar(3) !== null && <ProgressRow label="Par 3" percentage={s.girByPar(3)!} color={C.hit} />}
{s.girByPar(4) !== null && <ProgressRow label="Par 4" percentage={s.girByPar(4)!} color={C.hit} />}
{s.girByPar(5) !== null && <ProgressRow label="Par 5" percentage={s.girByPar(5)!} color={C.hit} />}
</div>
)}
{(s.girWithFairway("hit") !== null || s.girWithFairway("miss") !== null) && (
<StatTilePair
a={{ label: "GIR når fairway truffet", value: s.girWithFairway("hit") !== null ? pct(s.girWithFairway("hit")!) : "" }}
b={{ label: "GIR når fairway bommet", value: s.girWithFairway("miss") !== null ? pct(s.girWithFairway("miss")!) : "" }}
/>
)}
{(s.avgToParWithGir !== null || s.avgToParWithoutGir !== null) && (
<StatTilePair
a={{ label: "Snitt til par med GIR", value: s.avgToParWithGir !== null ? signed(s.avgToParWithGir) : "" }}
b={{ label: "Snitt til par uten GIR", value: s.avgToParWithoutGir !== null ? signed(s.avgToParWithoutGir) : "" }}
/>
)}
</StatCard>
)}
{/* 4. Bom-retning på green */}
{s.missedGreen.length > 0 && (
<StatCard title="Bom-retning på green" icon={Wind}>
<p className="text-sm font-semibold text-muted-foreground text-pretty">
Retning når greenen ble bommet ({s.missedGreen.length} hull).
</p>
<div className="mx-auto grid w-full max-w-xs grid-cols-3 grid-rows-3 gap-2">
<div className="col-start-2 row-start-1">
<CompassCell label="Langt" percentage={s.missDir("long")} />
</div>
<div className="col-start-1 row-start-2">
<CompassCell label="Venstre" percentage={s.missDir("left")} />
</div>
<div className="col-start-2 row-start-2 flex items-center justify-center">
<div className="relative flex size-20 items-center justify-center rounded-full bg-primary/15">
<div className="flex size-14 items-center justify-center rounded-full bg-primary/30">
<Flag aria-hidden="true" className="size-7 text-primary" fill="currentColor" />
</div>
</div>
</div>
<div className="col-start-3 row-start-2">
<CompassCell label="Høyre" percentage={s.missDir("right")} />
</div>
<div className="col-start-2 row-start-3">
<CompassCell label="Kort" percentage={s.missDir("short")} />
</div>
</div>
</StatCard>
)}
{/* 5. Putter */}
{s.puttCategories.some((c) => c.count > 0) && (
<StatCard title="Putter" icon={Circle}>
<div className="flex flex-col items-center gap-5 sm:flex-row">
<Donut
segments={s.puttCategories.map((c) => ({ label: c.label, value: c.count, color: c.color }))}
centerTop={String(s.totalPutts)}
centerBottom="putter/runde"
/>
<div className="w-full flex-1">
<Legend
items={s.puttCategories.map((c) => ({
label: c.label,
color: c.color,
value: `${c.count} · ${pct(c.percentage)}`,
}))}
/>
</div>
</div>
{(s.puttsByPar(3) !== null || s.puttsByPar(4) !== null || s.puttsByPar(5) !== null) && (
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">Snitt putter per hull</span>
{[3, 4, 5].map(
(parValue) =>
s.puttsByPar(parValue) !== null && (
<ProgressRow
key={parValue}
label={`Par ${parValue}`}
percentage={Math.min((s.puttsByPar(parValue)! / 3) * 100, 100)}
color={C.par}
suffix={s.puttsByPar(parValue)!.toFixed(2).replace(".", ",")}
/>
),
)}
</div>
)}
{(s.avgPuttsWithGir !== null || s.avgPuttsWithoutGir !== null) && (
<StatTilePair
a={{ label: "Snitt putter med GIR", value: s.avgPuttsWithGir !== null ? s.avgPuttsWithGir.toFixed(2).replace(".", ",") : "" }}
b={{ label: "Snitt putter uten GIR", value: s.avgPuttsWithoutGir !== null ? s.avgPuttsWithoutGir.toFixed(2).replace(".", ",") : "" }}
/>
)}
</StatCard>
)}
{/* 6. Putt-lengde */}
{s.onePuttPctByBucket.some((b) => b.percentage > 0) && (
<StatCard title="Putt-lengde" icon={BarChart3}>
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">Andel én-putt etter lengde første putt</span>
{s.onePuttPctByBucket.map((b, i) => (
<ProgressRow
key={b.label}
label={b.label}
percentage={b.percentage}
color={[C.birdie, C.birdie, C.par, C.bogey, C.dobbel, C.verre][i]}
/>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<MiniDistribution title="Lengde når green truffet" color={C.hit} data={s.lengthByGir(true)} />
<MiniDistribution title="Lengde når green bommet" color={C.miss} data={s.lengthByGir(false)} />
</div>
</StatCard>
)}
{/* 7. Chip, bunker og straffeslag */}
<StatCard title="Chip, bunker og straffeslag" icon={Waves}>
{(s.scramblingPct !== null || s.sandSavePct !== null) && (
<div className="grid grid-cols-2 gap-3">
<InfoStat
label="Scrambling"
value={s.scramblingPct !== null ? pct(s.scramblingPct) : ""}
explanation="Andel hull der du reddet par eller bedre uten å treffe greenen i regulering (GIR)."
/>
<InfoStat
label="Sand save"
value={s.sandSavePct !== null ? pct(s.sandSavePct) : ""}
explanation="Andel bunkerhull der du kom deg opp og i hull for par eller bedre fra greenbunker."
/>
</div>
)}
{s.chipCategories.some((c) => c.count > 0) && (
<div className="flex flex-col items-center gap-5 sm:flex-row">
<Donut
segments={s.chipCategories.map((c) => ({ label: c.label, value: c.count, color: c.color }))}
centerTop={s.avgChipsPerHole !== null ? s.avgChipsPerHole.toFixed(2).replace(".", ",") : ""}
centerBottom="chip/hull"
/>
<div className="w-full flex-1">
<Legend
items={s.chipCategories.map((c) => ({
label: c.label,
color: c.color,
value: `${c.count} · ${pct(c.percentage)}`,
}))}
/>
</div>
</div>
)}
<StatTilePair
a={{ label: "Bunkerslag per runde", value: String(s.bunkerPerRound) }}
b={{ label: "Straffeslag per runde", value: String(s.penaltyPerRound) }}
/>
{(s.avgToParWithBunker !== null || s.avgToParWithoutBunker !== null) && (
<StatTilePair
a={{ label: "Snitt til par med bunkerslag", value: s.avgToParWithBunker !== null ? signed(s.avgToParWithBunker) : "" }}
b={{ label: "Snitt til par uten bunkerslag", value: s.avgToParWithoutBunker !== null ? signed(s.avgToParWithoutBunker) : "" }}
/>
)}
{s.pctHolesWithPenalty !== null && (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{pct(s.pctHolesWithPenalty)}</span>
<span className="text-sm font-semibold text-muted-foreground text-pretty">Andel hull med minst ett straffeslag</span>
</div>
)}
</StatCard>
{/* 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 && (
<StatCard title="Annet" icon={MoreHorizontal}>
<StatTilePair
a={{ label: "Anywayslag per runde", value: String(s.anywayPerRound) }}
b={{
label: "Andel hull med anywayslag",
value: s.pctHolesWithAnyway !== null ? pct(s.pctHolesWithAnyway) : "",
}}
/>
</StatCard>
)}
</>
)}
</main>
</div>
)
}
// --- Compass cell ----------------------------------------------------------
function CompassCell({ label, percentage }: { label: string; percentage: number }) {
return (
<div className="flex min-h-16 flex-col items-center justify-center gap-0.5 rounded-2xl border border-border bg-muted/40 p-2 text-center">
<span className="text-lg font-extrabold tabular-nums text-foreground">{pct(percentage)}</span>
<span className="text-sm font-semibold text-muted-foreground">{label}</span>
</div>
)
}