All shipped, tested, and browser-verified. Summary of this round:

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).
This commit is contained in:
Erol Haagenrud 2026-07-28 07:43:47 +02:00
parent beae43ffd7
commit 99b71ab97b
3 changed files with 424 additions and 75 deletions

View file

@ -3813,6 +3813,64 @@ Ferdig og verifisert:
`/my-rounds/stats`/`/my-rounds/course/...` alle bekreftet 200 og
konsoll-feilfrie i en ekte innlogget nettleser-sesjon, `teeoff.no`
upåvirket.
- **Statistikk-siden utvidet: miss-retning, tidsvindu + forrige-periode-
sammenligning — BYGGET, TESTET OG LIVE (2026-07-28), samme dag, rett
etter forrige punkt:** brukeren reiste tre ting samtidig — ønsket
miss-RETNING (ikke bare treff%), trender, og et presist spørsmål om
hvorvidt GIR-fra-score-og-putt-inferens faktisk var forstått/utnyttet.
**Siste punkt avklart, ikke en bug:** bekreftet at `isGir = score - putts
<= par - 2` (allerede i `round-stats.tsx`, portert uendret til det nye
aggregerte endepunktet dagen før) ER akkurat denne generelle inferensen —
fungerer for ALLE score/putt/par-kombinasjoner, ikke bare brukerens
eksempel. Eneste reelle begrensning (forklart, ikke fikset): krever
putt-tall, så `strokes_only`-runder får aldri en GIR-verdi — score alene
er nesten aldri nok til å BEVISE GIR (en scrambling-birdie fra utenfor
green gir identisk score som en ekte GIR+1-putt).
**Miss-retning bygget:** `_summarize_rounds` i `app/routers/rounds.py`
utvidet med `fairway_left_pct`/`fairway_right_pct` (samme
`fairway_tracked`-pool som treff%) og `green_miss_long/short/left/
right_pct` (egen pool — hull der `approach_result` er registrert og ≠
"hit", UAVHENGIG av om putt er kjent, samme adskilte spor som
`round-stats.tsx` sin `missedGreen`/`missDir`). Ny `MissBar`-komponent i
`rounds-stats-summary.tsx` (fordelingsbar, samme visuelle idé som
`SegmentedBar`/`DistributionBar` andre steder i appen).
**Tidsvindu + forrige-periode bygget:** ny `StatsWindow`-type (`Literal`)
og `_resolve_stats_window()` — brukeren ba eksplisitt om BÅDE en full
velger (Siste runde/5/10/Denne måneden/I år/Siste år/Alltid) OG
sammenligningspiler mot forrige periode (utover det opprinnelig
anbefalte "kun siste 5 vs. alltid, ingen piler"). Én ENSARTET regel for
"forrige periode" på tvers av alle vindutyper — samme LENGDE (antall
runder for rullerende antall-vinduer, antall DAGER for dato-vinduer)
rett før gjeldende vindus start — i stedet for å måtte spesialdefinere
"forrige måned"/"forrige år" ulikt for hver kalenderbasert type.
`GET /rounds/stats/summary?window=...` returnerer nå `{window, current,
previous}` (`RoundStatsWindowSummary`) — samme `_summarize_rounds()`-
funksjon kalt to ganger på to ulike round_id-mengder, ingen duplisert
aggregeringslogikk. Frontend fikk en ny `Delta`-komponent (pil + farge,
farget etter en eksplisitt `goodDirection`-per-tall — lavere er bedre
for til-par/putt/chip/bunker/straffeslag/anywayslag, høyere er bedre for
treff-/rednings-prosentene, INGEN farge på de rene miss-retnings-tallene
siden venstre/høyre ikke er "bedre/verre").
**Verifisert i to lag:** (a) en frittstående Python-simulering av
`_resolve_stats_window()` mot syntetiske datasett — rullerende antalls-
vindu (riktig current/previous-splitt, riktig tomt `previous` når for få
runder finnes), dato-vindu (kalender-til-dato + rullerende forrige
periode), og en isolert miss-retning-poolingstest (`None`-verdier
korrekt ekskludert fra nevneren); (b) ekte typesjekket produksjonsbuild
(måtte rette en TypeScript-nullbarhets-feil underveis — `current`/
`previous` pakket ut via en IIFE inni JSX for at TS skulle smalne begge
riktig), full import-sjekk av hele FastAPI-appen i prod-imaget, OG et
ekte browserbesøk som viste reelle tall (fairway-miss 36/43/21,
green-miss-retning 0/75/13/13) + bekreftet vindu-velgeren fungerer
(klikket "Siste 10", pillen ble grønn, tallene oppdaterte seg) + et
direkte `fetch()`-kall fra siden selv som bekreftet `{window, current,
previous}`-formen (2 runder i `current`, korrekt tomt `previous` siden
brukeren kun har 2 fullførte runder totalt).
**Rullet ut live 2026-07-28**, ingen migrasjon, `docker compose up -d
--build teecup_api teecup_frontend`. Begge containere boot-et rent,
`/health` → 200, anonymt `GET /rounds/stats/summary?window=last_5` → 401
(bekrefter query-parameteren ruter riktig gjennom Caddy/rewrites),
`teeoff.no` upåvirket.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet

