"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 Uendret vs. forrige periode
}
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 (
{format(diff)}vs. forrige periode
)
}
function StatTile({
label,
value,
hint,
delta,
}: {
label: string
value: string
hint?: string
delta?: React.ReactNode
}) {
return (
{label}{value}
{delta}
{hint && {hint}}
)
}
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
)
}
// --- 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 (
)
}
// 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
Ingen greentreff-data registrert ennå.
}
return (
{trackedHoles} hull registrert
)
}
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
Ingen innspill med retning registrert ennå.
}
return (
{trackedHoles} innspill registrert med retning.
)
}
// --- 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
)
}
// --- 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,
yAxisLabel,
}: {
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
// Enhet for y-aksen (f.eks. "%", "Slag til par", "Antall") -- ekte HTML-
// tekst over grafen, ikke roterte SVG-glyffer, så den skalerer med
// brukerens skriftstørrelse (tilgjengelighetsregelen i CLAUDE.md).
yAxisLabel: 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 (
)
}
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 (
)
}
// 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,
yAxisLabel,
}: {
points: TrendPoint[]
label: string
yAxisLabel: 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 (
{yAxisLabel}
Kumulativt -- raten din til og med hver runde, ikke per-runde-tall.
)
}
// --- 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 (
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
>
)}
.
}
/>
}
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å"
}
/>
{/* 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. */}