teecup/frontend/components/rounds-stats-summary.tsx
Erol Haagenrud a65858af84 Statistikk: lineær regresjon i stedet for glidende snitt, trend på alle variabler
Brukeren påpekte (med rette) at det glidende 3-runde-snittet i "Utvikling"
bare gjentok stolpediagrammet med én runde forsinkelse -- for få punkter
til at det smoothet noe reelt. Foreslo tre alternativer via
AskUserQuestion (lineær regresjon / eksponentielt glidende snitt /
kumulativt løpende snitt); brukeren valgte lineær regresjon.

Lineær regresjon (minste kvadraters metode) gir en rett linje --
stigningstallet leses direkte som "endring per runde" og vises nå i
bildeteksten (f.eks. "+7 pp per runde"). Visuelt en helt annen form enn
stolpene (rett strek vs. hakkete stolper), som løser selve klagen.

Bruker ba samtidig om trend på ALLE variabler, ikke bare snitt til par.
Lagt til stolpe+trend for: Fairwaytreff, Greentreff (GIR), Putt/18,
Én-putt, Chip/runde, Bunkerslag/runde, Straffeslag/runde, Anywayslag/
runde -- hver plassert i det eksisterende kortet for akkurat den
metrikken. Scrambling/sand save er unntaket (brukervalg via
AskUserQuestion): for få kvalifiserte hull per runde (0-2) til at et
per-runde-tall er meningsfullt, vises i stedet som én kumulativ linje
("raten din til og med hver runde"), ingen stolper.

Backend: RoundStatsSummary sitt gamle round_series-felt (kun snitt til
par) erstattet av et nytt round_series på RoundStatsWindowSummary-nivå,
med per-runde-tall for alle variabler pluss kumulativ scrambling/sand
save. Fanget og rettet en enhets-bug selv under scratch-verifisering:
scrambling/sand save ble først bygget som 0-1-brøker (kopiert fra en
boolsk 1.0/0.0-sjekk) i stedet for samme 0-100-skala som resten av
API-et -- rettet før commit.

Verifisert: tsc --noEmit rent, 45/45 vitest, 31/31 backend-tester. Full
scratch-stack med åtte syntetiske runder (bevisst støyende fremgang, for
å faktisk se at regresjonslinjen skiller seg fra stolpene) browser-
verifisert lys+mørk: regresjonslinje bekreftet rett og visuelt distinkt
fra stolpene på alle variabler, riktig fortegn/farge på stigningstall,
ingen dobbel enhet i bildetekstene, kumulative linjer for scrambling/
sand save konvergerer som forventet. Ingen konsollfeil. teecup_db urørt.

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