View file

@ -27,7 +27,7 @@ tellende resultat lagres kun som `round_participant.score_differential`.
from __future__ import annotations
import math
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@ -675,9 +675,16 @@ class RoundStatsSummary(BaseModel):
avg_putts_per_18: float | None
putts_tracked_rounds: int
fairway_hit_pct: float | None
fairway_left_pct: float | None
fairway_right_pct: float | None
fairway_tracked_holes: int
gir_pct: float | None
gir_tracked_holes: int
green_miss_long_pct: float | None
green_miss_short_pct: float | None
green_miss_left_pct: float | None
green_miss_right_pct: float | None
green_miss_tracked_holes: int
one_putt_pct: float | None
scrambling_pct: float | None
sand_save_pct: float | None
@ -687,63 +694,78 @@ class RoundStatsSummary(BaseModel):
avg_anyway_per_round: float | None
@router.get("/rounds/stats/summary", response_model=RoundStatsSummary)
async def get_rounds_stats_summary(user: CurrentUser = Depends(get_current_user)) -> RoundStatsSummary:
"""Aggregert statistikk over ALLE brukerens fullførte runder (egen
deltaker-rad, ikke medspilleres). Ingen paginering -- samme "hent alt,
aggreger i Python"-mønster som `list_rounds`, forsvarlig i volum siden
`round_hole` alltid har nøyaktig 18 rader per deltaker uansett
holes_planned (se `_create_participant`).
class RoundStatsWindowSummary(BaseModel):
window: str
current: RoundStatsSummary
previous: RoundStatsSummary
Putt/18-hull-regelen (brukerens eksplisitte instruks 2026-07-28): for
hver fullført runde der puttsporing faktisk var (stat_level
'strokes_only') telles ALLE 18 lagrede hull -- et hull uten registrert
putt-verdi (uspilt, eller utenfor et 9-hulls spilleomfang) telles som 2
putter. Runder uten puttsporing holdes helt utenfor putt-tallet.
Alle andre andelstall (fairway/GIR/én-putt/scrambling/sand save) pooler
kun FAKTISK registrerte hull tvers av alle runder -- samme formler
som `round-stats.tsx` sin `computeStats()` bruker per enkeltrunde
(portert hit, ikke en ny definisjon), ingen padding der siden brukeren
ikke ba om det for disse tallene.
"""
async with plain_connection() as conn:
rows = await conn.fetch(
"""
SELECT rp.round_id::text AS round_id, rp.stat_level,
rh.par, rh.played, rh.score, rh.putts,
rh.tee_shot_result, rh.approach_result, rh.chip_count,
rh.bunker_shot_count, rh.penalty_strokes, rh.anyway_strokes
FROM round_participant rp
JOIN round r ON r.id = rp.round_id
JOIN round_hole rh ON rh.round_participant_id = rp.id
WHERE rp.user_id = $1 AND r.completed_at IS NOT NULL
ORDER BY r.played_at, rh.hole_number
""",
user.user_id,
)
by_round: dict[str, list] = {}
stat_level_by_round: dict[str, str] = {}
for r in rows:
by_round.setdefault(r["round_id"], []).append(r)
stat_level_by_round[r["round_id"]] = r["stat_level"]
StatsWindow = Literal["last_round", "last_5", "last_10", "month", "year", "last_year", "all"]
def _resolve_stats_window(window: StatsWindow, ordered_rounds: list[tuple[str, date]]) -> tuple[set[str], set[str]]:
"""`ordered_rounds` er (round_id, played_at) sortert NYEST FØRST.
Returnerer (gjeldende periode, forrige periode) som round_id-mengder.
Forrige periode er ALLTID perioden av SAMME LENGDE rett før gjeldende
periodes start -- rullerende for antall-baserte vinduer (samme antall
runder rett før), rullerende for dato-baserte vinduer (samme antall
DAGER rett før startdatoen). Én ensartet regel for alle vindutyper,
ingen kalender-spesialtilfeller (unngår å måtte definere "forrige
måned"/"forrige år" ulikt fra "forrige periode" for et rullerende
vindu som "siste 365 dager").
"""
if window == "all":
return {r[0] for r in ordered_rounds}, set()
if window in ("last_round", "last_5", "last_10"):
n = {"last_round": 1, "last_5": 5, "last_10": 10}[window]
current = ordered_rounds[:n]
previous = ordered_rounds[n : 2 * n]
return {r[0] for r in current}, {r[0] for r in previous}
today = date.today()
if window == "month":
start = today.replace(day=1)
elif window == "year":
start = today.replace(month=1, day=1)
elif window == "last_year":
start = today - timedelta(days=365)
else:
raise ValueError(f"Ukjent vindu: {window}")
length_days = (today - start).days + 1
prev_end = start - timedelta(days=1)
prev_start = prev_end - timedelta(days=length_days - 1)
current = {r[0] for r in ordered_rounds if r[1] >= start}
previous = {r[0] for r in ordered_rounds if prev_start <= r[1] <= prev_end}
return current, previous
def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level_by_round: dict[str, str]) -> RoundStatsSummary:
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
gjeldende og forrige periode (se `_resolve_stats_window`)."""
def avg(values: list[float]) -> float | None:
return sum(values) / len(values) if values else None
to_par_values: list[float] = []
putt_totals: list[float] = []
for round_id, holes in by_round.items():
for round_id in round_ids:
holes = by_round.get(round_id, [])
played = [h for h in holes if h["played"] and h["score"] is not None]
if played:
to_par_values.append(sum(h["score"] - h["par"] for h in played))
if stat_level_by_round[round_id] != "strokes_only":
if stat_level_by_round.get(round_id) != "strokes_only":
putt_totals.append(sum(h["putts"] if h["putts"] is not None else 2 for h in holes))
all_played = [h for holes in by_round.values() for h in holes if h["played"] and h["score"] is not None]
all_played = [h for rid in round_ids for h in by_round.get(rid, []) if h["played"] and h["score"] is not None]
fairway_tracked = [h for h in all_played if h["par"] != 3 and h["tee_shot_result"] is not None]
fairway_hit_pct = avg([100.0 if h["tee_shot_result"] == "fairway" else 0.0 for h in fairway_tracked])
fairway_left_pct = avg([100.0 if h["tee_shot_result"] == "left" else 0.0 for h in fairway_tracked])
fairway_right_pct = avg([100.0 if h["tee_shot_result"] == "right" else 0.0 for h in fairway_tracked])
def is_gir(h) -> bool:
return h["score"] - h["putts"] <= h["par"] - 2
@ -752,6 +774,15 @@ async def get_rounds_stats_summary(user: CurrentUser = Depends(get_current_user)
gir_pct = avg([100.0 if is_gir(h) else 0.0 for h in gir_eligible])
one_putt_pct = avg([100.0 if h["putts"] <= 1 else 0.0 for h in gir_eligible])
# Retning på bomskudd mot green -- eget spor (approach_result), ikke
# avledet av putte-basert GIR: en spiller kan registrere retning uten
# noensinne å registrere putter (og omvendt), samme uavhengighet som
# `round-stats.tsx` sin `missedGreen`/`missDir`.
green_missed = [h for h in all_played if h["approach_result"] is not None and h["approach_result"] != "hit"]
def miss_pct(direction: str) -> float | None:
return avg([100.0 if h["approach_result"] == direction else 0.0 for h in green_missed])
gir_missed = [h for h in gir_eligible if not is_gir(h)]
scrambling_pct = avg([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in gir_missed])
sand_eligible = [h for h in gir_missed if h["bunker_shot_count"] is not None and h["bunker_shot_count"] >= 1]
@ -759,21 +790,28 @@ async def get_rounds_stats_summary(user: CurrentUser = Depends(get_current_user)
def avg_per_round(field: str) -> float | None:
totals = []
for holes in by_round.values():
eligible = [h for h in holes if h["played"] and h["score"] is not None and h[field] is not None]
for round_id in round_ids:
eligible = [h for h in by_round.get(round_id, []) if h["played"] and h["score"] is not None and h[field] is not None]
if eligible:
totals.append(sum(h[field] for h in eligible))
return avg(totals)
return RoundStatsSummary(
rounds_completed=len(by_round),
rounds_completed=len(round_ids),
avg_score_to_par=avg(to_par_values),
avg_putts_per_18=avg(putt_totals),
putts_tracked_rounds=len(putt_totals),
fairway_hit_pct=fairway_hit_pct,
fairway_left_pct=fairway_left_pct,
fairway_right_pct=fairway_right_pct,
fairway_tracked_holes=len(fairway_tracked),
gir_pct=gir_pct,
gir_tracked_holes=len(gir_eligible),
green_miss_long_pct=miss_pct("long"),
green_miss_short_pct=miss_pct("short"),
green_miss_left_pct=miss_pct("left"),
green_miss_right_pct=miss_pct("right"),
green_miss_tracked_holes=len(green_missed),
one_putt_pct=one_putt_pct,
scrambling_pct=scrambling_pct,
sand_save_pct=sand_save_pct,
@ -784,6 +822,67 @@ async def get_rounds_stats_summary(user: CurrentUser = Depends(get_current_user)
)
@router.get("/rounds/stats/summary", response_model=RoundStatsWindowSummary)
async def get_rounds_stats_summary(
window: StatsWindow = Query("all"),
user: CurrentUser = Depends(get_current_user),
) -> RoundStatsWindowSummary:
"""Aggregert statistikk over brukerens fullførte runder (egen
deltaker-rad, ikke medspilleres), for et valgt tidsvindu -- pluss samme
tall for FORRIGE periode av samme lengde til sammenligning (se
`_resolve_stats_window`). Ingen paginering -- samme "hent alt, aggreger
i Python"-mønster som `list_rounds`, forsvarlig i volum siden
`round_hole` alltid har nøyaktig 18 rader per deltaker uansett
holes_planned (se `_create_participant`).
Putt/18-hull-regelen (brukerens eksplisitte instruks 2026-07-28): for
hver fullført runde der puttsporing faktisk var (stat_level
'strokes_only') telles ALLE 18 lagrede hull -- et hull uten registrert
putt-verdi (uspilt, eller utenfor et 9-hulls spilleomfang) telles som 2
putter. Runder uten puttsporing holdes helt utenfor putt-tallet.
Alle andre andelstall (fairway/GIR/én-putt/scrambling/sand save/miss-
retning) pooler kun FAKTISK registrerte hull -- samme formler som
`round-stats.tsx` sin `computeStats()` bruker per enkeltrunde (portert
hit, ikke en ny definisjon), ingen padding der brukeren ikke ba om det.
"""
async with plain_connection() as conn:
rows = await conn.fetch(
"""
SELECT rp.round_id::text AS round_id, rp.stat_level, r.played_at,
rh.par, rh.played, rh.score, rh.putts,
rh.tee_shot_result, rh.approach_result, rh.chip_count,
rh.bunker_shot_count, rh.penalty_strokes, rh.anyway_strokes
FROM round_participant rp
JOIN round r ON r.id = rp.round_id
JOIN round_hole rh ON rh.round_participant_id = rp.id
WHERE rp.user_id = $1 AND r.completed_at IS NOT NULL
ORDER BY r.played_at DESC, rh.hole_number
""",
user.user_id,
)
by_round: dict[str, list] = {}
stat_level_by_round: dict[str, str] = {}
played_at_by_round: dict[str, date] = {}
for r in rows:
by_round.setdefault(r["round_id"], []).append(r)
stat_level_by_round[r["round_id"]] = r["stat_level"]
played_at_by_round[r["round_id"]] = r["played_at"]
# Distinkte runder, nyest først -- rekkefølgen `rows` allerede har
# (ORDER BY r.played_at DESC) bevares siden Python-dicts er innsettings-
# ordnet, ingen ny sortering nødvendig.
ordered_rounds = [(rid, played_at_by_round[rid]) for rid in by_round]
current_ids, previous_ids = _resolve_stats_window(window, ordered_rounds)
return RoundStatsWindowSummary(
window=window,
current=_summarize_rounds(current_ids, by_round, stat_level_by_round),
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round),
)
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se

View file

@ -1,17 +1,18 @@
"use client"
// Aggregert statistikk på tvers av ALLE fullførte "Egne runder" (ADR-033),
// lenket fra dashbordets "Statistikk"-seksjon (2026-07-28). Egen backend-
// 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) må kjøre over ALLE
// fullførte runder samlet, ikke én runde om gangen.
// 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, Waves, Wind } from "lucide-react"
import { ArrowLeft, BarChart3, Flag, Target, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react"
type ApiStatsSummary = {
rounds_completed: number
@ -19,9 +20,16 @@ type ApiStatsSummary = {
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
@ -31,6 +39,24 @@ type ApiStatsSummary = {
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(".", ",")}`
@ -40,11 +66,58 @@ function pct(n: number): string {
return `${Math.round(n)} %`
}
function StatTile({ label, value, hint }: { label: string; value: string; hint?: string }) {
// 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>
)
@ -70,22 +143,54 @@ function 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 [data, setData] = useState<ApiStatsSummary | null>(null)
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", { credentials: "include" })
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: ApiStatsSummary = await res.json()
const json: ApiWindowSummary = await res.json()
if (!cancelled) setData(json)
} catch {
if (!cancelled) setError("Klarte ikke å hente statistikken. Prøv igjen om litt.")
@ -95,7 +200,9 @@ export function RoundsStatsSummary() {
return () => {
cancelled = true
}
}, [router])
}, [router, window])
const s = data?.current ?? null
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
@ -113,6 +220,21 @@ export function RoundsStatsSummary() {
</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}
@ -124,33 +246,49 @@ export function RoundsStatsSummary() {
<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 ? (
) : 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 en</h2>
<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">
Statistikken bygger seg opp automatisk etter hvert som du fullfører runder.
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 {data.rounds_completed} {data.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}.
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(data.rounds_completed)} />
<StatTile label="Snitt til par" value={data.avg_score_to_par !== null ? signed(data.avg_score_to_par) : ""} />
<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={data.avg_putts_per_18 !== null ? data.avg_putts_per_18.toFixed(1).replace(".", ",") : ""}
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={
data.putts_tracked_rounds > 0
? `${data.putts_tracked_rounds} ${data.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing`
s.putts_tracked_rounds > 0
? `${s.putts_tracked_rounds} ${s.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing`
: "Ingen runder med puttsporing ennå"
}
/>
@ -159,42 +297,96 @@ export function RoundsStatsSummary() {
<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}
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={data.gir_pct !== null ? pct(data.gir_pct) : ""}
hint={data.gir_tracked_holes > 0 ? `${data.gir_tracked_holes} hull registrert` : undefined}
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" />}
/>
<StatTile label="Én-putt" value={data.one_putt_pct !== null ? pct(data.one_putt_pct) : ""} />
</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 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={data.scrambling_pct !== null ? pct(data.scrambling_pct) : ""}
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={data.sand_save_pct !== null ? pct(data.sand_save_pct) : ""}
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={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(".", ",") : ""} />
<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>
{data.avg_anyway_per_round !== null && (
{s.avg_anyway_per_round !== null && (
<Section title="Annet" icon={Wind}>
<StatTile label="Anywayslag / runde" value={data.avg_anyway_per_round.toFixed(1).replace(".", ",")} />
<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>