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 `/my-rounds/stats`/`/my-rounds/course/...` alle bekreftet 200 og
konsoll-feilfrie i en ekte innlogget nettleser-sesjon, `teeoff.no` konsoll-feilfrie i en ekte innlogget nettleser-sesjon, `teeoff.no`
upåvirket. 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: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 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 from __future__ import annotations
import math import math
from datetime import date, datetime from datetime import date, datetime, timedelta
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@ -675,9 +675,16 @@ class RoundStatsSummary(BaseModel):
avg_putts_per_18: float | None avg_putts_per_18: float | None
putts_tracked_rounds: int putts_tracked_rounds: int
fairway_hit_pct: float | None fairway_hit_pct: float | None
fairway_left_pct: float | None
fairway_right_pct: float | None
fairway_tracked_holes: int fairway_tracked_holes: int
gir_pct: float | None gir_pct: float | None
gir_tracked_holes: int 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 one_putt_pct: float | None
scrambling_pct: float | None scrambling_pct: float | None
sand_save_pct: float | None sand_save_pct: float | None
@ -687,63 +694,78 @@ class RoundStatsSummary(BaseModel):
avg_anyway_per_round: float | None avg_anyway_per_round: float | None
@router.get("/rounds/stats/summary", response_model=RoundStatsSummary) class RoundStatsWindowSummary(BaseModel):
async def get_rounds_stats_summary(user: CurrentUser = Depends(get_current_user)) -> RoundStatsSummary: window: str
"""Aggregert statistikk over ALLE brukerens fullførte runder (egen current: RoundStatsSummary
deltaker-rad, ikke medspilleres). Ingen paginering -- samme "hent alt, previous: RoundStatsSummary
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 StatsWindow = Literal["last_round", "last_5", "last_10", "month", "year", "last_year", "all"]
'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. def _resolve_stats_window(window: StatsWindow, ordered_rounds: list[tuple[str, date]]) -> tuple[set[str], set[str]]:
Alle andre andelstall (fairway/GIR/én-putt/scrambling/sand save) pooler """`ordered_rounds` er (round_id, played_at) sortert NYEST FØRST.
kun FAKTISK registrerte hull tvers av alle runder -- samme formler Returnerer (gjeldende periode, forrige periode) som round_id-mengder.
som `round-stats.tsx` sin `computeStats()` bruker per enkeltrunde
(portert hit, ikke en ny definisjon), ingen padding der siden brukeren Forrige periode er ALLTID perioden av SAMME LENGDE rett før gjeldende
ikke ba om det for disse tallene. 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").
""" """
async with plain_connection() as conn: if window == "all":
rows = await conn.fetch( return {r[0] for r in ordered_rounds}, set()
"""
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] = {} if window in ("last_round", "last_5", "last_10"):
stat_level_by_round: dict[str, str] = {} n = {"last_round": 1, "last_5": 5, "last_10": 10}[window]
for r in rows: current = ordered_rounds[:n]
by_round.setdefault(r["round_id"], []).append(r) previous = ordered_rounds[n : 2 * n]
stat_level_by_round[r["round_id"]] = r["stat_level"] 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: def avg(values: list[float]) -> float | None:
return sum(values) / len(values) if values else None return sum(values) / len(values) if values else None
to_par_values: list[float] = [] to_par_values: list[float] = []
putt_totals: 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] played = [h for h in holes if h["played"] and h["score"] is not None]
if played: if played:
to_par_values.append(sum(h["score"] - h["par"] for h in 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)) 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_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_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: def is_gir(h) -> bool:
return h["score"] - h["putts"] <= h["par"] - 2 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]) 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]) 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)] 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]) 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] 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: def avg_per_round(field: str) -> float | None:
totals = [] totals = []
for holes in by_round.values(): for round_id in round_ids:
eligible = [h for h in holes if h["played"] and h["score"] is not None and h[field] is not None] 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: if eligible:
totals.append(sum(h[field] for h in eligible)) totals.append(sum(h[field] for h in eligible))
return avg(totals) return avg(totals)
return RoundStatsSummary( return RoundStatsSummary(
rounds_completed=len(by_round), rounds_completed=len(round_ids),
avg_score_to_par=avg(to_par_values), avg_score_to_par=avg(to_par_values),
avg_putts_per_18=avg(putt_totals), avg_putts_per_18=avg(putt_totals),
putts_tracked_rounds=len(putt_totals), putts_tracked_rounds=len(putt_totals),
fairway_hit_pct=fairway_hit_pct, fairway_hit_pct=fairway_hit_pct,
fairway_left_pct=fairway_left_pct,
fairway_right_pct=fairway_right_pct,
fairway_tracked_holes=len(fairway_tracked), fairway_tracked_holes=len(fairway_tracked),
gir_pct=gir_pct, gir_pct=gir_pct,
gir_tracked_holes=len(gir_eligible), 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, one_putt_pct=one_putt_pct,
scrambling_pct=scrambling_pct, scrambling_pct=scrambling_pct,
sand_save_pct=sand_save_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): async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata, """Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se

View file

@ -1,17 +1,18 @@
"use client" "use client"
// Aggregert statistikk på tvers av ALLE fullførte "Egne runder" (ADR-033), // Aggregert statistikk på tvers av "Egne runder" (ADR-033), lenket fra
// lenket fra dashbordets "Statistikk"-seksjon (2026-07-28). Egen backend- // 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 // aggregering (GET /rounds/stats/summary) -- ikke bare klientside-utledning
// av round-stats.tsx sin logikk, siden putt/18-hull-regelen (padding av // 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 // uspilte hull til 2 putter, KUN for dette tallet) og "forrige periode"-
// fullførte runder samlet, ikke én runde om gangen. // vinduet må regnes over ALLE fullførte runder samlet, ikke én om gangen.
import type React from "react" import type React from "react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" 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 = { type ApiStatsSummary = {
rounds_completed: number rounds_completed: number
@ -19,9 +20,16 @@ type ApiStatsSummary = {
avg_putts_per_18: number | null avg_putts_per_18: number | null
putts_tracked_rounds: number putts_tracked_rounds: number
fairway_hit_pct: number | null fairway_hit_pct: number | null
fairway_left_pct: number | null
fairway_right_pct: number | null
fairway_tracked_holes: number fairway_tracked_holes: number
gir_pct: number | null gir_pct: number | null
gir_tracked_holes: number 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 one_putt_pct: number | null
scrambling_pct: number | null scrambling_pct: number | null
sand_save_pct: number | null sand_save_pct: number | null
@ -31,6 +39,24 @@ type ApiStatsSummary = {
avg_anyway_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 { function signed(n: number): string {
const sign = n > 0 ? "+" : n < 0 ? "" : "±" const sign = n > 0 ? "+" : n < 0 ? "" : "±"
return `${sign}${Math.abs(n).toFixed(1).replace(".", ",")}` return `${sign}${Math.abs(n).toFixed(1).replace(".", ",")}`
@ -40,11 +66,58 @@ function pct(n: number): string {
return `${Math.round(n)} %` 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 ( return (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <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-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> <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>} {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</div> </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() { export function RoundsStatsSummary() {
const router = useRouter() 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) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function load() {
try { 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) { if (res.status === 401) {
router.replace("/") router.replace("/")
return return
} }
if (!res.ok) throw new Error(`stats: ${res.status}`) 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) if (!cancelled) setData(json)
} catch { } catch {
if (!cancelled) setError("Klarte ikke å hente statistikken. Prøv igjen om litt.") if (!cancelled) setError("Klarte ikke å hente statistikken. Prøv igjen om litt.")
@ -95,7 +200,9 @@ export function RoundsStatsSummary() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [router]) }, [router, window])
const s = data?.current ?? null
return ( return (
<div className="flex min-h-[100dvh] flex-col bg-background"> <div className="flex min-h-[100dvh] flex-col bg-background">
@ -113,6 +220,21 @@ export function RoundsStatsSummary() {
</header> </header>
<main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-8 px-4 py-6 pb-16"> <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 && ( {error && (
<p role="alert" className="text-base font-medium text-destructive"> <p role="alert" className="text-base font-medium text-destructive">
{error} {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" /> <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> <span className="text-base font-medium text-muted-foreground">Laster statistikk</span>
</div> </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 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"> <div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<BarChart3 aria-hidden="true" className="size-8 text-primary" /> <BarChart3 aria-hidden="true" className="size-8 text-primary" />
</div> </div>
<div className="flex max-w-md flex-col gap-2"> <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"> <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> </p>
</div> </div>
</div> </div>
) : data ? ( ) : data ? (
(() => {
const s = data.current
const p = data.previous
return (
<> <>
<p className="text-base leading-relaxed text-muted-foreground text-pretty"> <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> </p>
<Section title="Oversikt" icon={BarChart3}> <Section title="Oversikt" icon={BarChart3}>
<StatTile label="Runder fullført" value={String(data.rounds_completed)} /> <StatTile label="Runder fullført" value={String(s.rounds_completed)} />
<StatTile label="Snitt til par" value={data.avg_score_to_par !== null ? signed(data.avg_score_to_par) : ""} /> <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 <StatTile
label="Putt / 18 hull" 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={ hint={
data.putts_tracked_rounds > 0 s.putts_tracked_rounds > 0
? `${data.putts_tracked_rounds} ${data.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing` ? `${s.putts_tracked_rounds} ${s.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing`
: "Ingen runder med puttsporing ennå" : "Ingen runder med puttsporing ennå"
} }
/> />
@ -159,42 +297,96 @@ export function RoundsStatsSummary() {
<Section title="Fairway og green" icon={Flag}> <Section title="Fairway og green" icon={Flag}>
<StatTile <StatTile
label="Fairwaytreff" label="Fairwaytreff"
value={data.fairway_hit_pct !== null ? pct(data.fairway_hit_pct) : ""} value={s.fairway_hit_pct !== null ? pct(s.fairway_hit_pct) : ""}
hint={data.fairway_tracked_holes > 0 ? `${data.fairway_tracked_holes} hull registrert` : undefined} 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 <StatTile
label="Greentreff (GIR)" label="Greentreff (GIR)"
value={data.gir_pct !== null ? pct(data.gir_pct) : ""} value={s.gir_pct !== null ? pct(s.gir_pct) : ""}
hint={data.gir_tracked_holes > 0 ? `${data.gir_tracked_holes} hull registrert` : undefined} 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> </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}> <Section title="Redning" icon={Target}>
<StatTile <StatTile
label="Scrambling" 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" hint="Par eller bedre uten GIR"
/> />
<StatTile <StatTile
label="Sand save" 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" hint="Par eller bedre fra bunker"
/> />
</Section> </Section>
<Section title="Chip, bunker og straffeslag" icon={Waves}> <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
<StatTile label="Bunkerslag / runde" value={data.avg_bunker_per_round !== null ? data.avg_bunker_per_round.toFixed(1).replace(".", ",") : ""} /> label="Chip / runde"
<StatTile label="Straffeslag / runde" value={data.avg_penalty_per_round !== null ? data.avg_penalty_per_round.toFixed(1).replace(".", ",") : ""} /> 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> </Section>
{data.avg_anyway_per_round !== null && ( {s.avg_anyway_per_round !== null && (
<Section title="Annet" icon={Wind}> <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> </Section>
)} )}
</> </>
)
})()
) : null} ) : null}
</main> </main>
</div> </div>