1069 lines
47 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
putts_per_18: number | null
fairway_hit_pct: number | null
gir_pct: number | null
one_putt_pct: number | null
chip_per_round: number | null
bunker_per_round: number | null
penalty_per_round: number | null
anyway_per_round: number | null
scrambling_cumulative_pct: number | null
sand_save_cumulative_pct: number | null
}
type ApiStatsSummary = {
rounds_completed: number
avg_score_to_par: number | null
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
round_series: ApiRoundSeriesPoint[]
}
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)} %`
}
// Enhetsløs versjon av `pct` -- til bruk som `formatSlope` i BarTrendChart
// der `slopeSuffix` allerede bærer enheten ("pp per runde"); `pct` sin
// egen "%" ville da kollidert med det.
function roundedNoUnit(n: number): string {
return String(Math.round(n))
}
function oneDecimal(n: number): string {
return n.toFixed(1).replace(".", ",")
}
// 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, per variabel ---------
// Brukerinstruks 2026-08-12: stolpe per runde (eldste til venstre) +
// trendlinje, for ALLE variabler -- ikke bare snitt til par. Tidligere
// bevisst UTSATT (se CHANGELOG 2026-07-28: "for lite rundehistorikk til
// å vise noe meningsfullt"), bygget da brukeren fikk nok runder.
//
// Trendlinjen er LINEÆR REGRESJON (minste kvadraters metode), ikke et
// glidende snitt -- brukeren påpekte (med rette) at et glidende snitt
// over så få runder bare gjentar stolpene med én runde forsinkelse. En
// rett regresjonslinje er visuelt og matematisk en helt annen form (en
// retning, ikke en kopi av dataene), og stigningstallet kan leses rett
// ut som "endring per runde" (se AskUserQuestion-svar 2026-08-12).
//
// Fargevalg: KUN snitt til par har en meningsfull 0-linje (par) å være
// diverging rundt (primary = på/under par, brand-orange = over par,
// samme par som `Delta` allerede bruker). De andre variablene er ren
// magnitude (prosent eller antall, alltid >= 0) og får én gjennomgående
// farge -- ingen kunstig "godt/dårlig"-deling der en slik deling ikke
// finnes i selve tallet.
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
const TREND_MAGNITUDE_HEIGHT = TREND_CHART_HEIGHT - 34 // baseline nederst, samme topp-marg
// Lineær regresjon (minste kvadraters metode) -- returnerer den rette
// linjens y-verdi ved hver indeks. Rendret som polyline blir dette
// bokstavelig talt en rett strek, ikke en linje som følger dataene.
function linearRegression(values: number[]): number[] {
const n = values.length
if (n === 0) return []
const xMean = (n - 1) / 2
const yMean = values.reduce((a, b) => a + b, 0) / n
let num = 0
let den = 0
values.forEach((y, x) => {
num += (x - xMean) * (y - yMean)
den += (x - xMean) ** 2
})
const slope = den === 0 ? 0 : num / den
const intercept = yMean - slope * xMean
return values.map((_, x) => intercept + slope * x)
}
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`
}
type TrendPoint = { played_at: string; value: number }
function BarTrendChart({
points,
mode,
formatValue,
formatSlope = formatValue,
goodDirection,
slopeSuffix,
}: {
points: TrendPoint[]
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
// (kun snitt til par). "magnitude": baseline nederst, alltid >= 0, én farge.
mode: "diverging" | "magnitude"
// Brukt på hver stolpes verdi-etikett/tooltip -- kan trygt ha egen enhet
// (f.eks. "%") siden den står alene der.
formatValue: (v: number) => string
// Brukt KUN på stigningstallet i bildeteksten -- egen formatterer der
// formatValue sin enhet ville kollidert med slopeSuffix (f.eks. "52 %"
// + "pp per runde" ville blitt "52 % pp per runde"). Faller tilbake til
// formatValue når det ikke kolliderer (f.eks. `signed`, som ikke har
// noen enhet innebygd).
formatSlope?: (v: number) => string
goodDirection: "up" | "down"
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
slopeSuffix: string
}) {
if (points.length < 2) return null
const values = points.map((p) => p.value)
const trend = linearRegression(values)
const slopePerRound = points.length > 1 ? trend[trend.length - 1] - trend[0] : 0
const avgSlopePerRound = slopePerRound / (points.length - 1)
const improving = goodDirection === "up" ? avgSlopePerRound > 0 : avgSlopePerRound < 0
const baselineY = TREND_CHART_HEIGHT - 20
let valueToY: (v: number) => number
if (mode === "diverging") {
const maxAbs = Math.max(...values.map((v) => Math.abs(v)), 1)
const scale = TREND_HALF_HEIGHT / maxAbs
valueToY = (v) => baselineY - v * scale
} else {
const maxVal = Math.max(...values, 1)
const scale = TREND_MAGNITUDE_HEIGHT / maxVal
valueToY = (v) => baselineY - v * scale
}
const width = points.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 trendLinePoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
return (
<div className="flex flex-col gap-2">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={`${points.length} runder, fra ${formatShortDate(points[0].played_at)} til ${formatShortDate(points[points.length - 1].played_at)}. Trend: ${avgSlopePerRound >= 0 ? "+" : ""}${formatSlope(Math.abs(avgSlopePerRound))} ${slopeSuffix}.`}
viewBox={`0 0 ${width} ${TREND_CHART_HEIGHT}`}
width={width}
height={TREND_CHART_HEIGHT}
className="block"
>
<line x1={0} y1={baselineY} x2={width} y2={baselineY} stroke="var(--border)" strokeWidth={1} />
{points.map((point, i) => {
const cx = barCenterX(i)
const overPar = mode === "diverging" && point.value > 0
const isLast = i === points.length - 1
return (
<g key={point.played_at + i}>
<path
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.value), baselineY, TREND_BAR_WIDTH, 4)}
fill={mode === "diverging" ? (overPar ? "var(--brand-orange)" : "var(--primary)") : "var(--primary)"}
>
<title>
{formatShortDate(point.played_at)}: {formatValue(point.value)}
</title>
</path>
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
"merk endepunktet, ikke hvert punkt". */}
{isLast && (
<text
x={cx}
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
textAnchor="middle"
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{formatValue(point.value)}
</text>
)}
{(i === 0 || i === points.length - 1 || i === Math.floor(points.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={trendLinePoints} fill="none" stroke="var(--muted-foreground)" strokeWidth={2} strokeDasharray="5 4" strokeLinecap="round" />
</svg>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-semibold">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<span aria-hidden="true" className="h-0.5 w-4 rounded-full" style={{ backgroundColor: "var(--muted-foreground)", opacity: 0.7 }} />
Trend (lineær regresjon)
</span>
<span className={improving ? "text-primary" : "text-brand-orange"}>
{avgSlopePerRound >= 0 ? "+" : ""}
{formatSlope(Math.abs(avgSlopePerRound))} {slopeSuffix}
</span>
</div>
</div>
)
}
function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
const points = series.map((p) => ({ played_at: p.played_at, value: p.score_to_par }))
if (points.length < 2) return null
return (
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" />
</Card>
)
}
// Generisk per-variabel-trend -- filtrerer bort runder uten data for
// akkurat DENNE metrikken (f.eks. en runde uten puttsporing), i stedet
// for å late som verdien var 0.
function seriesFor(series: ApiRoundSeriesPoint[], field: keyof ApiRoundSeriesPoint): TrendPoint[] {
return series
.filter((p) => p[field] !== null)
.map((p) => ({ played_at: p.played_at, value: p[field] as number }))
}
// Kumulativ (ikke per-runde) linje for Scrambling/Sand save -- se
// AskUserQuestion-svar 2026-08-12: for få kvalifiserte hull per runde
// til at et per-runde-tall er meningsfullt, så her vises kun raten
// "til og med denne runden" som en enkel linje, ingen stolper.
function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: string }) {
if (points.length < 2) return null
const values = points.map((p) => p.value)
const maxVal = Math.max(...values, 1)
const baselineY = TREND_CHART_HEIGHT - 20
const scale = TREND_MAGNITUDE_HEIGHT / maxVal
const valueToY = (v: number) => baselineY - v * scale
const width = Math.max(points.length * 26, 160)
const stepX = (i: number) => (points.length > 1 ? (i / (points.length - 1)) * (width - 24) + 12 : width / 2)
const linePoints = points.map((p, i) => `${stepX(i)},${valueToY(p.value)}`).join(" ")
return (
<div className="flex flex-col gap-2">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={`${label}, kumulativt: ${Math.round(points[0].value)} % ved starten, ${Math.round(points[points.length - 1].value)} % nå.`}
viewBox={`0 0 ${width} ${TREND_CHART_HEIGHT}`}
width={width}
height={TREND_CHART_HEIGHT}
className="block"
>
<line x1={0} y1={baselineY} x2={width} y2={baselineY} stroke="var(--border)" strokeWidth={1} />
<polyline points={linePoints} fill="none" stroke="var(--primary)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
{points.map((p, i) => (
<circle key={p.played_at + i} cx={stepX(i)} cy={valueToY(p.value)} r={i === points.length - 1 ? 4 : 3} fill="var(--primary)">
<title>
{formatShortDate(p.played_at)}: {Math.round(p.value)} %
</title>
</circle>
))}
<text x={stepX(0)} y={valueToY(points[0].value) - 10} textAnchor="start" className="fill-muted-foreground text-[10px] font-semibold">
{Math.round(points[0].value)} %
</text>
<text
x={stepX(points.length - 1)}
y={valueToY(points[points.length - 1].value) - 10}
textAnchor="end"
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{Math.round(points[points.length - 1].value)} %
</text>
</svg>
</div>
<p className="text-xs font-semibold text-muted-foreground">Kumulativt -- raten din til og med hver runde, ikke per-runde-tall.</p>
</div>
)
}
// --- 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
const series = data.round_series
const fairwayTrend = seriesFor(series, "fairway_hit_pct")
const girTrend = seriesFor(series, "gir_pct")
const puttsTrend = seriesFor(series, "putts_per_18")
const onePuttTrend = seriesFor(series, "one_putt_pct")
const chipTrend = seriesFor(series, "chip_per_round")
const bunkerTrend = seriesFor(series, "bunker_per_round")
const penaltyTrend = seriesFor(series, "penalty_per_round")
const anywayTrend = seriesFor(series, "anyway_per_round")
const scramblingCumulative = seriesFor(series, "scrambling_cumulative_pct")
const sandSaveCumulative = seriesFor(series, "sand_save_cumulative_pct")
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={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>
{fairwayTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Fairwaytreff over tid</p>
<BarTrendChart
points={fairwayTrend}
mode="magnitude"
formatValue={pct}
formatSlope={roundedNoUnit}
goodDirection="up"
slopeSuffix="pp per runde"
/>
</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>
{girTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Greentreff over tid</p>
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
</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>
{puttsTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Putt / 18 hull over tid</p>
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" />
</div>
)}
{onePuttTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Én-putt over tid</p>
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
</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>
{(chipTrend.length >= 2 || bunkerTrend.length >= 2 || penaltyTrend.length >= 2) && (
<Card title="Chip, bunker og straffeslag over tid">
{chipTrend.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Chip / runde</p>
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
{bunkerTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Bunkerslag / runde</p>
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
{penaltyTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Straffeslag / runde</p>
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
</Card>
)}
{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>
)}
{anywayTrend.length >= 2 && (
<Card title="Anywayslag over tid">
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</Card>
)}
<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>
{(scramblingCumulative.length >= 2 || sandSaveCumulative.length >= 2) && (
<div className="grid grid-cols-1 gap-4 border-t border-border pt-3 sm:grid-cols-2">
{scramblingCumulative.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" />
</div>
)}
{sandSaveCumulative.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" />
</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>
)
}