diff --git a/CLAUDE.md b/CLAUDE.md
index cf82264..c93ae86 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3673,17 +3673,157 @@ Ferdig og verifisert:
fremtidig håndkodet frontend-arbeid denne økten — bruk den til å
FAKTISK se resultatet før noe rapporteres som ferdig, i stedet for kun
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:
-0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet
- (2026-07-26):** håndkodet (V0 tom for credits), rullet ut. Ekte
- nettleser-interaksjonstest ikke utført av meg (intet slikt verktøy
- tilgjengelig) — bruker bør selv klikke gjennom flyten (spesielt
- gjeste-kjønnsendringens reaktive utslagsfilter, "velg vs. rediger"-
- trykkflatene) før full tillit.
-0b. **Rundeleaderboard — LIVE, men ikke nettleser-testet (2026-07-26):**
- håndkodet (ikke V0), backend+frontend rullet ut. Bruker bør selv teste
- brutto/netto-veksling og visuelt uttrykk før full tillit.
+0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
+ (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
+ konsoll-feil. Ikke hvert enkelt interaksjonsdetalj (f.eks. gjeste-
+ kjønnsendringens reaktive utslagsfilter) klikket gjennom stykke for
+ stykke, men grunnleggende rendring/lasting er bevist, ikke lenger
+ bare typesjekket.
+0b. **Rundeleaderboard — nå FAKTISK nettleser-bekreftet (2026-07-27):**
+ brutto/netto/poeng-veksling testet direkte i nettleseren, viste
+ korrekte tall og riktig form/farge-språk.
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
venner/kategorisert deling fase 1 (ADR-036) — begge designet
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index ce3d1cf..6479345 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -1351,12 +1351,19 @@ kombinert med sticky venstre+høyre kolonner samtidig rendret fullstendig
ødelagt/overlappende i Chrome. Fikset ved å droppe sticky-posisjonering
på `Ut`/`Inn`/`Sum`-kolonnene (scroller nå med resten av hullene i normal
flyt) og beholde kun den velprøvde sticky venstre navnekolonnen. Verifisert
-med ekte skjermbilder + scroll-simulering mot ekte produksjonsdata. Full
-detalj i CLAUDE.md sin statuslogg (2026-07-27) — ikke duplisert her.
+med ekte skjermbilder + scroll-simulering mot ekte produksjonsdata.
+
+**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
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
uten en konkret, begrunnet bruk å vise til ennå.
-**Status: ingen kode endret** — dette er en anbefaling, ikke en
-beslutning. Hvis bekreftet: en liten, lav-risiko endring
-(`globals.css`, to nye CSS-variabler + Tailwind-token-kobling, samme
-mønster som `--brand-orange`), ingen migrasjon, ingen bakoverkompatibi-
-litetsbekymring siden det kun er et TILLEGG til paletten.
+**Oppdatering 2026-07-28:** brukeren ba eksplisitt om TO nye farger (ikke
+bare den anbefalte ene). Løftet BEGGE `--chart-3` (blå → `--info`) OG
+`--chart-4` (gul/gull → `--gold`, ikke drøftet over, men samme
+gjenbruk-fremfor-ny-nyanse-logikk) til egne kjernetoken i `globals.css`.
+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.
---
diff --git a/app/routers/rounds.py b/app/routers/rounds.py
index 2f3669e..1347d70 100644
--- a/app/routers/rounds.py
+++ b/app/routers/rounds.py
@@ -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]
+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):
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 4716261..f8bbef5 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -40,6 +40,10 @@
--color-background: var(--background);
--color-brand-orange: var(--brand-orange);
--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-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -71,6 +75,14 @@
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--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
tekst-/tall-etiketter, aldri farge alene som eneste signal. */
--chart-1: oklch(0.55 0.15 160);
@@ -112,6 +124,10 @@
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--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. */
--chart-1: oklch(0.7 0.16 160);
--chart-2: oklch(0.79 0.17 140);
@@ -152,6 +168,10 @@
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--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-2: oklch(0.79 0.17 140);
--chart-3: oklch(0.75 0.13 210);
diff --git a/frontend/app/my-rounds/course/[name]/page.tsx b/frontend/app/my-rounds/course/[name]/page.tsx
new file mode 100644
index 0000000..4c0446d
--- /dev/null
+++ b/frontend/app/my-rounds/course/[name]/page.tsx
@@ -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
+}
diff --git a/frontend/app/my-rounds/stats/page.tsx b/frontend/app/my-rounds/stats/page.tsx
new file mode 100644
index 0000000..52832c4
--- /dev/null
+++ b/frontend/app/my-rounds/stats/page.tsx
@@ -0,0 +1,5 @@
+import { RoundsStatsSummary } from "@/components/rounds-stats-summary"
+
+export default function RoundsStatsPage() {
+ return
+}
diff --git a/frontend/components/course-rounds.tsx b/frontend/components/course-rounds.tsx
new file mode 100644
index 0000000..7dc7d80
--- /dev/null
+++ b/frontend/components/course-rounds.tsx
@@ -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(null)
+ const [error, setError] = useState(null)
+ const [viewerId, setViewerId] = useState(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 (
+
+
+
+
+
+ Til dashbord
+
+
+
+
+
+
+
+
+
+ {courseName}
+
+
+ {rounds === null ? "Runder spilt på denne banen." : `${rounds.length} ${rounds.length === 1 ? "runde" : "runder"} spilt på denne banen.`}
+
)
})}
diff --git a/frontend/components/own-rounds.tsx b/frontend/components/own-rounds.tsx
index 2b91f00..2b7dca9 100644
--- a/frontend/components/own-rounds.tsx
+++ b/frontend/components/own-rounds.tsx
@@ -38,7 +38,7 @@ type ApiRound = {
// 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).
-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 differential = me?.counts_for_handicap ? me.score_differential : null
return {
@@ -54,9 +54,23 @@ function toRound(r: ApiRound, viewerId: string | null): Round {
totalScore: r.my_total_score ?? undefined,
toPar: r.my_score_to_par ?? undefined,
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() {
const router = useRouter()
const [rounds, setRounds] = useState(null)
@@ -87,7 +101,8 @@ export function OwnRounds() {
}
if (!res.ok) throw new Error(`rounds: ${res.status}`)
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 {
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
}
diff --git a/frontend/components/round-card.tsx b/frontend/components/round-card.tsx
index 404b469..e1fea5f 100644
--- a/frontend/components/round-card.tsx
+++ b/frontend/components/round-card.tsx
@@ -1,5 +1,5 @@
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"
export type RoundStatus = "active" | "completed"
@@ -20,6 +20,9 @@ export type Round = {
toPar?: number
// Handicap differential, only when a completed round counted (one decimal).
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 = {
@@ -141,6 +144,7 @@ export function RoundCard({ round }: { round: Round }) {
showDifferential && round.differential != null
? round.differential.toFixed(1).replace(".", ",")
: null
+ const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
// Concise summary so the compact score tile reads clearly for screen readers.
const scoreSummary =
@@ -170,6 +174,12 @@ export function RoundCard({ round }: { round: Round }) {
{displayTitle}
+ {showPersonalBest && (
+
+
+ Personlig rekord
+
+ )}
@@ -191,9 +201,9 @@ export function RoundCard({ round }: { round: Round }) {
{differentialText && (
-
-
- Diff {differentialText}
+
+
+ Hcp spilt til {differentialText}
)}
diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx
index e754aab..b51599a 100644
--- a/frontend/components/round-detail.tsx
+++ b/frontend/components/round-detail.tsx
@@ -1198,7 +1198,7 @@ function CompletedBanner({
{player.name}
{counts ? (
- Differensial
+ Hcp spilt til
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx
index 6b0102e..fa46a0c 100644
--- a/frontend/components/round-scorecard.tsx
+++ b/frontend/components/round-scorecard.tsx
@@ -427,7 +427,15 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
const showStableford = holes.some((h) => h.strokes_received !== 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))
+ // 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 totalPointsValues = orderedHoles.map(stablefordPoints).filter((n): n is number => n !== null)
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
@@ -463,7 +471,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
{totalGross !== null && (
{totalGross}
- {signedToPar(totalGross, totalPar)} til par
+ {signedToPar(totalGross, playedPar)} til par
)}
@@ -516,7 +524,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
{showStableford && }
diff --git a/frontend/components/rounds-stats-summary.tsx b/frontend/components/rounds-stats-summary.tsx
new file mode 100644
index 0000000..c814c54
--- /dev/null
+++ b/frontend/components/rounds-stats-summary.tsx
@@ -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 (
+