203 lines
8.4 KiB
TypeScript
203 lines
8.4 KiB
TypeScript
|
|
"use client"
|
|||
|
|
|
|||
|
|
// Aggregert statistikk på tvers av ALLE fullførte "Egne runder" (ADR-033),
|
|||
|
|
// lenket fra dashbordets "Statistikk"-seksjon (2026-07-28). 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) må kjøre over ALLE
|
|||
|
|
// fullførte runder samlet, ikke én runde 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, 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_tracked_holes: number
|
|||
|
|
gir_pct: number | null
|
|||
|
|
gir_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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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)} %`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function StatTile({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|||
|
|
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>
|
|||
|
|
{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>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function RoundsStatsSummary() {
|
|||
|
|
const router = useRouter()
|
|||
|
|
const [data, setData] = useState<ApiStatsSummary | null>(null)
|
|||
|
|
const [error, setError] = useState<string | null>(null)
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false
|
|||
|
|
async function load() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/rounds/stats/summary", { credentials: "include" })
|
|||
|
|
if (res.status === 401) {
|
|||
|
|
router.replace("/")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (!res.ok) throw new Error(`stats: ${res.status}`)
|
|||
|
|
const json: ApiStatsSummary = 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])
|
|||
|
|
|
|||
|
|
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">
|
|||
|
|
{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>
|
|||
|
|
) : data && data.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 ennå</h2>
|
|||
|
|
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
|
|||
|
|
Statistikken bygger seg opp automatisk etter hvert som du fullfører runder.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : data ? (
|
|||
|
|
<>
|
|||
|
|
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
|||
|
|
Aggregert over {data.rounds_completed} {data.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<Section title="Oversikt" icon={BarChart3}>
|
|||
|
|
<StatTile label="Runder fullført" value={String(data.rounds_completed)} />
|
|||
|
|
<StatTile label="Snitt til par" value={data.avg_score_to_par !== null ? signed(data.avg_score_to_par) : "–"} />
|
|||
|
|
<StatTile
|
|||
|
|
label="Putt / 18 hull"
|
|||
|
|
value={data.avg_putts_per_18 !== null ? data.avg_putts_per_18.toFixed(1).replace(".", ",") : "–"}
|
|||
|
|
hint={
|
|||
|
|
data.putts_tracked_rounds > 0
|
|||
|
|
? `${data.putts_tracked_rounds} ${data.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={data.fairway_hit_pct !== null ? pct(data.fairway_hit_pct) : "–"}
|
|||
|
|
hint={data.fairway_tracked_holes > 0 ? `${data.fairway_tracked_holes} hull registrert` : undefined}
|
|||
|
|
/>
|
|||
|
|
<StatTile
|
|||
|
|
label="Greentreff (GIR)"
|
|||
|
|
value={data.gir_pct !== null ? pct(data.gir_pct) : "–"}
|
|||
|
|
hint={data.gir_tracked_holes > 0 ? `${data.gir_tracked_holes} hull registrert` : undefined}
|
|||
|
|
/>
|
|||
|
|
<StatTile label="Én-putt" value={data.one_putt_pct !== null ? pct(data.one_putt_pct) : "–"} />
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
<Section title="Redning" icon={Target}>
|
|||
|
|
<StatTile
|
|||
|
|
label="Scrambling"
|
|||
|
|
value={data.scrambling_pct !== null ? pct(data.scrambling_pct) : "–"}
|
|||
|
|
hint="Par eller bedre uten GIR"
|
|||
|
|
/>
|
|||
|
|
<StatTile
|
|||
|
|
label="Sand save"
|
|||
|
|
value={data.sand_save_pct !== null ? pct(data.sand_save_pct) : "–"}
|
|||
|
|
hint="Par eller bedre fra bunker"
|
|||
|
|
/>
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
<Section title="Chip, bunker og straffeslag" icon={Waves}>
|
|||
|
|
<StatTile label="Chip / runde" value={data.avg_chip_per_round !== null ? data.avg_chip_per_round.toFixed(1).replace(".", ",") : "–"} />
|
|||
|
|
<StatTile label="Bunkerslag / runde" value={data.avg_bunker_per_round !== null ? data.avg_bunker_per_round.toFixed(1).replace(".", ",") : "–"} />
|
|||
|
|
<StatTile label="Straffeslag / runde" value={data.avg_penalty_per_round !== null ? data.avg_penalty_per_round.toFixed(1).replace(".", ",") : "–"} />
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
{data.avg_anyway_per_round !== null && (
|
|||
|
|
<Section title="Annet" icon={Wind}>
|
|||
|
|
<StatTile label="Anywayslag / runde" value={data.avg_anyway_per_round.toFixed(1).replace(".", ",")} />
|
|||
|
|
</Section>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
) : null}
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|