All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard. Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry. Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round. Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping. Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round. Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
This commit is contained in:
parent
074e17388b
commit
beae43ffd7
13 changed files with 790 additions and 41 deletions
158
CLAUDE.md
158
CLAUDE.md
|
|
@ -3673,17 +3673,157 @@ Ferdig og verifisert:
|
||||||
fremtidig håndkodet frontend-arbeid denne økten — bruk den til å
|
fremtidig håndkodet frontend-arbeid denne økten — bruk den til å
|
||||||
FAKTISK se resultatet før noe rapporteres som ferdig, i stedet for kun
|
FAKTISK se resultatet før noe rapporteres som ferdig, i stedet for kun
|
||||||
typesjekk+container-boot+`curl`-baserte proxyer for "det virker".
|
typesjekk+container-boot+`curl`-baserte proxyer for "det virker".
|
||||||
|
- **Full nettleser-gjennomgang av ALLE 22 skjermer, 2026-07-27 —
|
||||||
|
systematisk browsersjekk av alt som IKKE var reelt nettleser-testet
|
||||||
|
tidligere.** Brukeren spurte først hvilke visninger som faktisk finnes
|
||||||
|
(svart med en gruppert oversikt over `frontend/app/`s 22 `page.tsx`-
|
||||||
|
ruter), deretter ba om at ALLE de ikke-browsersjekkede skjermene faktisk
|
||||||
|
ble sjekket. Logget inn som `hei@erol.no` (spiller-konto, egne runder)
|
||||||
|
og — etter en egen runde med å spore opp riktig konto (magic-link fra
|
||||||
|
brukeren landet først på feil konto to ganger: `erol.haagenrud@gmail.com`
|
||||||
|
og kontoen manglet 2FA — satt opp TOTP for ekte ved å hente ut den rå
|
||||||
|
base32-secreten fra oppsett-skjermen og regne ut en gyldig 6-sifret kode
|
||||||
|
selv med et frittstående RFC 6238-script, ingen autentisator-app
|
||||||
|
involvert) — som `erol.haagenrud@envide.no` (org-eier for «Tjøme
|
||||||
|
Gents») for de org-/turnering-scopede skjermene. Gikk gjennom alle 22
|
||||||
|
ruter med ekte skjermbilder + konsoll-feil-sjekk (`list_console_
|
||||||
|
messages`) på hver.
|
||||||
|
**To reelle funn:**
|
||||||
|
1. Scorekort-gridet (§1, bygget dagen før) hadde EN NY sticky-kolonne-
|
||||||
|
overlapp-bug som IKKE fantes i utgangspunktet -- se eget punkt over,
|
||||||
|
fikset samme økt.
|
||||||
|
2. **Ny, ekte bug funnet i `round-scorecard.tsx`** (post-runde-
|
||||||
|
scorekortet, bygget i en tidligere økt) -- "til par" i BÅDE
|
||||||
|
header-hero-tallet og bunn-"TIL PAR"-brikken regnet
|
||||||
|
`totalGross - totalPar` der `totalPar` var summen av ALLE 18 hulls
|
||||||
|
par, ikke bare de faktisk spilte -- ga en absurd "−53 til par" for
|
||||||
|
en runde med 19 slag på kun 4 hull (skulle vært "+2"). Bekreftet
|
||||||
|
ved at `/my-rounds/[id]/stats` (en ANNEN komponent) viste riktig
|
||||||
|
"+2,00 til par" for SAMME runde, som isolerte feilen presist til
|
||||||
|
`round-scorecard.tsx`. Fikset med en ny `playedPar`-variabel
|
||||||
|
(paret for KUN spilte hull) brukt i de to til-par-utregningene --
|
||||||
|
"Par"-brikken nederst beholdt bevisst `totalPar` (hele rundens
|
||||||
|
par, riktig som statisk referanse). Bunn-"Par"/øvre "Hcp"/"Par"-
|
||||||
|
referanseradene i `ScoreBlock` ble sjekket og bekreftet IKKE
|
||||||
|
rammet (brukes kun til den statiske referansen, aldri til en
|
||||||
|
til-par-utregning).
|
||||||
|
**For å nå de org-/turnering-scopede skjermene: satt org «Tjøme
|
||||||
|
Gents» sin `public_profile`/`slug` MIDLERTIDIG (bekreftet med bruker
|
||||||
|
FØR endring) for å teste `/clubs/[slug]`, deretter revertert
|
||||||
|
eksplisitt til nøyaktig opprinnelig tilstand (`slug=null`,
|
||||||
|
`public_profile=false`) — bekreftet med en direkte databasespørring
|
||||||
|
etterpå at reverten var eksakt.
|
||||||
|
**Alle 22 skjermer bekreftet uten krasj/konsoll-feil** (utenom
|
||||||
|
forventede 401/403 på steder som SKAL avvise — feil passord-forsøk,
|
||||||
|
privat lagchat, ugyldig verify-token). Full liste med status i
|
||||||
|
chat-loggen denne runden.
|
||||||
|
**Rullet ut live 2026-07-27**, ingen migrasjon for til-par-fiksen,
|
||||||
|
`docker compose up -d --build teecup_frontend`, verifisert direkte i
|
||||||
|
nettleseren mot den samme runden som viste bugen (nå "+2 til par",
|
||||||
|
korrekt).
|
||||||
|
- **Dashboard-runde: bane-navn-fiks, to nye designfarger, bane-detaljvisning
|
||||||
|
og aggregert statistikk — BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE
|
||||||
|
(2026-07-28):** fem punkter reist av brukeren i samme runde, alle
|
||||||
|
bekreftet eksplisitt før bygging (AskUserQuestion) unntatt fargevalget,
|
||||||
|
der brukeren ba om at jeg selv "tok på designerbrillene" og foreslo.
|
||||||
|
1. **"Kommende runder" → "Runder"** på dashbordet (`dashboard.tsx`,
|
||||||
|
`UpcomingRounds`) — ren tekstendring, ingen annen forekomst i kodebasen.
|
||||||
|
2. **Tjøme-bane-duplikatet rettet i ekte `teecup_db`:** presist
|
||||||
|
diagnostisert FØR noe ble kjørt — alle tre av `hei@erol.no` sine
|
||||||
|
runder pekte til nøyaktig samme `teeoff_facility_slug`/
|
||||||
|
`teeoff_course_id` (`tjome-golfklubb`/140), kun `round.
|
||||||
|
course_name_snapshot`-TEKSTEN differerte (én runde fra FØR
|
||||||
|
enkeltbane-navnefiksen 25.07 hadde "Tjøme Golfklubb – Hovedbanen").
|
||||||
|
Ingen delt banetabell å slå sammen — frittstående runder har (bevisst,
|
||||||
|
ADR-033 Beslutning C) ingen egen `course`-rad, kun et navn-snapshot per
|
||||||
|
runde. Fiksen var én presist scopet `UPDATE round SET
|
||||||
|
course_name_snapshot = 'Tjøme Golfklubb' WHERE id = 'fa6e528f-...'`
|
||||||
|
(1 rad), kjørt etter eksplisitt bekreftelse, verifisert med en
|
||||||
|
read-only spørring rett etterpå.
|
||||||
|
3. **To nye kjernefarger** — brukeren ba eksplisitt om minst én, "kanskje
|
||||||
|
to", etter først å ha fått presentert (og avvist "kun én") en
|
||||||
|
tidligere anbefaling fra 25.07 om å løfte den allerede eksisterende
|
||||||
|
`--chart-3`-blåtonen. Løftet BEGGE allerede validerte statistikk-
|
||||||
|
fargene i stedet for å finne opp nye OKLCH-verdier: `--info`
|
||||||
|
(fra `--chart-3`, blå) og `--gold` (fra `--chart-4`, gul/gull) —
|
||||||
|
nye tokens i `globals.css` (`:root`/`.dark`/media-dark-blokken, alle
|
||||||
|
tre synkronisert som vanlig). Konkret, begrunnet førstebruk for
|
||||||
|
begge, ikke bare dekorativt: `--info` på et nytt "Hcp spilt til
|
||||||
|
X"-merke (før: ren grå tekst) på rundekort (`round-card.tsx`),
|
||||||
|
`--gold` på et nytt "Personlig rekord"-merke (`Medal`-ikon) som vises
|
||||||
|
når en fullført runde er brukerens laveste til-par blant MINST to
|
||||||
|
fullførte runder (unngår at den eneste fullførte runden feilaktig
|
||||||
|
kalles en "rekord"). Beregnet i `dashboard.tsx`/`own-rounds.tsx`/
|
||||||
|
`course-rounds.tsx` sine respektive `findPersonalBestRoundId()`.
|
||||||
|
4. **Bane-detaljvisning:** "Spilte baner" på dashbordet er nå klikkbar
|
||||||
|
(`PlayedCourses`, `dashboard.tsx`) — ny rute
|
||||||
|
`/my-rounds/course/[name]` (`components/course-rounds.tsx`), gjenbruker
|
||||||
|
eksisterende `GET /rounds` (ingen nytt backend-endepunkt), filtrerer
|
||||||
|
client-side på nøyaktig samme `course_name_snapshot`-nøkkel dashbordets
|
||||||
|
egen gruppering allerede bruker. "Personlig rekord" regnes likevel over
|
||||||
|
HELE rundelisten, ikke bare denne banens, for at merket skal bety det
|
||||||
|
samme uansett hvor et rundekort vises.
|
||||||
|
**Reell bug funnet OG fikset UNDER browserverifisering, ikke antatt
|
||||||
|
riktig fra kildekoden alene:** første versjon leste `params.name` rått
|
||||||
|
uten `decodeURIComponent` (matchet et eksisterende mønster i
|
||||||
|
`/clubs/[slug]/page.tsx` som aldri hadde blitt testet med et navn som
|
||||||
|
inneholder mellomrom/æøå) — et ekte skjermbilde viste tittelen som
|
||||||
|
`Tj%C3%B8me%20G...` og "0 runder funnet", siden matchen skjedde mot den
|
||||||
|
RÅ URL-kodede strengen. Rettet med et eksplisitt `decodeURIComponent`,
|
||||||
|
bekreftet med et nytt skjermbilde: riktig tittel og alle tre Tjøme-
|
||||||
|
rundene listet, inkl. både `--info`- og `--gold`-merkene rendret
|
||||||
|
korrekt.
|
||||||
|
5. **Aggregert statistikk, klikkbar fra dashbordet:** ny
|
||||||
|
`GET /rounds/stats/summary` (`app/routers/rounds.py`), ny side
|
||||||
|
`/my-rounds/stats` (`components/rounds-stats-summary.tsx`), lenket
|
||||||
|
fra dashbordets "Statistikk"-seksjon ("Se full statistikk"). Bruker
|
||||||
|
valgte det BREDESTE av tre foreslåtte omfang (utover kun runder/snitt-
|
||||||
|
til-par/putt: fairwaytreff, GIR, én-putt, scrambling, sand save,
|
||||||
|
snitt chip/bunker/straffeslag/anywayslag per runde) — portert fra de
|
||||||
|
allerede R&A/manuelt verifiserte formlene i `round-stats.tsx` sin
|
||||||
|
`computeStats()` for ÉN runde, generalisert til å pole alle kvalifiserte
|
||||||
|
hull på tvers av ALLE fullførte runder (ikke gjennomsnitt av per-runde-
|
||||||
|
prosenter, som ville vektet små utvalg feil).
|
||||||
|
**Putt/18-hull-regelen** (brukerens eksplisitte instruks, presist
|
||||||
|
bekreftet tolkning FØR bygging via AskUserQuestion): `round_hole` har
|
||||||
|
alltid nøyaktig 18 rader per deltaker uansett `holes_planned` (9 eller
|
||||||
|
18) — verifisert i `_create_participant`. For hver fullført runde der
|
||||||
|
puttsporing faktisk var på (`stat_level != 'strokes_only'`) telles
|
||||||
|
derfor alle 18 lagrede rader, et hull uten registrert putt-verdi
|
||||||
|
(uspilt, eller utenfor et 9-hulls spilleomfang) telles som 2 putter —
|
||||||
|
runder UTEN puttsporing holdes helt utenfor tallet (ellers ville alle
|
||||||
|
18 hull feilaktig blitt padded). Kun putt-tallet padder — alle andre
|
||||||
|
andelstall bruker KUN faktisk registrerte hull, som instruert.
|
||||||
|
**Verifisert i to lag:** (a) en frittstående Python-simulering av
|
||||||
|
nøyaktig samme aggregeringslogikk mot et hånd-konstruert 3-runde-
|
||||||
|
datasett (én `strokes_only`-runde padding-ekskludert, én
|
||||||
|
`strokes_and_putts`-runde med 9 av 18 hull padded) — alle hånd-regnede
|
||||||
|
forventninger stemte eksakt, inkl. det kritiske tilfellet (padded runde
|
||||||
|
ga nøyaktig 36 putt/18, strokes_only-runden talte 0 mot totalen); (b)
|
||||||
|
ekte typesjekket produksjonsbuild + import-sjekk av hele FastAPI-appen
|
||||||
|
i det faktiske prod-imaget (ikke bare syntaks) + et ekte browserbesøk
|
||||||
|
som viste reelle, korrekt utregnede tall for `hei@erol.no` sine 2
|
||||||
|
fullførte runder.
|
||||||
|
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt for
|
||||||
|
databaseskrivingen (punkt 2) og fargevalget (punkt 3, "to farger i stedet"
|
||||||
|
for anbefalt én); resten bygget direkte på brukerens egen presise
|
||||||
|
instruks. Ingen migrasjon. `docker compose up -d --build teecup_api
|
||||||
|
teecup_frontend` (kjørt to ganger — én gang for hovedleveransen, én gang
|
||||||
|
for `decodeURIComponent`-fiksen over). `/health`/`/dashboard`/
|
||||||
|
`/my-rounds/stats`/`/my-rounds/course/...` alle bekreftet 200 og
|
||||||
|
konsoll-feilfrie i en ekte innlogget nettleser-sesjon, `teeoff.no`
|
||||||
|
upåvirket.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
(2026-07-26):** håndkodet (V0 tom for credits), rullet ut. Ekte
|
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||||
nettleser-interaksjonstest ikke utført av meg (intet slikt verktøy
|
konsoll-feil. Ikke hvert enkelt interaksjonsdetalj (f.eks. gjeste-
|
||||||
tilgjengelig) — bruker bør selv klikke gjennom flyten (spesielt
|
kjønnsendringens reaktive utslagsfilter) klikket gjennom stykke for
|
||||||
gjeste-kjønnsendringens reaktive utslagsfilter, "velg vs. rediger"-
|
stykke, men grunnleggende rendring/lasting er bevist, ikke lenger
|
||||||
trykkflatene) før full tillit.
|
bare typesjekket.
|
||||||
0b. **Rundeleaderboard — LIVE, men ikke nettleser-testet (2026-07-26):**
|
0b. **Rundeleaderboard — nå FAKTISK nettleser-bekreftet (2026-07-27):**
|
||||||
håndkodet (ikke V0), backend+frontend rullet ut. Bruker bør selv teste
|
brutto/netto/poeng-veksling testet direkte i nettleseren, viste
|
||||||
brutto/netto-veksling og visuelt uttrykk før full tillit.
|
korrekte tall og riktig form/farge-språk.
|
||||||
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
|
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
|
||||||
venner/kategorisert deling fase 1 (ADR-036) — begge designet
|
venner/kategorisert deling fase 1 (ADR-036) — begge designet
|
||||||
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
|
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
|
||||||
|
|
|
||||||
|
|
@ -1351,12 +1351,19 @@ kombinert med sticky venstre+høyre kolonner samtidig rendret fullstendig
|
||||||
ødelagt/overlappende i Chrome. Fikset ved å droppe sticky-posisjonering
|
ødelagt/overlappende i Chrome. Fikset ved å droppe sticky-posisjonering
|
||||||
på `Ut`/`Inn`/`Sum`-kolonnene (scroller nå med resten av hullene i normal
|
på `Ut`/`Inn`/`Sum`-kolonnene (scroller nå med resten av hullene i normal
|
||||||
flyt) og beholde kun den velprøvde sticky venstre navnekolonnen. Verifisert
|
flyt) og beholde kun den velprøvde sticky venstre navnekolonnen. Verifisert
|
||||||
med ekte skjermbilder + scroll-simulering mot ekte produksjonsdata. Full
|
med ekte skjermbilder + scroll-simulering mot ekte produksjonsdata.
|
||||||
detalj i CLAUDE.md sin statuslogg (2026-07-27) — ikke duplisert her.
|
|
||||||
|
**Samme dag, oppfølging: full nettleser-gjennomgang av alle 22 skjermer**
|
||||||
|
(brukeren spurte hvilke visninger som finnes, ba deretter om at alle
|
||||||
|
ikke-browsersjekkede ble sjekket). Fant OG fikset en ny, ekte "til par"-
|
||||||
|
bug i `round-scorecard.tsx` (regnet mot hele rundens par i stedet for
|
||||||
|
kun spilte hulls par — ga en absurd "−53 til par" midt i en runde).
|
||||||
|
Resten av de 22 skjermene bekreftet uten krasj/konsoll-feil. Full detalj
|
||||||
|
i CLAUDE.md sin statuslogg (2026-07-27) — ikke duplisert her.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## En tredje (informasjons-)farge til designet — 🧠 DRØFTET 2026-07-25, IKKE besluttet
|
## En tredje (informasjons-)farge til designet — ✅ BYGGET OG LIVE 2026-07-28
|
||||||
|
|
||||||
Brukeren spurte om det ville vært en idé å introdusere én (eller kanskje
|
Brukeren spurte om det ville vært en idé å introdusere én (eller kanskje
|
||||||
to) nye farger til TeeCups design — trolig utløst av Golf GameBook-
|
to) nye farger til TeeCups design — trolig utløst av Golf GameBook-
|
||||||
|
|
@ -1394,11 +1401,14 @@ fire klassiske statuskategoriene godt (god/informasjon/advarsel/
|
||||||
kritisk-destruktiv). Flere farger enn det risikerer å utvanne betydningen
|
kritisk-destruktiv). Flere farger enn det risikerer å utvanne betydningen
|
||||||
uten en konkret, begrunnet bruk å vise til ennå.
|
uten en konkret, begrunnet bruk å vise til ennå.
|
||||||
|
|
||||||
**Status: ingen kode endret** — dette er en anbefaling, ikke en
|
**Oppdatering 2026-07-28:** brukeren ba eksplisitt om TO nye farger (ikke
|
||||||
beslutning. Hvis bekreftet: en liten, lav-risiko endring
|
bare den anbefalte ene). Løftet BEGGE `--chart-3` (blå → `--info`) OG
|
||||||
(`globals.css`, to nye CSS-variabler + Tailwind-token-kobling, samme
|
`--chart-4` (gul/gull → `--gold`, ikke drøftet over, men samme
|
||||||
mønster som `--brand-orange`), ingen migrasjon, ingen bakoverkompatibi-
|
gjenbruk-fremfor-ny-nyanse-logikk) til egne kjernetoken i `globals.css`.
|
||||||
litetsbekymring siden det kun er et TILLEGG til paletten.
|
Konkret førstebruk: `--info` på et nytt "Hcp spilt til X"-merke på
|
||||||
|
rundekort, `--gold` på et nytt "Personlig rekord"-merke (laveste til-par
|
||||||
|
blant minst to fullførte runder). Full detalj i CLAUDE.md sin statuslogg
|
||||||
|
(2026-07-28) — ikke duplisert her.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,121 @@ async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[Rou
|
||||||
return [await _load_round_out(conn, r["id"], user.user_id) for r in ids]
|
return [await _load_round_out(conn, r["id"], user.user_id) for r in ids]
|
||||||
|
|
||||||
|
|
||||||
|
class RoundStatsSummary(BaseModel):
|
||||||
|
rounds_completed: int
|
||||||
|
avg_score_to_par: float | None
|
||||||
|
avg_putts_per_18: float | None
|
||||||
|
putts_tracked_rounds: int
|
||||||
|
fairway_hit_pct: float | None
|
||||||
|
fairway_tracked_holes: int
|
||||||
|
gir_pct: float | None
|
||||||
|
gir_tracked_holes: int
|
||||||
|
one_putt_pct: float | None
|
||||||
|
scrambling_pct: float | None
|
||||||
|
sand_save_pct: float | None
|
||||||
|
avg_chip_per_round: float | None
|
||||||
|
avg_bunker_per_round: float | None
|
||||||
|
avg_penalty_per_round: float | None
|
||||||
|
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`).
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
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():
|
||||||
|
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":
|
||||||
|
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]
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
def is_gir(h) -> bool:
|
||||||
|
return h["score"] - h["putts"] <= h["par"] - 2
|
||||||
|
|
||||||
|
gir_eligible = [h for h in all_played if h["putts"] is not None]
|
||||||
|
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])
|
||||||
|
|
||||||
|
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]
|
||||||
|
sand_save_pct = avg([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in sand_eligible])
|
||||||
|
|
||||||
|
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]
|
||||||
|
if eligible:
|
||||||
|
totals.append(sum(h[field] for h in eligible))
|
||||||
|
return avg(totals)
|
||||||
|
|
||||||
|
return RoundStatsSummary(
|
||||||
|
rounds_completed=len(by_round),
|
||||||
|
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_tracked_holes=len(fairway_tracked),
|
||||||
|
gir_pct=gir_pct,
|
||||||
|
gir_tracked_holes=len(gir_eligible),
|
||||||
|
one_putt_pct=one_putt_pct,
|
||||||
|
scrambling_pct=scrambling_pct,
|
||||||
|
sand_save_pct=sand_save_pct,
|
||||||
|
avg_chip_per_round=avg_per_round("chip_count"),
|
||||||
|
avg_bunker_per_round=avg_per_round("bunker_shot_count"),
|
||||||
|
avg_penalty_per_round=avg_per_round("penalty_strokes"),
|
||||||
|
avg_anyway_per_round=avg_per_round("anyway_strokes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-brand-orange: var(--brand-orange);
|
--color-brand-orange: var(--brand-orange);
|
||||||
--color-brand-orange-foreground: var(--brand-orange-foreground);
|
--color-brand-orange-foreground: var(--brand-orange-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-gold: var(--gold);
|
||||||
|
--color-gold-foreground: var(--gold-foreground);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
|
@ -71,6 +75,14 @@
|
||||||
--ring: oklch(0.7512 0.1613 130.33);
|
--ring: oklch(0.7512 0.1613 130.33);
|
||||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||||
--brand-orange-foreground: oklch(0.99 0 0);
|
--brand-orange-foreground: oklch(0.99 0 0);
|
||||||
|
/* Løftet fra chart-3/chart-4 (2026-07-28, se DESIGN_SYSTEM.md) -- egne
|
||||||
|
kjernetoken for nøytral informasjon (--info) og personlig-rekord/
|
||||||
|
merkeverdig-prestasjon (--gold), samme validerte OKLCH-verdier som
|
||||||
|
statistikk-skalaen bruker allerede. */
|
||||||
|
--info: oklch(0.68 0.13 210);
|
||||||
|
--info-foreground: oklch(0.99 0 0);
|
||||||
|
--gold: oklch(0.79 0.15 85);
|
||||||
|
--gold-foreground: oklch(0.22 0.04 85);
|
||||||
/* Statistikk-fargeskala (rundestatistikk, ADR-033) -- brukt sammen med
|
/* Statistikk-fargeskala (rundestatistikk, ADR-033) -- brukt sammen med
|
||||||
tekst-/tall-etiketter, aldri farge alene som eneste signal. */
|
tekst-/tall-etiketter, aldri farge alene som eneste signal. */
|
||||||
--chart-1: oklch(0.55 0.15 160);
|
--chart-1: oklch(0.55 0.15 160);
|
||||||
|
|
@ -112,6 +124,10 @@
|
||||||
--ring: oklch(0.7512 0.1613 130.33);
|
--ring: oklch(0.7512 0.1613 130.33);
|
||||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||||
--brand-orange-foreground: oklch(0.16 0.02 40);
|
--brand-orange-foreground: oklch(0.16 0.02 40);
|
||||||
|
--info: oklch(0.75 0.13 210);
|
||||||
|
--info-foreground: oklch(0.16 0.02 210);
|
||||||
|
--gold: oklch(0.83 0.14 85);
|
||||||
|
--gold-foreground: oklch(0.18 0.03 85);
|
||||||
/* Statistikk-fargeskala, løftet for kontrast på mørk bakgrunn. */
|
/* Statistikk-fargeskala, løftet for kontrast på mørk bakgrunn. */
|
||||||
--chart-1: oklch(0.7 0.16 160);
|
--chart-1: oklch(0.7 0.16 160);
|
||||||
--chart-2: oklch(0.79 0.17 140);
|
--chart-2: oklch(0.79 0.17 140);
|
||||||
|
|
@ -152,6 +168,10 @@
|
||||||
--ring: oklch(0.7512 0.1613 130.33);
|
--ring: oklch(0.7512 0.1613 130.33);
|
||||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||||
--brand-orange-foreground: oklch(0.16 0.02 40);
|
--brand-orange-foreground: oklch(0.16 0.02 40);
|
||||||
|
--info: oklch(0.75 0.13 210);
|
||||||
|
--info-foreground: oklch(0.16 0.02 210);
|
||||||
|
--gold: oklch(0.83 0.14 85);
|
||||||
|
--gold-foreground: oklch(0.18 0.03 85);
|
||||||
--chart-1: oklch(0.7 0.16 160);
|
--chart-1: oklch(0.7 0.16 160);
|
||||||
--chart-2: oklch(0.79 0.17 140);
|
--chart-2: oklch(0.79 0.17 140);
|
||||||
--chart-3: oklch(0.75 0.13 210);
|
--chart-3: oklch(0.75 0.13 210);
|
||||||
|
|
|
||||||
6
frontend/app/my-rounds/course/[name]/page.tsx
Normal file
6
frontend/app/my-rounds/course/[name]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { CourseRounds } from "@/components/course-rounds"
|
||||||
|
|
||||||
|
export default async function CourseRoundsPage({ params }: { params: Promise<{ name: string }> }) {
|
||||||
|
const { name } = await params
|
||||||
|
return <CourseRounds courseName={decodeURIComponent(name)} />
|
||||||
|
}
|
||||||
5
frontend/app/my-rounds/stats/page.tsx
Normal file
5
frontend/app/my-rounds/stats/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { RoundsStatsSummary } from "@/components/rounds-stats-summary"
|
||||||
|
|
||||||
|
export default function RoundsStatsPage() {
|
||||||
|
return <RoundsStatsSummary />
|
||||||
|
}
|
||||||
195
frontend/components/course-rounds.tsx
Normal file
195
frontend/components/course-rounds.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Bane-detaljvisning (2026-07-28): "Spilte baner" på dashbordet lenker hit --
|
||||||
|
// alle egne runder spilt på én gitt bane. Ingen nytt backend-endepunkt --
|
||||||
|
// samme GET /rounds som resten av "Egne runder"-flyten, filtrert client-side
|
||||||
|
// på course_name_snapshot (samme nøkkel dashbordets "Spilte baner"-gruppering
|
||||||
|
// allerede bruker, se dashboard.tsx). "Personlig rekord"-merket regnes likevel
|
||||||
|
// ALLTID over hele rundelisten (ikke bare denne banen), for at merket skal
|
||||||
|
// bety det samme uansett hvor et rundekort vises.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Loader2, MapPin, ArrowLeft } from "lucide-react"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
import { RoundCard, type Round } from "@/components/round-card"
|
||||||
|
|
||||||
|
type ApiRoundParticipant = {
|
||||||
|
id: string
|
||||||
|
user_id: string | null
|
||||||
|
is_owner: boolean
|
||||||
|
guest_name: string | null
|
||||||
|
counts_for_handicap: boolean
|
||||||
|
score_differential: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiRound = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
course_name_snapshot: string
|
||||||
|
tee_name_snapshot: string
|
||||||
|
played_at: string
|
||||||
|
holes_planned: number
|
||||||
|
completed_at: string | null
|
||||||
|
participants: ApiRoundParticipant[]
|
||||||
|
my_holes_played: number
|
||||||
|
my_total_score: number | null
|
||||||
|
my_score_to_par: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round {
|
||||||
|
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
courseName: r.course_name_snapshot,
|
||||||
|
status: r.completed_at ? "completed" : "active",
|
||||||
|
teeName: r.tee_name_snapshot,
|
||||||
|
holes: r.holes_planned === 9 ? 9 : 18,
|
||||||
|
date: r.played_at,
|
||||||
|
playerCount: r.participants.length,
|
||||||
|
holesPlayed: r.my_holes_played,
|
||||||
|
totalScore: r.my_total_score ?? undefined,
|
||||||
|
toPar: r.my_score_to_par ?? undefined,
|
||||||
|
differential: me?.counts_for_handicap ? me.score_differential : null,
|
||||||
|
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPersonalBestRoundId(rounds: ApiRound[]): string | null {
|
||||||
|
const completed = rounds.filter((r) => r.completed_at !== null && r.my_score_to_par !== null)
|
||||||
|
if (completed.length < 2) return null
|
||||||
|
let best: { id: string; toPar: number } | null = null
|
||||||
|
for (const r of completed) {
|
||||||
|
const toPar = r.my_score_to_par as number
|
||||||
|
if (!best || toPar < best.toPar) best = { id: r.id, toPar }
|
||||||
|
}
|
||||||
|
return best?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseRounds({ courseName }: { courseName: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [rounds, setRounds] = useState<Round[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [viewerId, setViewerId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch("/auth/me", { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: { id: string } | null) => {
|
||||||
|
if (!cancelled && data) setViewerId(data.id)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/rounds", { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
|
const data: ApiRound[] = await res.json()
|
||||||
|
const personalBestRoundId = findPersonalBestRoundId(data)
|
||||||
|
const forCourse = data
|
||||||
|
.filter((r) => r.course_name_snapshot === courseName)
|
||||||
|
.map((r) => toRound(r, viewerId, personalBestRoundId))
|
||||||
|
if (!cancelled) setRounds(forCourse)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router, viewerId, courseName])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl px-2 py-2 text-base font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
Til dashbord
|
||||||
|
</Link>
|
||||||
|
<Wordmark compact />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="flex items-center gap-2 text-3xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
<MapPin aria-hidden="true" className="size-7 shrink-0 text-primary" />
|
||||||
|
<span className="truncate">{courseName}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
{rounds === null ? "Runder spilt på denne banen." : `${rounds.length} ${rounds.length === 1 ? "runde" : "runder"} spilt på denne banen.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-4 text-base font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{rounds === null ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : rounds.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<li key={round.id}>
|
||||||
|
<RoundCard round={round} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex flex-col items-center justify-center gap-4 py-20 text-center"
|
||||||
|
>
|
||||||
|
<Loader2 aria-hidden="true" className="size-9 animate-spin text-primary" />
|
||||||
|
<span className="text-base font-medium text-muted-foreground">Laster runder…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<MapPin 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 runder funnet</h2>
|
||||||
|
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Fant ingen runder registrert på denne banen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -117,7 +117,7 @@ function toTournament(t: ApiTournament): Tournament {
|
||||||
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
|
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRound(r: ApiRound, viewerId: string | null): Round {
|
function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round {
|
||||||
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
@ -132,9 +132,22 @@ function toRound(r: ApiRound, viewerId: string | null): Round {
|
||||||
totalScore: r.my_total_score ?? undefined,
|
totalScore: r.my_total_score ?? undefined,
|
||||||
toPar: r.my_score_to_par ?? undefined,
|
toPar: r.my_score_to_par ?? undefined,
|
||||||
differential: me?.counts_for_handicap ? me.score_differential : null,
|
differential: me?.counts_for_handicap ? me.score_differential : null,
|
||||||
|
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Laveste egne til-par blant minst to fullførte runder -- én eneste
|
||||||
|
// fullført runde er ikke ennå meningsfullt en "rekord".
|
||||||
|
function findPersonalBestRoundId(completedRounds: ApiRound[]): string | null {
|
||||||
|
if (completedRounds.length < 2) return null
|
||||||
|
let best: { id: string; toPar: number } | null = null
|
||||||
|
for (const r of completedRounds) {
|
||||||
|
if (r.my_score_to_par === null) continue
|
||||||
|
if (!best || r.my_score_to_par < best.toPar) best = { id: r.id, toPar: r.my_score_to_par }
|
||||||
|
}
|
||||||
|
return best?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
// --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------
|
// --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------
|
||||||
|
|
||||||
type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null }
|
type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null }
|
||||||
|
|
@ -328,6 +341,7 @@ export function Dashboard() {
|
||||||
const activeRounds = rows.filter((r) => !r.completed_at)
|
const activeRounds = rows.filter((r) => !r.completed_at)
|
||||||
const completedRounds = rows.filter((r) => r.completed_at)
|
const completedRounds = rows.filter((r) => r.completed_at)
|
||||||
const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3)
|
const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3)
|
||||||
|
const personalBestRoundId = findPersonalBestRoundId(completedRounds)
|
||||||
|
|
||||||
const avgToParSample = completedRounds
|
const avgToParSample = completedRounds
|
||||||
.filter((r) => r.my_score_to_par !== null)
|
.filter((r) => r.my_score_to_par !== null)
|
||||||
|
|
@ -395,7 +409,7 @@ export function Dashboard() {
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-8">
|
<div className="mt-6 flex flex-col gap-8">
|
||||||
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
|
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
|
||||||
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id))} loading={rounds === null} />
|
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
|
||||||
<UpcomingTournaments entries={combinedTournaments} />
|
<UpcomingTournaments entries={combinedTournaments} />
|
||||||
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
||||||
<PlayedCourses courses={playedCourses} />
|
<PlayedCourses courses={playedCourses} />
|
||||||
|
|
@ -703,13 +717,13 @@ function ShortcutButton({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Kommende runder --------------------------------------------------------
|
// --- 2. Runder --------------------------------------------------------
|
||||||
|
|
||||||
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
|
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
|
||||||
const visible = rounds.slice(0, 3)
|
const visible = rounds.slice(0, 3)
|
||||||
return (
|
return (
|
||||||
<section aria-label="Kommende runder">
|
<section aria-label="Runder">
|
||||||
<SectionHeader title="Kommende runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
|
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
|
@ -794,7 +808,10 @@ function StatsSection({
|
||||||
const TrendIcon = hcpTrend !== null && hcpTrend < 0 ? TrendingDown : TrendingUp
|
const TrendIcon = hcpTrend !== null && hcpTrend < 0 ? TrendingDown : TrendingUp
|
||||||
return (
|
return (
|
||||||
<section aria-label="Statistikk">
|
<section aria-label="Statistikk">
|
||||||
<SectionHeader title="Statistikk" />
|
<SectionHeader
|
||||||
|
title="Statistikk"
|
||||||
|
action={roundsCount > 0 ? <SeeAllLink href="/my-rounds/stats" label="Se full statistikk" /> : null}
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<StatTile label="Runder" value={String(roundsCount)} />
|
<StatTile label="Runder" value={String(roundsCount)} />
|
||||||
<StatTile label="HCP nå" value={hcpNow}>
|
<StatTile label="HCP nå" value={hcpNow}>
|
||||||
|
|
@ -828,16 +845,22 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
|
||||||
{courses.map((course) => {
|
{courses.map((course) => {
|
||||||
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
||||||
return (
|
return (
|
||||||
<li key={course.name} className="flex min-h-[60px] items-center gap-3 px-4 py-3">
|
<li key={course.name}>
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
<Link
|
||||||
<MapPin aria-hidden="true" className="size-5" />
|
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
|
||||||
</span>
|
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
>
|
||||||
<span className="truncate text-base font-bold text-foreground">{course.name}</span>
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
<MapPin aria-hidden="true" className="size-5" />
|
||||||
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-base font-bold text-foreground">{course.name}</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ type ApiRound = {
|
||||||
|
|
||||||
// viewerId er null helt til /auth/me har svart -- faller da tilbake til
|
// viewerId er null helt til /auth/me har svart -- faller da tilbake til
|
||||||
// eierens rad (samme oppførsel som før ADR-036 fase 3-utvidelsen).
|
// eierens rad (samme oppførsel som før ADR-036 fase 3-utvidelsen).
|
||||||
function toRound(r: ApiRound, viewerId: string | null): Round {
|
function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round {
|
||||||
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
||||||
const differential = me?.counts_for_handicap ? me.score_differential : null
|
const differential = me?.counts_for_handicap ? me.score_differential : null
|
||||||
return {
|
return {
|
||||||
|
|
@ -54,9 +54,23 @@ function toRound(r: ApiRound, viewerId: string | null): Round {
|
||||||
totalScore: r.my_total_score ?? undefined,
|
totalScore: r.my_total_score ?? undefined,
|
||||||
toPar: r.my_score_to_par ?? undefined,
|
toPar: r.my_score_to_par ?? undefined,
|
||||||
differential,
|
differential,
|
||||||
|
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Samme regel som dashboardet (2026-07-28): laveste til-par blant minst to
|
||||||
|
// fullførte runder -- én enkelt fullført runde er ikke ennå en "rekord".
|
||||||
|
function findPersonalBestRoundId(rounds: ApiRound[]): string | null {
|
||||||
|
const completed = rounds.filter((r) => r.completed_at !== null && r.my_score_to_par !== null)
|
||||||
|
if (completed.length < 2) return null
|
||||||
|
let best: { id: string; toPar: number } | null = null
|
||||||
|
for (const r of completed) {
|
||||||
|
const toPar = r.my_score_to_par as number
|
||||||
|
if (!best || toPar < best.toPar) best = { id: r.id, toPar }
|
||||||
|
}
|
||||||
|
return best?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function OwnRounds() {
|
export function OwnRounds() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [rounds, setRounds] = useState<Round[] | null>(null)
|
const [rounds, setRounds] = useState<Round[] | null>(null)
|
||||||
|
|
@ -87,7 +101,8 @@ export function OwnRounds() {
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
const data: ApiRound[] = await res.json()
|
const data: ApiRound[] = await res.json()
|
||||||
if (!cancelled) setRounds(data.map((r) => toRound(r, viewerId)))
|
const personalBestRoundId = findPersonalBestRoundId(data)
|
||||||
|
if (!cancelled) setRounds(data.map((r) => toRound(r, viewerId, personalBestRoundId)))
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Trophy, Users } from "lucide-react"
|
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Medal, Trophy, Users } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export type RoundStatus = "active" | "completed"
|
export type RoundStatus = "active" | "completed"
|
||||||
|
|
@ -20,6 +20,9 @@ export type Round = {
|
||||||
toPar?: number
|
toPar?: number
|
||||||
// Handicap differential, only when a completed round counted (one decimal).
|
// Handicap differential, only when a completed round counted (one decimal).
|
||||||
differential?: number | null
|
differential?: number | null
|
||||||
|
// Lowest score-to-par among the viewer's own completed rounds (computed
|
||||||
|
// by the caller across the full rounds list, not by the card itself).
|
||||||
|
isPersonalBest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = {
|
const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = {
|
||||||
|
|
@ -141,6 +144,7 @@ export function RoundCard({ round }: { round: Round }) {
|
||||||
showDifferential && round.differential != null
|
showDifferential && round.differential != null
|
||||||
? round.differential.toFixed(1).replace(".", ",")
|
? round.differential.toFixed(1).replace(".", ",")
|
||||||
: null
|
: null
|
||||||
|
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
|
||||||
|
|
||||||
// Concise summary so the compact score tile reads clearly for screen readers.
|
// Concise summary so the compact score tile reads clearly for screen readers.
|
||||||
const scoreSummary =
|
const scoreSummary =
|
||||||
|
|
@ -170,6 +174,12 @@ export function RoundCard({ round }: { round: Round }) {
|
||||||
<span className="truncate">{displayTitle}</span>
|
<span className="truncate">{displayTitle}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<RoundStatusBadge status={round.status} />
|
<RoundStatusBadge status={round.status} />
|
||||||
|
{showPersonalBest && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-gold/20 px-2.5 py-1 text-xs font-bold text-foreground">
|
||||||
|
<Medal aria-hidden="true" className="size-3.5 text-gold" />
|
||||||
|
Personlig rekord
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground">
|
||||||
|
|
@ -191,9 +201,9 @@ export function RoundCard({ round }: { round: Round }) {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{differentialText && (
|
{differentialText && (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-info/15 px-2.5 py-0.5 text-sm font-semibold text-foreground">
|
||||||
<Gauge aria-hidden="true" className="size-4 shrink-0" />
|
<Gauge aria-hidden="true" className="size-4 shrink-0 text-info" />
|
||||||
<span className="tabular-nums">Diff {differentialText}</span>
|
<span className="tabular-nums">Hcp spilt til {differentialText}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1198,7 +1198,7 @@ function CompletedBanner({
|
||||||
<span className="text-base font-bold text-foreground">{player.name}</span>
|
<span className="text-base font-bold text-foreground">{player.name}</span>
|
||||||
{counts ? (
|
{counts ? (
|
||||||
<span className="flex items-baseline gap-1.5">
|
<span className="flex items-baseline gap-1.5">
|
||||||
<span className="text-xs font-medium text-muted-foreground">Differensial</span>
|
<span className="text-xs font-medium text-muted-foreground">Hcp spilt til</span>
|
||||||
<span className="text-lg font-extrabold tabular-nums text-foreground">
|
<span className="text-lg font-extrabold tabular-nums text-foreground">
|
||||||
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
|
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,15 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
const showStableford = holes.some((h) => h.strokes_received !== null)
|
const showStableford = holes.some((h) => h.strokes_received !== null)
|
||||||
const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null)
|
const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null)
|
||||||
|
// Full rundens par (statisk referanse, f.eks. "Par 72") -- ALDRI brukt til
|
||||||
|
// en til-par-utregning, kun til "Par"-feltet nederst som viser hele
|
||||||
|
// runden uansett hvor langt man har kommet.
|
||||||
const totalPar = sum(orderedHoles.map((h) => h.par))
|
const totalPar = sum(orderedHoles.map((h) => h.par))
|
||||||
|
// Til-par MÅ regnes mot paret for kun de SPILTE hullene -- ellers blir
|
||||||
|
// "til par" meningsløst midt i runden (f.eks. 19 slag på 4 hull ville vist
|
||||||
|
// "−53 til par" mot en 72-par bane i stedet for korrekt "+2", funnet ved
|
||||||
|
// faktisk nettleser-testing 2026-07-27).
|
||||||
|
const playedPar = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.par)) : 0
|
||||||
const totalGross = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null
|
const totalGross = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null
|
||||||
const totalPointsValues = orderedHoles.map(stablefordPoints).filter((n): n is number => n !== null)
|
const totalPointsValues = orderedHoles.map(stablefordPoints).filter((n): n is number => n !== null)
|
||||||
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
|
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
|
||||||
|
|
@ -463,7 +471,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
{totalGross !== null && (
|
{totalGross !== null && (
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-3xl font-extrabold tabular-nums text-foreground">{totalGross}</span>
|
<span className="text-3xl font-extrabold tabular-nums text-foreground">{totalGross}</span>
|
||||||
<span className="text-sm font-bold text-brand-orange">{signedToPar(totalGross, totalPar)} til par</span>
|
<span className="text-sm font-bold text-brand-orange">{signedToPar(totalGross, playedPar)} til par</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -516,7 +524,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
<TotalTile label="Score" value={totalGross !== null ? String(totalGross) : "–"} />
|
<TotalTile label="Score" value={totalGross !== null ? String(totalGross) : "–"} />
|
||||||
<TotalTile
|
<TotalTile
|
||||||
label="Til par"
|
label="Til par"
|
||||||
value={totalGross !== null ? signedToPar(totalGross, totalPar) : "–"}
|
value={totalGross !== null ? signedToPar(totalGross, playedPar) : "–"}
|
||||||
tone="orange"
|
tone="orange"
|
||||||
/>
|
/>
|
||||||
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : "–"} tone="green" />}
|
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : "–"} tone="green" />}
|
||||||
|
|
|
||||||
202
frontend/components/rounds-stats-summary.tsx
Normal file
202
frontend/components/rounds-stats-summary.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Aggregert statistikk på tvers av ALLE fullførte "Egne runder" (ADR-033),
|
||||||
|
// lenket fra dashbordets "Statistikk"-seksjon (2026-07-28). Egen backend-
|
||||||
|
// aggregering (GET /rounds/stats/summary) -- ikke bare klientside-utledning
|
||||||
|
// av round-stats.tsx sin logikk, siden putt/18-hull-regelen (padding av
|
||||||
|
// uspilte hull til 2 putter, KUN for dette tallet) må kjøre over ALLE
|
||||||
|
// fullførte runder samlet, ikke én runde om gangen.
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, BarChart3, Flag, Target, Waves, Wind } from "lucide-react"
|
||||||
|
|
||||||
|
type ApiStatsSummary = {
|
||||||
|
rounds_completed: number
|
||||||
|
avg_score_to_par: number | null
|
||||||
|
avg_putts_per_18: number | null
|
||||||
|
putts_tracked_rounds: number
|
||||||
|
fairway_hit_pct: number | null
|
||||||
|
fairway_tracked_holes: number
|
||||||
|
gir_pct: number | null
|
||||||
|
gir_tracked_holes: number
|
||||||
|
one_putt_pct: number | null
|
||||||
|
scrambling_pct: number | null
|
||||||
|
sand_save_pct: number | null
|
||||||
|
avg_chip_per_round: number | null
|
||||||
|
avg_bunker_per_round: number | null
|
||||||
|
avg_penalty_per_round: number | null
|
||||||
|
avg_anyway_per_round: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(n: number): string {
|
||||||
|
const sign = n > 0 ? "+" : n < 0 ? "−" : "±"
|
||||||
|
return `${sign}${Math.abs(n).toFixed(1).replace(".", ",")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(n: number): string {
|
||||||
|
return `${Math.round(n)} %`
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
|
||||||
|
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: typeof BarChart3
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-extrabold tracking-tight text-foreground">
|
||||||
|
<Icon aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundsStatsSummary() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [data, setData] = useState<ApiStatsSummary | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/rounds/stats/summary", { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`stats: ${res.status}`)
|
||||||
|
const json: ApiStatsSummary = await res.json()
|
||||||
|
if (!cancelled) setData(json)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente statistikken. Prøv igjen om litt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
aria-label="Til dashbord"
|
||||||
|
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-lg font-extrabold tracking-tight text-foreground">Statistikk</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-8 px-4 py-6 pb-16">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-base font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data === null && !error ? (
|
||||||
|
<div role="status" aria-live="polite" className="flex flex-col items-center justify-center gap-4 py-20 text-center">
|
||||||
|
<div aria-hidden="true" className="size-9 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
<span className="text-base font-medium text-muted-foreground">Laster statistikk…</span>
|
||||||
|
</div>
|
||||||
|
) : data && data.rounds_completed === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||||
|
<BarChart3 aria-hidden="true" className="size-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex max-w-md flex-col gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Ingen fullførte runder ennå</h2>
|
||||||
|
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Statistikken bygger seg opp automatisk etter hvert som du fullfører runder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Aggregert over {data.rounds_completed} {data.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Section title="Oversikt" icon={BarChart3}>
|
||||||
|
<StatTile label="Runder fullført" value={String(data.rounds_completed)} />
|
||||||
|
<StatTile label="Snitt til par" value={data.avg_score_to_par !== null ? signed(data.avg_score_to_par) : "–"} />
|
||||||
|
<StatTile
|
||||||
|
label="Putt / 18 hull"
|
||||||
|
value={data.avg_putts_per_18 !== null ? data.avg_putts_per_18.toFixed(1).replace(".", ",") : "–"}
|
||||||
|
hint={
|
||||||
|
data.putts_tracked_rounds > 0
|
||||||
|
? `${data.putts_tracked_rounds} ${data.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing`
|
||||||
|
: "Ingen runder med puttsporing ennå"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Fairway og green" icon={Flag}>
|
||||||
|
<StatTile
|
||||||
|
label="Fairwaytreff"
|
||||||
|
value={data.fairway_hit_pct !== null ? pct(data.fairway_hit_pct) : "–"}
|
||||||
|
hint={data.fairway_tracked_holes > 0 ? `${data.fairway_tracked_holes} hull registrert` : undefined}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Greentreff (GIR)"
|
||||||
|
value={data.gir_pct !== null ? pct(data.gir_pct) : "–"}
|
||||||
|
hint={data.gir_tracked_holes > 0 ? `${data.gir_tracked_holes} hull registrert` : undefined}
|
||||||
|
/>
|
||||||
|
<StatTile label="Én-putt" value={data.one_putt_pct !== null ? pct(data.one_putt_pct) : "–"} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Redning" icon={Target}>
|
||||||
|
<StatTile
|
||||||
|
label="Scrambling"
|
||||||
|
value={data.scrambling_pct !== null ? pct(data.scrambling_pct) : "–"}
|
||||||
|
hint="Par eller bedre uten GIR"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Sand save"
|
||||||
|
value={data.sand_save_pct !== null ? pct(data.sand_save_pct) : "–"}
|
||||||
|
hint="Par eller bedre fra bunker"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Chip, bunker og straffeslag" icon={Waves}>
|
||||||
|
<StatTile label="Chip / runde" value={data.avg_chip_per_round !== null ? data.avg_chip_per_round.toFixed(1).replace(".", ",") : "–"} />
|
||||||
|
<StatTile label="Bunkerslag / runde" value={data.avg_bunker_per_round !== null ? data.avg_bunker_per_round.toFixed(1).replace(".", ",") : "–"} />
|
||||||
|
<StatTile label="Straffeslag / runde" value={data.avg_penalty_per_round !== null ? data.avg_penalty_per_round.toFixed(1).replace(".", ",") : "–"} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{data.avg_anyway_per_round !== null && (
|
||||||
|
<Section title="Annet" icon={Wind}>
|
||||||
|
<StatTile label="Anywayslag / runde" value={data.avg_anyway_per_round.toFixed(1).replace(".", ",")} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue