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:
parent
beae43ffd7
commit
99b71ab97b
3 changed files with 424 additions and 75 deletions
58
CLAUDE.md
58
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 på (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 på 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 på (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
|
||||
|
|
|
|||
|
|
@ -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 ennå</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 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={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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue