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:
Erol Haagenrud 2026-07-28 07:04:56 +02:00
parent 074e17388b
commit beae43ffd7
13 changed files with 790 additions and 41 deletions

158
CLAUDE.md
View file

@ -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

View file

@ -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
`Ut`/`Inn`/`Sum`-kolonnene (scroller nå med resten av hullene i normal `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.
--- ---

View file

@ -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 (stat_level
'strokes_only') telles ALLE 18 lagrede hull -- et hull uten registrert
putt-verdi (uspilt, eller utenfor et 9-hulls spilleomfang) telles som 2
putter. Runder uten puttsporing holdes helt utenfor putt-tallet.
Alle andre andelstall (fairway/GIR/én-putt/scrambling/sand save) pooler
kun FAKTISK registrerte hull tvers av alle runder -- samme formler
som `round-stats.tsx` sin `computeStats()` bruker per enkeltrunde
(portert hit, ikke en ny definisjon), ingen padding der siden brukeren
ikke ba om det for disse tallene.
"""
async with plain_connection() as conn:
rows = await conn.fetch(
"""
SELECT rp.round_id::text AS round_id, rp.stat_level,
rh.par, rh.played, rh.score, rh.putts,
rh.tee_shot_result, rh.approach_result, rh.chip_count,
rh.bunker_shot_count, rh.penalty_strokes, rh.anyway_strokes
FROM round_participant rp
JOIN round r ON r.id = rp.round_id
JOIN round_hole rh ON rh.round_participant_id = rp.id
WHERE rp.user_id = $1 AND r.completed_at IS NOT NULL
ORDER BY r.played_at, rh.hole_number
""",
user.user_id,
)
by_round: dict[str, list] = {}
stat_level_by_round: dict[str, str] = {}
for r in rows:
by_round.setdefault(r["round_id"], []).append(r)
stat_level_by_round[r["round_id"]] = r["stat_level"]
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

View file

@ -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);

View 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)} />
}

View file

@ -0,0 +1,5 @@
import { RoundsStatsSummary } from "@/components/rounds-stats-summary"
export default function RoundsStatsPage() {
return <RoundsStatsSummary />
}

View 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 denne banen.
</p>
</div>
</div>
)
}

View file

@ -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>
) )
})} })}

View file

@ -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.")
} }

View file

@ -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>

View file

@ -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>

View file

@ -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" />}

View 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>
)
}