teecup/frontend/components/rounds-stats-summary.tsx
Erol Haagenrud beae43ffd7 All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00

202 lines
8.4 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 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>
)
}