GIR question answered, not a gap: score − putts ≤ par − 2 is already the general formula, already ported to the new endpoint — it's not case-by-case, it works for every combination. The one real limit: it needs putts, so strokes_only rounds can't get a reliable GIR (score alone almost never proves it — a chipped-in birdie and a real GIR+1-putt look identical). Miss direction — added fairway left/right and green long/short/left/right as pooled distribution bars on the aggregate stats page, using the same fields the per-round page already tracks. Time-window picker — Siste runde / 5 / 10 / Denne måneden / I år / Siste år / Alltid, filters the whole page. "vs. forrige periode" deltas — added, per your ask. One rule for every window type: "previous" = the same length (rounds or days) immediately before the current window's start, so there's no special-casing per calendar type. Colored by direction (green = improvement, orange = worse) except on the miss-direction numbers, which have no "better" side. Verified with standalone logic tests against hand-computed scenarios, a full app import check, a clean typecheck, and live in the browser (clicked through windows, confirmed real numbers and a correct empty-previous case since your account only has 2 completed rounds so far).
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
"use client"
|
||
|
||
// Aggregert statistikk på tvers av "Egne runder" (ADR-033), lenket fra
|
||
// dashbordets "Statistikk"-seksjon (2026-07-28, utvidet samme dag med
|
||
// tidsvindu + miss-retning + forrige-periode-sammenligning). 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.
|
||
|
||
import type React from "react"
|
||
import { useEffect, useState } from "react"
|
||
import { useRouter } from "next/navigation"
|
||
import Link from "next/link"
|
||
import { ArrowLeft, BarChart3, Flag, 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
|
||
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
|
||
}
|
||
|
||
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 <span className="text-xs font-semibold text-muted-foreground">Uendret</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 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="sr-only">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-sm shadow-black/5">
|
||
<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 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>
|
||
)
|
||
}
|
||
|
||
// Horisontal fordelingsbar med retningsandeler (venstre/høyre eller
|
||
// lang/kort/venstre/høyre) -- pooler kun faktisk registrerte bom.
|
||
function MissBar({
|
||
tracked,
|
||
segments,
|
||
}: {
|
||
tracked: number
|
||
segments: { label: string; value: number | null; color: string }[]
|
||
}) {
|
||
if (tracked === 0) return <p className="text-sm text-muted-foreground">Ingen retning registrert ennå.</p>
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex h-3 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>
|
||
)
|
||
}
|
||
|
||
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("/")
|
||
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-8 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-primary/15">
|
||
<BarChart3 aria-hidden="true" className="size-8 text-primary" />
|
||
</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>
|
||
|
||
<Section title="Fairway og green" icon={Flag}>
|
||
<StatTile
|
||
label="Fairwaytreff"
|
||
value={s.fairway_hit_pct !== null ? pct(s.fairway_hit_pct) : "–"}
|
||
delta={<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />}
|
||
hint={s.fairway_tracked_holes > 0 ? `${s.fairway_tracked_holes} hull registrert` : undefined}
|
||
/>
|
||
<StatTile
|
||
label="Greentreff (GIR)"
|
||
value={s.gir_pct !== null ? pct(s.gir_pct) : "–"}
|
||
delta={<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />}
|
||
hint={s.gir_tracked_holes > 0 ? `${s.gir_tracked_holes} hull registrert` : undefined}
|
||
/>
|
||
<StatTile
|
||
label="Én-putt"
|
||
value={s.one_putt_pct !== null ? pct(s.one_putt_pct) : "–"}
|
||
delta={<Delta current={s.one_putt_pct} previous={p.one_putt_pct} format={signedPoints} goodDirection="up" />}
|
||
/>
|
||
</Section>
|
||
|
||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||
<div>
|
||
<h3 className="text-sm font-bold text-foreground">Hvor bommer du fra utslaget?</h3>
|
||
<p className="text-xs text-muted-foreground">Kun par 4/5, kun registrerte utslag.</p>
|
||
</div>
|
||
<MissBar
|
||
tracked={s.fairway_tracked_holes}
|
||
segments={[
|
||
{ label: "Venstre", value: s.fairway_left_pct, color: "var(--chart-5)" },
|
||
{ label: "Fairway", value: s.fairway_hit_pct, color: "var(--primary)" },
|
||
{ label: "Høyre", value: s.fairway_right_pct, color: "var(--chart-4)" },
|
||
]}
|
||
/>
|
||
<div>
|
||
<h3 className="text-sm font-bold text-foreground">Hvor bommer du på innspillet?</h3>
|
||
<p className="text-xs text-muted-foreground">Kun hull der greentreff ble bommet og retning registrert.</p>
|
||
</div>
|
||
<MissBar
|
||
tracked={s.green_miss_tracked_holes}
|
||
segments={[
|
||
{ label: "Langt", value: s.green_miss_long_pct, color: "var(--chart-5)" },
|
||
{ label: "Kort", value: s.green_miss_short_pct, color: "var(--chart-4)" },
|
||
{ label: "Venstre", value: s.green_miss_left_pct, color: "var(--chart-6)" },
|
||
{ label: "Høyre", value: s.green_miss_right_pct, color: "var(--info)" },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<Section title="Redning" icon={Target}>
|
||
<StatTile
|
||
label="Scrambling"
|
||
value={s.scrambling_pct !== null ? pct(s.scrambling_pct) : "–"}
|
||
delta={<Delta current={s.scrambling_pct} previous={p.scrambling_pct} format={signedPoints} goodDirection="up" />}
|
||
hint="Par eller bedre uten GIR"
|
||
/>
|
||
<StatTile
|
||
label="Sand save"
|
||
value={s.sand_save_pct !== null ? pct(s.sand_save_pct) : "–"}
|
||
delta={<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />}
|
||
hint="Par eller bedre fra bunker"
|
||
/>
|
||
</Section>
|
||
|
||
<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>
|
||
)}
|
||
</>
|
||
)
|
||
})()
|
||
) : null}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|