From beae43ffd7de67f1d088679424d0b3dc1989d80d Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 28 Jul 2026 07:04:56 +0200 Subject: [PATCH] All done and verified live in the browser. Summary of this round: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- CLAUDE.md | 158 +++++++++++++- FEATURE_BACKLOG.md | 26 ++- app/routers/rounds.py | 115 ++++++++++ frontend/app/globals.css | 20 ++ frontend/app/my-rounds/course/[name]/page.tsx | 6 + frontend/app/my-rounds/stats/page.tsx | 5 + frontend/components/course-rounds.tsx | 195 +++++++++++++++++ frontend/components/dashboard.tsx | 53 +++-- frontend/components/own-rounds.tsx | 19 +- frontend/components/round-card.tsx | 18 +- frontend/components/round-detail.tsx | 2 +- frontend/components/round-scorecard.tsx | 12 +- frontend/components/rounds-stats-summary.tsx | 202 ++++++++++++++++++ 13 files changed, 790 insertions(+), 41 deletions(-) create mode 100644 frontend/app/my-rounds/course/[name]/page.tsx create mode 100644 frontend/app/my-rounds/stats/page.tsx create mode 100644 frontend/components/course-rounds.tsx create mode 100644 frontend/components/rounds-stats-summary.tsx 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 ( +
+
+
+ +
+
+ +
+
+

+

+

+ {rounds === null ? "Runder spilt på denne banen." : `${rounds.length} ${rounds.length === 1 ? "runde" : "runder"} spilt på denne banen.`} +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ {rounds === null ? ( + + ) : rounds.length === 0 ? ( + + ) : ( +
    + {rounds.map((round) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ) +} + +function LoadingState() { + return ( +
+
+ ) +} + +function EmptyState() { + return ( +
+
+
+
+

Ingen runder funnet

+

+ Fant ingen runder registrert på denne banen. +

+
+
+ ) +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 8ec86a0..27840b7 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -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 } } -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) return { id: r.id, @@ -132,9 +132,22 @@ function toRound(r: ApiRound, viewerId: string | null): Round { 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, } } +// 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) ------------ 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 completedRounds = rows.filter((r) => r.completed_at) const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3) + const personalBestRoundId = findPersonalBestRoundId(completedRounds) const avgToParSample = completedRounds .filter((r) => r.my_score_to_par !== null) @@ -395,7 +409,7 @@ export function Dashboard() {
- toRound(r, me.id))} loading={rounds === null} /> + toRound(r, me.id, personalBestRoundId))} loading={rounds === null} /> @@ -703,13 +717,13 @@ function ShortcutButton({ ) } -// --- 2. Kommende runder -------------------------------------------------------- +// --- 2. Runder -------------------------------------------------------- function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) { const visible = rounds.slice(0, 3) return ( -
- 0 ? : null} /> +
+ 0 ? : null} /> {loading ? (
@@ -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 ( +
+ {label} + {value} + {hint && {hint}} +
+ ) +} + +function Section({ + title, + icon: Icon, + children, +}: { + title: string + icon: typeof BarChart3 + children: React.ReactNode +}) { + return ( +
+

+

+
{children}
+
+ ) +} + +export function RoundsStatsSummary() { + const router = useRouter() + const [data, setData] = useState(null) + const [error, setError] = useState(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 ( +
+
+
+ +
+
+ +
+ {error && ( +

+ {error} +

+ )} + + {data === null && !error ? ( +
+ + ) : data && data.rounds_completed === 0 ? ( +
+
+
+
+

Ingen fullførte runder ennå

+

+ Statistikken bygger seg opp automatisk etter hvert som du fullfører runder. +

+
+
+ ) : data ? ( + <> +

+ Aggregert over {data.rounds_completed} {data.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}. +

+ +
+ + + 0 + ? `${data.putts_tracked_rounds} ${data.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing` + : "Ingen runder med puttsporing ennå" + } + /> +
+ +
+ 0 ? `${data.fairway_tracked_holes} hull registrert` : undefined} + /> + 0 ? `${data.gir_tracked_holes} hull registrert` : undefined} + /> + +
+ +
+ + +
+ +
+ + + +
+ + {data.avg_anyway_per_round !== null && ( +
+ +
+ )} + + ) : null} +
+
+ ) +}