"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, Target, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react"
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
}
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"
}) {
if (current === null || previous === null) return null
const diff = current - previous
if (diff === 0) return Uendret
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 (
{title}
{subtitle &&
{subtitle}
}
{children}
)
}
function Section({
title,
icon: Icon,
children,
}: {
title: string
icon: typeof BarChart3
children: React.ReactNode
}) {
return (
)
}
// --- 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 = (
`${s.label} ${Math.round(s.value)} prosent`).join(", ")}`}
>
{centerValue}
{centerLabel}
)
if (!showLegend) return ring
return (
{ring}
{segments.map((s) => (
{s.label}
{Math.round(s.value)} %
))}
)
}
// --- 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 (
{value !== null ? `${Math.round(value)} %` : "–"}
{label}
)
}
function GirCompass({
hitPct,
trackedHoles,
missTrackedHoles,
long,
short,
left,
right,
}: {
hitPct: number | null
trackedHoles: number
missTrackedHoles: number
long: number | null
short: number | null
left: number | null
right: number | null
}) {
if (hitPct === null) {
return Ingen greentreff-data registrert ennå.
}
return (
{Math.round(hitPct)} %
Greentreff
{trackedHoles} hull registrert
{missTrackedHoles > 0 && `, ${missTrackedHoles} bom 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 Ingen utslag registrert ennå.
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 (
{segments.map((s) =>
s.value && s.value > 0 ? (
) : null,
)}
{segments.map((s) => (
{s.label}: {s.value !== null ? pct(s.value) : "–"}
))}
)
}
// --- 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 (
{title}
Ingen data registrert ennå.
)
}
const aBetter = aValue !== null && bValue !== null && aValue < bValue
const bBetter = aValue !== null && bValue !== null && bValue < aValue
return (
{title}
{aLabel}
{aValue !== null ? signed(aValue) : "–"}
{bLabel}
{bValue !== null ? signed(bValue) : "–"}
)
}
export function RoundsStatsSummary() {
const router = useRouter()
const [window, setWindow] = useState("all")
const [data, setData] = useState(null)
const [error, setError] = useState(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 (
{WINDOW_OPTIONS.map((opt) => (
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}
))}
{error && (
{error}
)}
{data === null && !error ? (
) : s && s.rounds_completed === 0 ? (
Ingen fullførte runder i perioden
Prøv et annet tidsvindu, eller kom tilbake etter neste fullførte runde.
) : data ? (
(() => {
const s = data.current
const p = data.previous
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å"
}
/>
vs. forrige periode
Fairwaytreff vs. forrige periode
{s.putt_dist_one_pct === null && Ingen puttfordeling registrert ennå.
}
Én-putt vs. forrige periode
{s.avg_anyway_per_round !== null && (
)}
>
)
})()
) : null}
)
}