teecup/frontend/components/rounds-stats-summary.tsx
Erol Haagenrud c967ed11da Statistikk: ekspandert trekkspill, spilt-til-HCP, omstrukturert /my-rounds/stats
Tre brukertilbakemeldinger etter gjennomgang av statistikksidene:

1. round-stats.tsx: trekkspillseksjonene starter nå ekspandert (reverserer
   en tidligere beslutning fra 2026-07-25 om å vise dem sammenslått).

2. Dashbordets "HCP nå" viser nå computed_handicap_index ("spilt til"-HCP,
   beregnet fra faktiske runder) i stedet for det manuelt satte tallet i
   profilen -- historikk-sparklinen filtreres til samme kilde (source=
   "computed"), så den ikke lenger blander manuelle og beregnede punkter.

3. /my-rounds/stats:
   - Alle "vs. forrige periode"-deltaer (pil+tall+pp) er nå synlig
     selvforklarende -- teksten fantes tidligere kun som skjermleser-tekst,
     seende brukere så bare et ikon og et tall uten kontekst.
   - Greentreff (GIR) og Innspill splittet i to kort (GIR er en beregnet
     andel, Innspill er en retningsbeskrivelse -- samme prinsipp som
     round-stats.tsx sin eksisterende splitt). Utslag flyttet først.
     Redning og "Til par: med vs. uten" flyttet ned under Annet.
   - Ny stolpegraf med glidende snitt-trendlinje (snitt til par per runde,
     eldste til venstre) -- tidligere bevisst utsatt pga. for lite
     rundehistorikk (se CHANGELOG 2026-07-28), bygget nå. Backend:
     RoundStatsSummary fikk et nytt round_series-felt (kronologisk
     snitt-til-par per runde i valgt vindu).

Verifisert: tsc --noEmit rent, 45/45 vitest, 31/31 backend-tester (inkl.
det nye round_series-feltet via en scratch-DB-kjøring). Full scratch-
stack med seks syntetiske runder (varierende dato/resultat) browser-
verifisert lys+mørk: HCP-bytte bekreftet (computed 5,3 vist i stedet for
manuelt satt 15,0), ny kortrekkefølge, splittet GIR/Innspill, synlige
delta-bildetekster, graf med trendlinje. Ingen konsollfeil. teecup_db
urørt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 09:12:49 +02:00

829 lines
35 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"
// Aggregert statistikk på tvers av "Egne runder" (ADR-033), lenket fra
// dashbordets "Statistikk"-seksjon. Egen backend-aggregering
// (GET /rounds/stats/summary) -- ikke bare klientside-utledning av
// round-stats.tsx sin logikk, siden putt/18-hull-regelen (padding av
// uspilte hull til 2 putter, KUN for dette tallet) og "forrige periode"-
// vinduet må regnes over ALLE fullførte runder samlet, ikke én om gangen.
//
// Visuell versjon 2 (2026-07-28): brukeren delte et referansebilde av en
// konkurrent-app sin statistikkskjerm (donuter med sentertall, kompass-
// diagram for miss-retning) og ba om noe tilsvarende -- håndkodet direkte
// (ingen V0-credits igjen), verifisert med ekte skjermbilder via Chrome
// DevTools MCP i stedet for kun typesjekk, se CLAUDE.md. Inspirert av
// referansen, ikke kopiert: egne farger/ikonografi, ingen flyt-diagram for
// scrambling/sand save (ville antydet en forgrening dataene ikke har -- det
// er to uavhengige prosenter, ikke en beslutningstre), ingen trendlinje
// ennå (for lite rundehistorikk til å være meningsfullt, se CLAUDE.md).
import type React from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BarChart3, Flag, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react"
type ApiRoundSeriesPoint = { played_at: string; score_to_par: number }
type ApiStatsSummary = {
rounds_completed: number
avg_score_to_par: number | null
round_series: ApiRoundSeriesPoint[]
avg_putts_per_18: number | null
putts_tracked_rounds: number
fairway_hit_pct: number | null
fairway_left_pct: number | null
fairway_right_pct: number | null
fairway_tracked_holes: number
gir_pct: number | null
gir_tracked_holes: number
green_miss_long_pct: number | null
green_miss_short_pct: number | null
green_miss_left_pct: number | null
green_miss_right_pct: number | null
green_miss_tracked_holes: number
one_putt_pct: number | null
putt_dist_one_pct: number | null
putt_dist_two_pct: number | null
putt_dist_three_plus_pct: number | null
scrambling_pct: number | null
sand_save_pct: number | null
avg_chip_per_round: number | null
avg_bunker_per_round: number | null
avg_penalty_per_round: number | null
avg_anyway_per_round: number | null
avg_to_par_with_gir: number | null
avg_to_par_without_gir: number | null
avg_to_par_fairway_hit: number | null
avg_to_par_fairway_miss: number | null
avg_to_par_with_bunker: number | null
avg_to_par_without_bunker: number | null
avg_to_par_with_anyway: number | null
avg_to_par_without_anyway: number | null
}
type ApiWindowSummary = {
window: string
current: ApiStatsSummary
previous: ApiStatsSummary
}
type WindowKey = "last_round" | "last_5" | "last_10" | "month" | "year" | "last_year" | "all"
const WINDOW_OPTIONS: { key: WindowKey; label: string }[] = [
{ key: "last_round", label: "Siste runde" },
{ key: "last_5", label: "Siste 5" },
{ key: "last_10", label: "Siste 10" },
{ key: "month", label: "Denne måneden" },
{ key: "year", label: "I år" },
{ key: "last_year", label: "Siste år" },
{ key: "all", label: "Alltid" },
]
function signed(n: number): string {
const sign = n > 0 ? "+" : n < 0 ? "" : "±"
return `${sign}${Math.abs(n).toFixed(1).replace(".", ",")}`
}
function pct(n: number): string {
return `${Math.round(n)} %`
}
// Prosentpoeng-differanse mellom to andeler (ikke "N % endring", men "N pp").
function signedPoints(n: number): string {
const rounded = Math.round(n)
const sign = rounded > 0 ? "+" : rounded < 0 ? "" : "±"
return `${sign}${Math.abs(rounded)} pp`
}
// "up" = høyere tall er bedre (fairwaytreff, GIR, ...), "down" = lavere
// tall er bedre (snitt til par, putt/runde, ...). Utelatt = ren beskrivende
// retning (miss-retning venstre/høyre osv.) -- ingen "bedre/verre" der.
function Delta({
current,
previous,
format,
goodDirection,
}: {
current: number | null
previous: number | null
format: (diff: number) => string
goodDirection?: "up" | "down"
}) {
// Teksten "vs. forrige periode" var tidligere KUN skjermleser-tekst --
// seende brukere så bare et ikon + et tall + "pp", uten noen synlig
// forklaring på hva tallet ble sammenlignet MOT. Nå alltid synlig, slik
// at delta-en er selvforklarende alene, uten å måtte lese en fjern
// overskrift lenger oppe på siden (2026-08-12, brukertilbakemelding).
if (current === null || previous === null) return null
const diff = current - previous
if (diff === 0) {
return <span className="text-xs font-semibold text-muted-foreground">Uendret vs. forrige periode</span>
}
const improved = goodDirection === "up" ? diff > 0 : goodDirection === "down" ? diff < 0 : null
const Icon = diff > 0 ? TrendingUp : TrendingDown
const colorClass = improved === true ? "text-primary" : improved === false ? "text-brand-orange" : "text-muted-foreground"
return (
<span className={`inline-flex flex-wrap items-center gap-1 text-xs font-bold ${colorClass}`}>
<Icon aria-hidden="true" className="size-3.5" />
<span className="tabular-nums">{format(diff)}</span>
<span className="font-semibold text-muted-foreground">vs. forrige periode</span>
</span>
)
}
function StatTile({
label,
value,
hint,
delta,
}: {
label: string
value: string
hint?: string
delta?: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
{delta}
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</div>
)
}
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div>
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
{children}
</section>
)
}
function Section({
title,
icon: Icon,
children,
}: {
title: string
icon: typeof BarChart3
children: React.ReactNode
}) {
return (
<section className="flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-lg font-extrabold tracking-tight text-foreground">
<Icon aria-hidden="true" className="size-5 text-primary" />
{title}
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">{children}</div>
</section>
)
}
// --- Donut: sentertall + inntil 4 fargede segmenter (conic-gradient) -------
// Gjenbrukt for BÅDE puttfordelingen (3 segmenter) og de enkle rednings-
// gaugene (1 segment fylt + resten "spor"). Prosent er ALLTID skrevet ut i
// en forklaringsliste under -- fargen bærer aldri betydningen alene.
function Donut({
segments,
centerValue,
centerLabel,
size = 132,
thickness = 18,
showLegend = true,
}: {
segments: { label: string; value: number; color: string }[]
centerValue: string
centerLabel: string
size?: number
thickness?: number
showLegend?: boolean
}) {
let cursor = 0
const stops: string[] = []
for (const seg of segments) {
const start = cursor
const end = cursor + seg.value
stops.push(`${seg.color} ${start}% ${end}%`)
cursor = end
}
if (cursor < 100) stops.push(`var(--muted) ${cursor}% 100%`)
const gradient = `conic-gradient(${stops.join(", ")})`
const ring = (
<div
className="relative shrink-0 rounded-full"
style={{ width: size, height: size, background: gradient }}
role="img"
aria-label={`${centerLabel}: ${centerValue}. ${segments.map((s) => `${s.label} ${Math.round(s.value)} prosent`).join(", ")}`}
>
<div
className="absolute rounded-full bg-card"
style={{
width: size - thickness * 2,
height: size - thickness * 2,
top: thickness,
left: thickness,
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-extrabold tabular-nums text-foreground">{centerValue}</span>
<span className="text-[11px] font-semibold text-muted-foreground">{centerLabel}</span>
</div>
</div>
)
if (!showLegend) return ring
return (
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-center">
{ring}
<ul className="flex flex-1 flex-col gap-1.5">
{segments.map((s) => (
<li key={s.label} className="flex items-center gap-2 text-sm text-muted-foreground">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: s.color }} />
<span className="flex-1">{s.label}</span>
<span className="font-bold tabular-nums text-foreground">{Math.round(s.value)} %</span>
</li>
))}
</ul>
</div>
)
}
// --- Kompass-diagram for greentreff-miss-retning ----------------------------
// Greentreff i midten, langt over, kort under, venstre til venstre, høyre
// til høyre -- speiler hvor bommen faktisk gikk relativt til green, i
// stedet for en flat fordelingsbar. Alle fire retningsceller viser tallet
// sitt som tekst uansett -- retning er beskrivende, ikke god/dårlig, så
// ingen av dem fargekodes grønn/oransje (kun sentercellen, som ER et
// treff/bom-tall, får grønn fremheving).
function CompassCell({ icon: Icon, label, value }: { icon: typeof ArrowUp; label: string; value: number | null }) {
return (
<div className="flex min-h-16 flex-col items-center justify-center gap-0.5 rounded-2xl border border-border bg-card p-2">
<Icon aria-hidden="true" className="size-4 text-muted-foreground" />
<span className="text-lg font-extrabold tabular-nums text-foreground">{value !== null ? `${Math.round(value)} %` : ""}</span>
<span className="text-[11px] font-semibold text-muted-foreground">{label}</span>
</div>
)
}
// Greentreff (GIR) og Innspill var tidligere ÉN kombinert komponent
// (GirCompass). Splittet i to 2026-08-12 på brukerens eksplisitte
// instruks: GIR er en beregnet andel (traff/bommet), Innspill er en
// retningsbeskrivelse -- to ulike typer informasjon, samme prinsipp som
// round-stats.tsx sin split mellom "Greentreff (GIR)" og "Bom-retning på
// green" (se den filen).
function GirGauge({ hitPct, trackedHoles }: { hitPct: number | null; trackedHoles: number }) {
if (hitPct === null) {
return <p className="text-sm text-muted-foreground">Ingen greentreff-data registrert ennå.</p>
}
return (
<div className="flex flex-col items-center gap-2">
<Donut
segments={[
{ label: "Traff green", value: hitPct, color: "var(--primary)" },
{ label: "Bommet green", value: 100 - hitPct, color: "var(--brand-orange)" },
]}
centerValue={`${Math.round(hitPct)} %`}
centerLabel="GIR"
/>
<p className="text-xs text-muted-foreground">{trackedHoles} hull registrert</p>
</div>
)
}
function ApproachDirectionCompass({
trackedHoles,
long,
short,
left,
right,
}: {
trackedHoles: number
long: number | null
short: number | null
left: number | null
right: number | null
}) {
if (trackedHoles === 0) {
return <p className="text-sm text-muted-foreground">Ingen innspill med retning registrert ennå.</p>
}
return (
<div className="flex flex-col items-center gap-3">
<div className="grid w-full max-w-[280px] grid-cols-3 grid-rows-3 gap-2.5">
<div aria-hidden="true" />
<CompassCell icon={ArrowUp} label="Langt" value={long} />
<div aria-hidden="true" />
<CompassCell icon={ArrowLeft} label="Venstre" value={left} />
<div className="flex items-center justify-center">
<div className="relative flex size-14 items-center justify-center rounded-full bg-primary/15">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/30">
<Flag aria-hidden="true" className="size-5 text-primary" fill="currentColor" />
</div>
</div>
</div>
<CompassCell icon={ArrowRight} label="Høyre" value={right} />
<div aria-hidden="true" />
<CompassCell icon={ArrowDown} label="Kort" value={short} />
<div aria-hidden="true" />
</div>
<p className="text-center text-xs text-muted-foreground">{trackedHoles} innspill registrert med retning.</p>
</div>
)
}
// --- Fairway: enklere venstre/fairway/høyre-bar (ingen langt/kort-akse
// fra utslaget, så ikke et fullt kompass) --------------------------------
function FairwayBar({
tracked,
left,
hit,
right,
}: {
tracked: number
left: number | null
hit: number | null
right: number | null
}) {
if (tracked === 0) return <p className="text-sm text-muted-foreground">Ingen utslag registrert ennå.</p>
const segments = [
{ label: "Venstre", value: left, color: "var(--brand-orange)" },
{ label: "Fairway", value: hit, color: "var(--primary)" },
{ label: "Høyre", value: right, color: "var(--brand-orange)" },
]
return (
<div className="flex flex-col gap-2">
<div className="flex h-4 w-full overflow-hidden rounded-full bg-muted">
{segments.map((s) =>
s.value && s.value > 0 ? (
<div key={s.label} style={{ width: `${s.value}%`, backgroundColor: s.color }} title={`${s.label}: ${Math.round(s.value)} %`} />
) : null,
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{segments.map((s) => (
<span key={s.label} className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="size-2.5 rounded-full" style={{ backgroundColor: s.color }} />
{s.label}: <span className="font-semibold tabular-nums text-foreground">{s.value !== null ? pct(s.value) : ""}</span>
</span>
))}
</div>
</div>
)
}
// --- Utvikling over tid: stolpegraf + trendlinje -----------------------
// Brukerinstruks 2026-08-12: stolpe per runde (eldste til venstre) +
// trendlinje over. Tidligere bevisst UTSATT (se CHANGELOG 2026-07-28: "for
// lite rundehistorikk til å vise noe meningsfullt") -- bygges nå som
// brukeren har nok fullførte runder til at en trend faktisk sier noe.
//
// Fargevalg følger samme diverging-par som `Delta` allerede bruker på
// resten av siden (primary = under/på par, brand-orange = over par) --
// ikke en ny palett, samme etablerte semantikk. Trendlinjen er et
// glidende snitt (ikke rå lineær regresjon), tegnet i en nøytral,
// dempet farge og stiplet, slik at den tydelig leses som UTLEDET, ikke
// som en tredje rådataserie.
const TREND_BAR_WIDTH = 20
const TREND_BAR_GAP = 10
const TREND_CHART_HEIGHT = 160
const TREND_HALF_HEIGHT = TREND_CHART_HEIGHT / 2 - 14 // plass til dato-etiketter under
function movingAverage(values: number[], window: number): number[] {
return values.map((_, i) => {
const start = Math.max(0, i - window + 1)
const slice = values.slice(start, i + 1)
return slice.reduce((sum, v) => sum + v, 0) / slice.length
})
}
function formatShortDate(iso: string): string {
const d = new Date(iso)
return `${d.getDate()}.${d.getMonth() + 1}`
}
// Stolpe med avrundet ende BORTE fra 0-linjen, skarp kant VED 0-linjen --
// samme prinsipp uansett om verdien er over eller under par (dataviz-
// skillets "4px avrundet dataende, skarp ved grunnlinjen").
function barPath(x: number, valueY: number, baselineY: number, width: number, radius: number): string {
const top = Math.min(valueY, baselineY)
const bottom = Math.max(valueY, baselineY)
const h = bottom - top
if (h < radius) {
// For kort til å avrunde pent -- vanlig rektangel.
return `M ${x} ${top} H ${x + width} V ${bottom} H ${x} Z`
}
const roundedAtTop = valueY < baselineY
if (roundedAtTop) {
return `M ${x} ${top + radius} A ${radius} ${radius} 0 0 1 ${x + radius} ${top} H ${x + width - radius} A ${radius} ${radius} 0 0 1 ${x + width} ${top + radius} V ${bottom} H ${x} Z`
}
return `M ${x} ${top} H ${x + width} V ${bottom - radius} A ${radius} ${radius} 0 0 1 ${x + width - radius} ${bottom} H ${x + radius} A ${radius} ${radius} 0 0 1 ${x} ${bottom - radius} Z`
}
function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
if (series.length < 2) return null
const values = series.map((p) => p.score_to_par)
const trend = movingAverage(values, 3)
const maxAbs = Math.max(...values.map((v) => Math.abs(v)), 1)
const scale = TREND_HALF_HEIGHT / maxAbs
const baselineY = TREND_CHART_HEIGHT - 20
const width = series.length * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_GAP
const barCenterX = (i: number) => TREND_BAR_GAP + i * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_WIDTH / 2
const valueToY = (v: number) => baselineY - v * scale
const trendPoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
return (
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={`Snitt til par for ${series.length} runder, fra ${formatShortDate(series[0].played_at)} til ${formatShortDate(series[series.length - 1].played_at)}.`}
viewBox={`0 0 ${width} ${TREND_CHART_HEIGHT}`}
width={width}
height={TREND_CHART_HEIGHT}
className="block"
>
{/* 0-linjen (par) -- hårfin, ubrutt, tilbaketrukket, som resten av rutenettet skal være. */}
<line x1={0} y1={baselineY} x2={width} y2={baselineY} stroke="var(--border)" strokeWidth={1} />
{series.map((point, i) => {
const cx = barCenterX(i)
const overPar = point.score_to_par > 0
const isLast = i === series.length - 1
return (
<g key={point.played_at + i}>
<path
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.score_to_par), baselineY, TREND_BAR_WIDTH, 4)}
fill={overPar ? "var(--brand-orange)" : "var(--primary)"}
>
<title>
{formatShortDate(point.played_at)}: {signed(point.score_to_par)}
</title>
</path>
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
"merk endepunktet, ikke hvert punkt". Resten leses via 0-linjen,
stolpenes relative høyde, og trendlinjens retning. */}
{isLast && (
<text
x={cx}
y={valueToY(point.score_to_par) + (overPar ? -6 : 14)}
textAnchor="middle"
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{signed(point.score_to_par)}
</text>
)}
{(i === 0 || i === series.length - 1 || i === Math.floor(series.length / 2)) && (
<text x={cx} y={TREND_CHART_HEIGHT - 4} textAnchor="middle" className="fill-muted-foreground text-[10px] font-semibold">
{formatShortDate(point.played_at)}
</text>
)}
</g>
)
})}
<polyline points={trendPoints} fill="none" stroke="var(--muted-foreground)" strokeWidth={2} strokeDasharray="5 4" strokeLinecap="round" />
</svg>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-0.5 w-4 rounded-full" style={{ backgroundColor: "var(--muted-foreground)", opacity: 0.7 }} />
Trendlinje (glidende snitt, siste 3 runder)
</span>
</div>
</Card>
)
}
// --- Til par: med vs. uten en gitt hendelse på hullet -----------------------
// Brukerens eksplisitte instruks 2026-07-28: se snitt til par NÅR noe
// inntraff mot NÅR det ikke gjorde det (greentreff, fairwaytreff, bunker,
// anywayslag), i stedet for bare en isolert prosentandel. Fargen følger
// hvilken av de to som FAKTISK var lavest denne perioden (ikke en fast
// antakelse om hvilken side som "skal" være best), siden det er selve
// sammenligningen som er poenget.
function CompareToPar({
title,
aLabel,
aValue,
bLabel,
bValue,
}: {
title: string
aLabel: string
aValue: number | null
bLabel: string
bValue: number | null
}) {
if (aValue === null && bValue === null) {
return (
<div>
<h3 className="text-sm font-bold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">Ingen data registrert ennå.</p>
</div>
)
}
const aBetter = aValue !== null && bValue !== null && aValue < bValue
const bBetter = aValue !== null && bValue !== null && bValue < aValue
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">{title}</h3>
<div className="grid grid-cols-2 gap-3">
<div className={`rounded-xl border p-3 ${aBetter ? "border-primary bg-primary/10" : "border-border bg-card"}`}>
<span className="text-xs font-semibold text-muted-foreground">{aLabel}</span>
<div className="text-xl font-extrabold tabular-nums text-foreground">{aValue !== null ? signed(aValue) : ""}</div>
</div>
<div className={`rounded-xl border p-3 ${bBetter ? "border-primary bg-primary/10" : "border-border bg-card"}`}>
<span className="text-xs font-semibold text-muted-foreground">{bLabel}</span>
<div className="text-xl font-extrabold tabular-nums text-foreground">{bValue !== null ? signed(bValue) : ""}</div>
</div>
</div>
</div>
)
}
export function RoundsStatsSummary() {
const router = useRouter()
const [window, setWindow] = useState<WindowKey>("all")
const [data, setData] = useState<ApiWindowSummary | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/rounds/stats/summary?window=${window}`, { credentials: "include" })
if (res.status === 401) {
router.replace("/logg-inn")
return
}
if (!res.ok) throw new Error(`stats: ${res.status}`)
const json: ApiWindowSummary = await res.json()
if (!cancelled) setData(json)
} catch {
if (!cancelled) setError("Klarte ikke å hente statistikken. Prøv igjen om litt.")
}
}
void load()
return () => {
cancelled = true
}
}, [router, window])
const s = data?.current ?? null
return (
<div className="flex min-h-[100dvh] flex-col 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="/dashboard"
aria-label="Til dashbord"
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-6" />
</Link>
<h1 className="text-lg font-extrabold tracking-tight text-foreground">Statistikk</h1>
</div>
</header>
<main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-6 px-4 py-6 pb-16">
<div className="flex gap-2 overflow-x-auto pb-1">
{WINDOW_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => setWindow(opt.key)}
className={`min-h-11 shrink-0 rounded-full px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
window === opt.key ? "bg-primary text-primary-foreground" : "bg-muted text-foreground hover:bg-accent"
}`}
>
{opt.label}
</button>
))}
</div>
{error && (
<p role="alert" className="text-base font-medium text-destructive">
{error}
</p>
)}
{data === null && !error ? (
<div role="status" aria-live="polite" className="flex flex-col items-center justify-center gap-4 py-20 text-center">
<div aria-hidden="true" className="size-9 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<span className="text-base font-medium text-muted-foreground">Laster statistikk</span>
</div>
) : s && s.rounds_completed === 0 ? (
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
<BarChart3 aria-hidden="true" className="size-8 text-muted-foreground" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="text-xl font-bold text-foreground">Ingen fullførte runder i perioden</h2>
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
Prøv et annet tidsvindu, eller kom tilbake etter neste fullførte runde.
</p>
</div>
</div>
) : data ? (
(() => {
const s = data.current
const p = data.previous
return (
<>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
Aggregert over {s.rounds_completed} {s.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}
{p.rounds_completed > 0 && (
<>
{" "}
· sammenlignet med {p.rounds_completed} {p.rounds_completed === 1 ? "runde" : "runder"} i perioden før
</>
)}
.
</p>
<Section title="Oversikt" icon={BarChart3}>
<StatTile label="Runder fullført" value={String(s.rounds_completed)} />
<StatTile
label="Snitt til par"
value={s.avg_score_to_par !== null ? signed(s.avg_score_to_par) : ""}
delta={<Delta current={s.avg_score_to_par} previous={p.avg_score_to_par} format={signed} goodDirection="down" />}
/>
<StatTile
label="Putt / 18 hull"
value={s.avg_putts_per_18 !== null ? s.avg_putts_per_18.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_putts_per_18} previous={p.avg_putts_per_18} format={signed} goodDirection="down" />}
hint={
s.putts_tracked_rounds > 0
? `${s.putts_tracked_rounds} ${s.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing`
: "Ingen runder med puttsporing ennå"
}
/>
</Section>
<ScoreTrendChart series={s.round_series} />
{/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter
Greentreff (en beregnet andel), så Innspill (en retnings-
beskrivelse -- egen kortype av samme grunn som round-stats.tsx
skiller "Greentreff (GIR)" fra "Bom-retning på green"). Redning
og "Til par: med vs. uten" flyttet ned under Annet, se der. */}
<Card title="Utslag" subtitle="Kun par 4/5, kun registrerte utslag.">
<FairwayBar tracked={s.fairway_tracked_holes} left={s.fairway_left_pct} hit={s.fairway_hit_pct} right={s.fairway_right_pct} />
<div className="flex items-center justify-between border-t border-border pt-3">
<span className="text-sm font-semibold text-muted-foreground">Fairwaytreff</span>
<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />
</div>
</Card>
<Card title="Greentreff (GIR)" subtitle="Andel innspill som traff green i regulert antall slag.">
<GirGauge hitPct={s.gir_pct} trackedHoles={s.gir_tracked_holes} />
<div className="flex items-center justify-between border-t border-border pt-3">
<span className="text-sm font-semibold text-muted-foreground">Greentreff</span>
<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />
</div>
</Card>
<Card title="Innspill" subtitle="Retningen du bommer i, når du bommer greenen.">
<ApproachDirectionCompass
trackedHoles={s.green_miss_tracked_holes}
long={s.green_miss_long_pct}
short={s.green_miss_short_pct}
left={s.green_miss_left_pct}
right={s.green_miss_right_pct}
/>
</Card>
<Card title="Putting">
<Donut
centerValue={s.avg_putts_per_18 !== null ? s.avg_putts_per_18.toFixed(1).replace(".", ",") : ""}
centerLabel="putt / 18 hull"
segments={
s.putt_dist_one_pct !== null
? [
{ label: "1-putt", value: s.putt_dist_one_pct ?? 0, color: "var(--primary)" },
{ label: "2-putt", value: s.putt_dist_two_pct ?? 0, color: "var(--info)" },
{ label: "3-putt+", value: s.putt_dist_three_plus_pct ?? 0, color: "var(--brand-orange)" },
]
: []
}
/>
{s.putt_dist_one_pct === null && <p className="text-sm text-muted-foreground">Ingen puttfordeling registrert ennå.</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<span className="text-sm font-semibold text-muted-foreground">Én-putt</span>
<Delta current={s.one_putt_pct} previous={p.one_putt_pct} format={signedPoints} goodDirection="up" />
</div>
</Card>
<Section title="Chip, bunker og straffeslag" icon={Waves}>
<StatTile
label="Chip / runde"
value={s.avg_chip_per_round !== null ? s.avg_chip_per_round.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_chip_per_round} previous={p.avg_chip_per_round} format={signed} goodDirection="down" />}
/>
<StatTile
label="Bunkerslag / runde"
value={s.avg_bunker_per_round !== null ? s.avg_bunker_per_round.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_bunker_per_round} previous={p.avg_bunker_per_round} format={signed} goodDirection="down" />}
/>
<StatTile
label="Straffeslag / runde"
value={s.avg_penalty_per_round !== null ? s.avg_penalty_per_round.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_penalty_per_round} previous={p.avg_penalty_per_round} format={signed} goodDirection="down" />}
/>
</Section>
{s.avg_anyway_per_round !== null && (
<Section title="Annet" icon={Wind}>
<StatTile
label="Anywayslag / runde"
value={s.avg_anyway_per_round.toFixed(1).replace(".", ",")}
delta={<Delta current={s.avg_anyway_per_round} previous={p.avg_anyway_per_round} format={signed} goodDirection="down" />}
/>
</Section>
)}
<Card title="Redning" subtitle="Par eller bedre etter en bom.">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col items-center gap-2">
<Donut
size={104}
thickness={14}
showLegend={false}
centerValue={s.scrambling_pct !== null ? `${Math.round(s.scrambling_pct)}%` : ""}
centerLabel=""
segments={s.scrambling_pct !== null ? [{ label: "Scrambling", value: s.scrambling_pct, color: "var(--primary)" }] : []}
/>
<span className="text-sm font-bold text-foreground">Scrambling</span>
<Delta current={s.scrambling_pct} previous={p.scrambling_pct} format={signedPoints} goodDirection="up" />
</div>
<div className="flex flex-col items-center gap-2">
<Donut
size={104}
thickness={14}
showLegend={false}
centerValue={s.sand_save_pct !== null ? `${Math.round(s.sand_save_pct)}%` : ""}
centerLabel=""
segments={s.sand_save_pct !== null ? [{ label: "Sand save", value: s.sand_save_pct, color: "var(--primary)" }] : []}
/>
<span className="text-sm font-bold text-foreground">Sand save</span>
<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />
</div>
</div>
</Card>
<Card title="Til par: med vs. uten" subtitle="Snitt til par på enkelthull, splittet på om noe faktisk inntraff.">
<CompareToPar
title="Greentreff (GIR)"
aLabel="Med GIR"
aValue={s.avg_to_par_with_gir}
bLabel="Uten GIR"
bValue={s.avg_to_par_without_gir}
/>
<CompareToPar
title="Fairwaytreff"
aLabel="Traff fairway"
aValue={s.avg_to_par_fairway_hit}
bLabel="Bom fairway"
bValue={s.avg_to_par_fairway_miss}
/>
<CompareToPar
title="Bunker"
aLabel="Ikke i bunker"
aValue={s.avg_to_par_without_bunker}
bLabel="I bunker"
bValue={s.avg_to_par_with_bunker}
/>
<CompareToPar
title="Anywayslag"
aLabel="Uten anywayslag"
aValue={s.avg_to_par_without_anyway}
bLabel="Med anywayslag"
bValue={s.avg_to_par_with_anyway}
/>
</Card>
</>
)
})()
) : null}
</main>
</div>
)
}