diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 238a961..70d29e5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -379,7 +379,9 @@ "Bash(grep -n \"class RoundCreate\\\\|class RoundOut\\\\|played_at\\\\|start_hole: int$\\\\|started_at\" /opt/teecup/app/routers/rounds.py)", "Bash(python3 -c \"import ast; ast.parse\\(open\\('/opt/teecup/app/routers/rounds.py'\\).read\\(\\)\\)\")", "Bash(python3 test_round_edit_delete.py)", - "Bash(python3 test_round_starthole_time_nearby.py)" + "Bash(python3 test_round_starthole_time_nearby.py)", + "Bash(python3 test_round_strokes_received.py)", + "Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index fa3ed7d..16eb5f8 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -2157,6 +2157,430 @@ migrasjon, `docker compose up -d --build teecup_api teecup_frontend`, begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new` → 200, `teeoff.no` upåvirket. +**Reell UX-bug funnet OG fikset, samme dag 2026-07-25, rapportert av +bruker med skjermbilder av en konkurrerende golf-app (Golf Game Book) +som referanse:** brukeren rapporterte at "All statistikk" ikke viste +noe utover slag/putter under selve registreringen. **Bekreftet direkte +mot ekte `teecup_db` (kun lesing) at dette IKKE var en datafeil** -- +brukerens faktiske runde hadde `stat_level='full'` lagret korrekt. Rot- +årsaken var et REELT presentasjonsproblem: alle detaljfeltene (kølle, +utslag, innspill, chip, bunker, straffeslag, putt-avstand, anywayslag) +lå bak en "Score"/"Statistikk"-fane (V0-runden 2026-07-24s bevisste +designvalg) som brukeren aldri oppdaget -- "aktivert full statistikk" +ga i praksis ingen synlig endring uten et ekstra, ikke-annonsert +tastetrykk. **Fikset ved å fjerne fane-løsningen helt:** alle feltene +vises nå ALLTID samlet under slag/putter i én sammenhengende scroll når +`statLevel==="full"` (samme prinsipp brukeren opprinnelig ba om FØR +V0-rundens sveip/fane-vurdering) -- fjerner enhver tvetydighet om +hvorvidt statistikken faktisk er slått på. `PanelTabs`-komponenten og +`panelTab`-state fjernet som død kode. +**Samtidig bygget: "Så langt i runden"-oversikt**, direkte etterspurt +("jeg trenger et grensesnitt som viser scoren min så langt") med +referansebildene som inspirasjon for FUNKSJONEN (ikke kopiert +utseendemessig). Ny komponent `ScoreSoFar` i `round-detail.tsx`: en +kompakt alltid-synlig linje ("Så langt: X hull · Y slag · +Z til par") +rett under spillerfanene, pluss en "Vis full oversikt"-knapp (samme +etablerte mønster som `session-scorecard.tsx` sin `HoleSummaryTable` +for turnering-scoring) som åpner en tabell: hull, par, score, netto, +løpende sum. **Ny backend-beregning for å muliggjøre netto-kolonnen:** +`RoundHoleOut` fikk et nytt felt `strokes_received` (`list_holes` i +`app/routers/rounds.py`) -- utledet fra deltakerens +`course_handicap_snapshot` + hullenes `stroke_index` via den +ALLEREDE eksisterende `allocate_strokes_by_index()` fra +`handicap_engine.py` (samme allokeringsalgoritme som brukes overalt +ellers i appen, ingen ny logikk) -- `None` for en deltaker uten +beregnet HCP (f.eks. en gjest uten oppgitt handicap), aldri lagret, +kun beregnet ved lesing. +**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle + +isolert scratch-MinIO + engangs API-container): 11 sjekker, inkl. et +presist talleksempel (course rating 72.0/slope 113/HCP 10.0 → course +handicap nøyaktig 10, bekreftet at de 10 laveste stroke-indeksene fikk +nøyaktig 1 slag hver og de resterende 8 fikk 0, sum(strokes_received) +== course_handicap), et registrert hull som ga korrekt netto (score 5 − +1 mottatt slag = 4), og en gjest UTEN HCP som korrekt fikk +`strokes_received: null` på alle 18 hull. `test_isolation.sql` 12/12 +(ingen skjemaendring). Ekte typesjekket produksjonsbuild kompilerte +rent. +**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen +migrasjon, kun `teecup_api`+`teecup_frontend` redeployet. +`/health`/`/dashboard` → 200, `teeoff.no` upåvirket. + +**Reell produksjonsregresjon rapportert AV BRUKEREN samme dag, rett +etter utrulling over, funnet og fikset umiddelbart:** "Ingenting er +klikkbart i den avanserte statistikken." Første antagelse (frontend- +CSS/event-håndtering) ble IKKE bekreftet ved kodegjennomgang -- all +onClick-kabling i `DirectionCross`/`Stepper`/`ChoiceRow`/`NumberPicker` +var korrekt. Root cause funnet ved å faktisk GJENSKAPE brukerens +klikk-sekvens mot en fersk, isolert scratch-container (samme mønster +som resten av runden) i stedet for å gjette videre: `update_hole` +(PATCH-endepunktet for hull-registrering) konstruerte fortsatt +`RoundHoleOut(**dict(row))` UTEN det nye påkrevde +`strokes_received`-feltet fra runden rett over -- en Pydantic +`ValidationError` (500) på HVER ENESTE hull-lagring, ikke bare de +"avanserte" feltene. Frontend sin `updateStat()` svelger `!res.ok` +stille uten feilmelding, så symptomet fremsto nøyaktig som "ingenting +skjer når jeg trykker" for ALLE felt (Slag/Putter inkludert) -- brukeren +merket det trolig først på de avanserte feltene siden Slag/Putter fra +TIDLIGERE runder allerede hadde lagrede verdier som så riktige ut ved +åpning. +**Fikset:** `update_hole` beregner nå `strokes_received` for akkurat det +oppdaterte hullet (henter deltakerens `course_handicap_snapshot` + +ALLE 18 sine `stroke_index` -- samme allokeringsalgoritme som +`list_holes`, siden fordelingen avhenger av hele rundens +stroke-indeks-rekkefølge, ikke bare ett hull) og sender den med i +responsen. +**Scratch-verifisert på nytt, presist mot akkurat denne regresjonen:** +14 sjekker som gjenskaper brukerens EKSAKTE klikk-rekkefølge (Slag → +kølle → Utslag-retning → Innspill-retning → Chip → første putt-bøtte → +Anywayslag, pluss et klikk på et avansert felt FØR Slag i det hele tatt +er satt, og en gjest uten HCP) -- alle 200 med riktig ekko, `GET` etterpå +bekrefter faktisk lagring, gjest uten HCP gir korrekt `strokes_received: +null` uten å krasje. `test_isolation.sql` uendret (ingen skjemaendring, +ren Python-fiks). **Rullet ut live 2026-07-25**, bruker bekreftet +eksplisitt: kun `teecup_api` redeployet, `/health`/`/dashboard` → 200, +`teeoff.no` upåvirket. + +**Rediger/Fullfør/Slett tonet ned og flyttet til toppen + auto-scroll ved +hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at +"Fullfør runde"/"Slett runde" lå som store, fremtredende knapper RETT +under "Neste hull"-navigasjonen nederst i hull-panelet -- altfor lett å +trykke feil ved et uhell mens man bare skulle bla mellom hull. Flyttet +alle tre (Rediger/Fullfør/Slett) til en samlet rad ØVERST på siden, +tonet ned til samme nøytrale "trigger"-stil (ikke lenger store, +fargede knapper) -- brukeren må nå aktivt scrolle OPP forbi hele +hull-registreringen for å nå dem. Samtidig rapportert, i samme runde: +"Neste hull"/"Forrige" lastet nytt innhold, men brukeren ble stående +scrollet nede der knappene er, med det nye hullets Slag-felt utenfor +skjermen. Løst med `holePanelRef` + `scrollIntoView({block:"start"})` +kalt fra `goPrev`/`goNext` OG fra hull-navigasjonens direkte hull-valg, +med `scroll-mt-28` på panelet for å unngå at den sticky headeren dekker +toppen. Ren frontend-endring, ingen backend/migrasjon. Ekte typesjekket +build kjørt og bekreftet (alle 19 ruter), rullet ut, `teeoff.no` +upåvirket. + +**"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle- +statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme +dag (2026-07-25):** brukeren etterspurte flere detaljer i +rundeoversikten: netto- og stableford-sum, hvor man kan se totalt antall +putter/chip/bunker/straffeslag/anywayslag for runden, grafisk fremstilt +prosentvis fordeling av fairwaytreff og innspillstreff, og gjennomsnittlig +score til par (to desimaler) splittet på fairwaytreff-vs-bom og +innspill-treff-vs-bom. +**Ingen backend-endring nødvendig** -- alt beregnes klientside fra data +`GET .../holes` allerede returnerer (score/par/strokes_received/putts/ +chip_count/bunker_shot_count/penalty_strokes/tee_shot_result/ +approach_result/anyway_strokes). `ScoreSoFar`-komponenten +(round-detail.tsx) utvidet med: en `StatPill`-rutenett (Slag/Til par/ +Netto/Stableford/Putt/Chip/Bunker/Straffeslag/Anywayslag -- hver vist +kun hvis det faktisk finnes registrert data for akkurat det feltet), en +ny Stableford-kolonne i hull-for-hull-tabellen (kun når netto er +beregnbart), og to nye `DistributionBar`-seksjoner (Fairwaytreff/ +Innspill) -- generalisert N-kategori-variant av samme visuelle idé som +`SegmentedBar` i tournament-leaderboard.tsx (ett fargesegmentert +rektangel + prosent-legend), ikke noe chart-bibliotek lagt til. +**Presisert i kode-kommentar, bevisst valg:** appen har ingen egen +"spilleform"-innstilling for frittstående runder -- stableford beregnes +derfor alltid ut fra netto score når det er mulig (krever registrert +HCP, samme forutsetning som netto), uavhengig av om brukeren +"egentlig" spiller slagspill eller stableford. Standard stableford- +poengtabell brukt (netto par = 2 poeng, ett poeng mer/mindre per slag +bedre/dårligere enn par, gulv på 0). +Gjennomsnittlig score-til-par (fairway/innspill, treff-vs-bom) bruker +BRUTTO score (ikke netto) relativt til par, formatert med fortegn og to +desimaler (`formatSignedAvg`), matcher brukerens eksplisitte +spesifikasjon. Fairwaytreff ekskluderer par-3-hull (samme regel som +registrerings-skjemaet, som aldri viser Utslag-retning der). +**Logikken verifisert manuelt mot et regnet eksempel** (4 hull, blandet +par 3/4/5, ulike utslag-/innspillresultater) FØR utrulling -- stableford- +sum, netto-sum og begge gjennomsnitts-splittene stemte med +håndregning. Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle +19 ruter). **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: +ren frontend-endring, ingen migrasjon, `/health`/`/my-rounds` → 200, +`teeoff.no` upåvirket. + +**Rundestatistikk-skjerm (dypdykk), inspirert av en konkurrent-video, +BYGGET OG LIVE samme dag (2026-07-25):** brukeren lastet opp en 26 +sekunders skjermopptaksvideo av en KONKURRENT-apps statistikkskjerm og +ba eksplisitt om et V0-prompt inspirert av innholdet, IKKE et plagiat. +Video analysert bilde for bilde (ffmpeg kjørt i en engangs Docker- +container, ikke installert på verten -- ryddet opp etterpå). Innhold +identifisert: score-fordeling (par/bogey/dobbel bogey/verre), +snitt-til-par per hulltype, fairwayfordeling + score-splitt, GIR-donut + +per-hulltype + kryss med fairway + score-splitt, bom-retning på green +som et kompass-diagram, putt-fordeling (1/2/3-putt) + snitt per hulltype ++ med/uten GIR, én-putt% etter puttlengde + lengdefordeling hit/miss, +chip-fordeling, scrambling%/sand save%, bunker/straffeslag per runde + +score-splitt. +**Bevisste valg for å unngå plagiat, skrevet inn i selve V0-promptet:** +egen visuell identitet (TeeCups presise grønn/oransje, ikke konkurrentens +fargekoding), fritt valg av chart-type/layout/rekkefølge til V0 selv, +INGEN kopiering av konkurrentens eksakte ordlyd/fargekoding/ikonografi. +Puttlengde-bøttene i promptet er TeeCups EGNE seks bøtter +(<1m/<2m/<3m/<5m/<8m/8m+, samme som ADR-033 allerede lagrer) -- bevisst +ANDRE enn videoens fem bøtter (<1m/1-2/2-4/4-8/+), både fordi det unngår +en direkte kopi og fordi det er det datamodellen faktisk allerede +produserer. "Lengste drive" (krever GPS/avstandsmåling appen ikke har) +utelatt fra promptet. +**Zip 14 mottatt og integrert samme dag:** diffet mot live-treet FØR noe +ble tatt inn (samme rutine som alltid) -- kun to reelt nye filer +(`components/round-stats.tsx`, en ny `RoundStats`-skjerm bygget med +kollapsbare `StatCard`-seksjoner, conic-gradient-donuter med sentertall, +avviks-stolper fra en null-linje, og et kompass-rutenett for bom-retning +-- en tydelig ANNEN visuell løsning enn konkurrentens skjermbilder, ikke +en klone). Resten av eksporten var V0s vanlige uvitende reverts, inkl. +sin egen `/rounds/[id]`-ruteversjon fra FØR `/my-rounds`-omdøpingen +(se lenger opp, "Reell produksjonsbug...") -- korrekt hoppet over. Ny +rute lagt til som `app/my-rounds/[id]/stats/page.tsx` (ikke +`app/rounds/[id]/stats` som eksporten foreslo), samme +kollisjon-unngåelse. `app/globals.css` sin nye `--chart-1..6` +data-viz-fargeskala (god->dårlig-skala, brukt sammen med +tekst-/tall-etiketter) slått sammen inn i alle tre temablokkene; +V0s egne, mer omtrentlige `--primary`/`--ring`/`--brand-orange`-verdier +i SAMME diff ble bevisst IKKE tatt inn -- beholdt de presise OKLCH- +verdiene utregnet i ADR-016. +**Datalag skrevet fullstendig om fra mock:** ny `computeStats()`- +funksjon i `round-stats.tsx` regner ut alt fra rå `GET .../holes`-data +(samme endepunkt round-detail.tsx allerede bruker -- ingen ny backend). +Hver seksjon skjules helt når det ikke finnes nok data for den (samme +"vis kun det som faktisk finnes"-prinsipp som `ScoreSoFar`). Ny enkel +spillervelger (pill-rad) lagt til når runden har flere deltakere, +default til eieren. `CompletedBanner` i round-detail.tsx fikk en "Se +full rundestatistikk"-lenke (fantes i V0s egen, ellers reverterte fil -- +portert manuelt inn i vår live versjon i stedet for å ta hele filen). +**Matematikken verifisert FØR utrulling, ikke bare "kompilerer":** +`computeStats()`-logikken portert til et frittstående Node-script og +kjørt mot et hånd-etterregnet 6-hulls syntetisk datasett (blandet par +3/4/5, fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle utledede +tall (score-kategorier, snitt-til-par per hulltype, fairway-splitt, +GIR%/kryss-fairway/score-splitt, bom-retning, putt-fordeling, +scrambling%, sand save%) stemte med manuell utregning. Ekte typesjekket +produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet som +ny rute). +**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon, +`/health`/`/my-rounds` → 200, `teeoff.no` upåvirket. + +**Rundeliste-info + scorekort-redesign, BYGGET OG LIVE samme dag +(2026-07-25):** brukeren delte en ny skjermopptaksvideo (video 2, 18 +frames analysert ved 1,5 fps) av to problemer: "Egne runder"-listen +manglet ALL score-informasjon (kun tee/hull/dato/spillerantall/status), +og selve scorekort-registreringen (rapportert "veldig dårlig +designet") stablet fem ulike kontrolltyper (numpad, retningskors, +steppere, pill-rutenett, pill-rad) uten hierarki, pluss at "Runde +fullført"-siden gjentok NØYAKTIG samme tall tre ganger (CompletedBanner- +differensial, "Så langt"-oppsummeringslinjen, og StatPill-rutenettet +under). Bedt om å reflektere kort og eventuelt skrive V0-prompt(er). +**Reflektert og handlet i samme runde** (ikke bare skrevet ned): pekte ut +at score/til-par mangler helt fra listekortet som det klareste, +konkrete hullet; anbefalte å samle alt utover Slag/Putter bak én +kollapsbar "Flere detaljer"-seksjon som den viktigste enkeltfiksen for +scorekort-rotet; og fikset duplikat-problemet PÅ "Runde fullført"-siden +DIREKTE (ikke via V0) siden det bare er snakk om å SKJULE innhold, ikke +designe noe nytt: `ScoreSoFar` skjules nå helt når runden er fullført +(`{!completed && }`) -- CompletedBanner sin "Se full +rundestatistikk"-lenke dekker akkurat det samme, langt grundigere. +**Ny backend-beregning FØR V0-prompten** (slik at prompten kunne +referere til ekte, tilgjengelig data): `_load_round_out` +(`app/routers/rounds.py`) kjører nå en liten aggregatspørring mot +`round_hole` for eierens egen deltaker-rad ved hver lasting -- +`owner_holes_played`/`owner_total_score`/`owner_score_to_par` (null +til minst ett hull er registrert). Verifisert med 10 scratch-sjekker, +inkl. at en gjests egen score IKKE lekker inn i eierens aggregat. +**To V0-prompter skrevet** (round-card-redesign med prominent +score-flis/hull-fremdrift-bar/differensial; scorekort-redesign med +Slag+Putter+Avstand-første-putt alltid synlig og alt annet bak "Flere +detaljer", pluss en bevisst ikke-bygget reservasjon av layout-plass for +en fremtidig avstandsmåling-funksjon brukeren bekreftet er planlagt). +**Zip 15 og 16 mottatt samme dag** (samme v0.app-prosjekt, kontinuerlig +-- `round-card.tsx`/`own-rounds.tsx` byte-for-byte identiske i begge +zip-ene; zip 16s `round-detail.tsx` var den nyeste med selve +kollaps-redesignet, zip 15s tilsvarende fil manglet det og ble derfor +forkastet til fordel for zip 16). +`round-card.tsx`: ny `ScoreTile` -- prominent resultat+til-par-flis +(fargekodet under/over par uten å stole på farge alene, tekst+tall +alltid med) for fullførte/scorede runder, en hull-fremdrift-progressbar +("6/18 hull spilt") for runder som fortsatt pågår, pluss en HCP- +differensial-chip i metadata-raden når runden faktisk telte. Fullt +`aria-label` på hele kortet for skjermlesere. `own-rounds.tsx` sitt +datalag skrevet om fra mock til ekte fetch, kobler de nye `owner_*`- +feltene fra backend + eierens `score_differential` (kun vist når +`counts_for_handicap`). +`round-detail.tsx`: ny kollapsbar "Flere detaljer"-seksjon (lukket som +default, `ChevronDown`-rotasjon på toggle) som nå rommer kølle/utslag- +retning/innspill-retning/chip-bunker-straffeslag/anywayslag -- Slag, +Putter og Avstand første putt forblir alltid synlig over kollapsen, +uendret rekkefølge fra den forrige runden. **Viktig integrasjonsdisiplin:** +V0s eksport hadde reversert til en MYE eldre mock-baseline (manglet +StatLevel-gating, Anywayslag, bøtte-basert puttlengde, kølle-bag fra +profil, maxValue-capping, "Hullet er spilt"-avkrysningen som bevisst BLE +FJERNET en tidligere runde, "Tid brukt" i CompletedBanner) -- kun selve +kollaps-mekanismen og plasseringen av "Flere detaljer" ble hentet ut og +lagt oppå den fullt oppdaterte, LIVE koden. Ingen av de tidligere +byggede funksjonene gikk tapt. Lagt til en kort kode-kommentar (ikke +noe bygget UI) som reserverer plass ved siden av hull-headeren til en +fremtidig avstandsmåling-indikator. +**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet +(alle 19 ruter). Ingen ny scratch-backend-runde nødvendig utover +owner-score-testen over (ingen ny domenelogikk i selve rundeliste-/ +scorekort-visningen, kun lesing av allerede-testede felt). +**Rullet ut live 2026-07-25**, ren frontend-endring + den lille +backend-tilføyelsen over, ingen migrasjon, `/health`/`/my-rounds` → +200, `teeoff.no` upåvirket. + +**Manglende scorekort på statistikk-siden, funnet og fikset SAMME dag +(2026-07-25):** brukeren spurte rett etter forrige runde: "Hvor er +scorekortet? (Gjerne også med statistikk?)" -- et ekte, uforutsett hull +i forrige rundes endring. Da `ScoreSoFar` ("Så langt i runden") ble +skjult for fullførte runder (for å fjerne dobbel informasjon, se over), +forsvant OGSÅ det eneste stedet den rå hull-for-hull-tabellen (Hull/ +Par/Score/Netto/Sum) fantes -- ingen tilsvarende tabell ble noensinne +lagt til på den nye `round-stats.tsx`-siden, som kun inneholdt UTLEDET/ +aggregert statistikk (donuter, kategori-stolper, avviks-visualiseringer), +aldri de faktiske rå tallene per hull. Med andre ord: brukeren fikk +riktignok fjernet duplikatet, men mistet samtidig tilgang til selve +scorekortet -- en reell regresjon, ikke bare en presentasjonsdetalj. +**Fikset ved å legge til en ny "Scorekort"-seksjon FØRST på +`round-stats.tsx`** (før "Scorer"-kategoriseksjonen), åpen som default +(i motsetning til resten av seksjonene som starter kollapsbare men +åpne -- denne er det brukeren eksplisitt spurte etter, så den skal ikke +kreve et ekstra trykk). Samme tabellmønster som `ScoreSoFar` sin +tidligere tabell hadde (Hull/Par/Score/Netto/Stableford/Sum, Stableford- +kolonnen vist kun når netto er beregnbart). To type-tilføyelser var +nødvendig: `start_hole: number` lagt til `round-stats.tsx` sin +`ApiRound`-type (manglet fra før, siden ingen tidligere seksjon på +denne siden trengte rundens faktiske start-/rekkefølge) og +`strokes_received: number | null` lagt til `ApiHole`-typen (feltet kom +allerede fra backend -- lagt til i forrige runde for `list_holes` -- +men ble aldri lest av denne siden før nå). Ny `holeOrder`/ +`orderedHoles`-beregning i `RoundStats` gjenbruker EKSAKT samme +sirkulære start_hole-formel som round-detail.tsx, slik at en 9-hulls +runde som starter på hull 10 vises i riktig spillerekkefølge (10-18), +ikke bare rå hullnummer 1-9. +**Lærdom notert for fremtidige runder:** når et helt panel flyttes/ +skjules for å fjerne duplisert informasjon, må hver del av det gamle +panelet spores til et nytt hjem FØR det fjernes -- ikke bare de delene +som åpenbart var "statistikk". Denne runden ble oppdaget kun fordi +brukeren faktisk lette etter scorekortet rett etterpå, ikke ved egen +verifisering før utrulling. +Ekte typesjekket produksjonsbuild kjørt og bekreftet. **Rullet ut live +2026-07-25**, ren frontend-endring, ingen backend/migrasjon, +`teeoff.no` upåvirket. + +**Scorekort-visning: ny presentasjonsregel + V0-prompt skrevet, IKKE +bygget ennå (2026-07-25):** brukeren delte et referansebilde av et +tradisjonelt fysisk golf-scorekort (horisontal layout, hull 1-9/10-18 +som KOLONNER, Slope/Par/Score/Net som rader, "Ut"/"Inn"-sum-kolonne +YTTERST TIL HØYRE for hver ni-hulls-halvdel) og formulerte en generell +presentasjonsregel: **listes hullene horisontalt (som kolonner), skal +summeringen stå TIL HØYRE; listes hullene vertikalt (som rader), skal +summeringen stå UNDER.** Den nylig byggede "Scorekort"-seksjonen på +`round-stats.tsx` (se punktet rett over) lister hull VERTIKALT (én rad +per hull) med en løpende sum-KOLONNE innimellom hver rad -- ikke i tråd +med regelen (en vertikal liste burde hatt en avsluttende sumrad +UNDERST, ikke en kolonne). Bedt om å tenke gjennom dette og skrive et +V0-prompt for et "fabelaktig" scorekort inspirert av (ikke et plagiat +av) referansebildet. +**Bevisste avvik fra referansebildet for å unngå plagiat, skrevet inn i +selve promptet:** egen visuell identitet (TeeCups grønn/oransje, ikke +bildets rød-sirkel/blå-firkant-fargekoding for birdie/bogey), egen +term ("Hcp" for hullets slag-fordelings-rangering -- IKKE "Slope", som +i golf-terminologi betyr banens/tee-ens helhetlige vanskelighetsgrad, +et helt annet tall enn det bildet faktisk viser per hull), en ekstra +Stableford-rad (finnes ikke i referansen, men vi beregner det allerede +andre steder), og INGEN kopiering av bildets spiller-header-komposisjon +(navn/HCP/posisjon-boksen) -- kun selve tabell-strukturen er +inspirasjonskilden. +**Presist håndtert i promptet, IKKE triviell:** appen støtter allerede +vilkårlig `start_hole` + `holes_planned` (9 ELLER 18, sirkulær +rekkefølge, se "Manglende scorekort"-punktet over) -- en STIV +fysisk "hull 1-9 er alltid Ut" ville vært feil for en 9-hulls runde som +starter på hull 10. Promptet ber derfor om ÉN 9-kolonners blokk når +`holes_planned=9`, TO blokker (første/andre halvdel AV SPILLEREKKEFØLGEN, +ikke nødvendigvis fysisk hull 1-9/10-18) når `holes_planned=18` -- den +faktiske hull-til-blokk-tildelingen løses av meg i datalaget ved +integrering, som med alle tidligere V0-runder. +**Venter på V0-eksport** før noe bygges. Når den kommer, erstatter den +den eksisterende vertikale "Scorekort"-tabellen på `round-stats.tsx` +(bygget rett over samme dag) -- ikke en ny, separat skjerm. +**Notert som en generell prinsipp-lærdom, relevant utover akkurat dette +scorekortet:** samme horisontal-til-høyre/vertikal-til-under-regel bør +vurderes senere for turnering-scorekortet (`session-scorecard.tsx`, +match-play) den dagen det scorekortet også skal pusses -- ikke i +omfang nå, bare notert for konsistens. + +**Presisering av promptet samme dag, FØR noe ble sendt til V0:** +brukeren spurte eksplisitt om et "if REALLY necessary"-unntak for +horisontal scroll (opprinnelig formulering) faktisk fanget opp målet +om ALDRI å måtte scrolle. Vurdert og svart nei -- reell breddekonflikt, +ikke bare en formulering-detalj: 9 hull + 1 sum-kolonne (+ en +label-kolonne) får ikke plass på en telefonskjerm med normal +"lesbar uten briller"-tekststørrelse, og et unntak formulert som en +myk fallback ville sannsynligvis latt V0 falle tilbake til scroll uansett, +siden tilgjengelighetskravet rett under ga et påskudd. **Løst ved å +gjøre "ingen scroll" til et HARDT krav i promptet, OG eksplisitt fortelle +V0 hvordan det oppnås** (kompakte, fete, høykontrast-siffer i selve +rutenettet -- samme konvensjon som et fysisk scorekort bruker, også +synlig i brukerens eget referansebilde -- mens "lesbar uten +briller"-kravet eksplisitt avgrenses til labels/knapper/løpetekst, ikke +hvert enkelt rutenett-siffer). Smale forkortede rad-labels ("Hcp", +"Par", "Score", "Netto") i en trang venstre-gutter i stedet for en bred +tekstkolonne, for å frigjøre bredde til de 9 hull-kolonnene. + +**Zip 17 mottatt, horisontalt scorekort BYGGET OG LIVE samme dag +(2026-07-25):** V0 leverte akkurat det det reviderte promptet ba om -- +en EKTE HTML `` med `` (fast `w-9`-label-kolonne, auto +for hver hull-kolonne, `w-[11%]` sum-kolonne) og `table-fixed`, ingen +scroll-container noe sted. Score-cellene bruker FORM (sirkel = under +par, firkant = over par) + fylt/ufylt (fylt = 2+ slag av) i stedet for +farge alene, pluss en egen liten symbolforklaring under rundesammendraget +-- tilfredsstiller "aldri stole på farge alene" uten å kopiere +referansebildets rød-sirkel/blå-firkant-konvensjon. +**Egen, ny dedikert side** `/my-rounds/[id]/scorecard` +(`components/round-scorecard.tsx`, ny `app/my-rounds/[id]/scorecard/ +page.tsx`) -- IKKE slått sammen inn i `round-stats.tsx`, siden V0 +designet komponenten med sin egen fulle side-chrome (sticky header, +rundesammendrag-strip), ikke som et innebygd tabell-fragment. Dette gir +en klar arbeidsdeling: `round-scorecard.tsx` = det rå scorekortet, +`round-stats.tsx` = utledet/aggregert statistikk -- samme prinsipp som +allerede etablert for `ScoreSoFar` vs. `round-stats.tsx` tidligere denne +økten. +**Bevisst forkastet fra V0s eksport:** V0s egen `strokesReceived()`- +funksjon var en generisk `Math.floor(hcp/18) + (index <= hcp%18 ? 1 : +0)`-modulo-formel -- byttet ut med backend sin ALLEREDE beregnede +`strokes_received` per hull (samme `allocate_strokes_by_index()` som +resten av appen bruker), for å unngå to parallelle, potensielt +avvikende implementasjoner av HCP-slagfordeling i samme app. Samme +sirkulære `start_hole`-rekkefølge som `round-detail.tsx`/ +`round-stats.tsx` bruker for Ut/Inn-blokkene. Ny spillervelger +(pill-rad) lagt til for runder med flere deltakere, samme mønster som +`round-stats.tsx`. +**Opprydning i `round-stats.tsx`:** den midlertidige vertikale +"Scorekort"-tabellen (bygget tidligere samme dag som en rask fiks for +"Hvor er scorekortet?") er FJERNET og erstattet med en enkel, prominent +"Se scorekort"-lenke til den nye siden, rett under rundesammendraget -- +`round-stats.tsx` er dermed nå rendyrket aggregert/utledet statistikk, +det rå scorekortet finnes kun ett sted. `stablefordPoints()`- +hjelpefunksjonen og `holeOrder`/`orderedHoles`-beregningen i +`round-stats.tsx`, som kun eksisterte for den fjernede tabellen, fjernet +som dødt kode. +**V0 la selv til en to-knappers layout i `CompletedBanner`** +(round-detail.tsx) -- "Se scorekort" (primær, fylt) ved siden av den +eksisterende "Se full rundestatistikk" (sekundær, omrisset) -- tatt inn +uendret bortsett fra å rette `/rounds/...`-hrefs til `/my-rounds/...`. +**Samtidig, urelatert, rapportert av bruker midt i integreringen:** +Anywayslag manglet helt fra `round-stats.tsx` sin "Chip, bunker og +straffeslag"-seksjon (verken `anyway_strokes` i `ApiHole`-typen eller +noe utledet total fantes). Lagt til `anyway_strokes` i typen, ny +`anywayPerRound`-aggregat i `computeStats()`, ny generalisert +`StatTileRow`-komponent (2 ELLER 3 fliser -- `StatTilePair` var fast to) +brukt for Bunkerslag/Straffeslag/(Anywayslag når tilgjengelig). +Seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" (bredere +navn, bruker nevnte at et fritekst-notatfelt trolig havner der senere). +**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet, +ny rute `/my-rounds/[id]/scorecard` listet. Ingen ny backend-endring +(`anyway_strokes`/`strokes_received` fantes allerede i `RoundHoleOut`). +**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon, +`teeoff.no` upåvirket. + **Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,** som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå — matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i diff --git a/CLAUDE.md b/CLAUDE.md index d98c969..d75c7b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2314,6 +2314,231 @@ Ferdig og verifisert: avstand mot alle 174 teeoff-anlegg, geolokasjon i nettleseren, feiler stille hvis avslått). 14 nye scratch-sjekker inkl. et ekte nearby-kall mot teeoff. Full detalj i ADR-033. +- **Reell UX-bug fikset + "Så langt i runden"-oversikt bygget, samme + dag (2026-07-25), klar for utrulling:** brukeren rapporterte at "All + statistikk" ikke viste noe utover slag/putter -- bekreftet (kun + lesing) mot ekte `teecup_db` at `stat_level='full'` FAKTISK var + lagret riktig, så feilen var presentasjonen: alle detaljfeltene lå + bak en "Score"/"Statistikk"-fane fra forrige V0-runde som brukeren + aldri oppdaget. Fikset ved å fjerne faneløsningen helt -- alt vises nå + alltid samlet. Samtidig bygget en ny "Så langt"-oversikt + (`ScoreSoFar`-komponent: kompakt linje + utvidbar full-oversikt- + tabell med netto per hull), med et nytt backend-felt + `strokes_received` (`allocate_strokes_by_index()`, ingen ny + algoritme). 11 nye scratch-sjekker (inkl. et presist tall-eksempel på + slagfordelingen), ren build. Full detalj i ADR-033. + **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: kun + `teecup_api`+`teecup_frontend` redeployet, ingen migrasjon, + `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. +- **Reell produksjonsregresjon rapportert av bruker rett etter utrullingen + over, funnet og fikset umiddelbart samme dag (2026-07-25):** + "Ingenting er klikkbart i den avanserte statistikken." Root cause var + IKKE frontend (all onClick-kabling var korrekt) -- funnet ved å faktisk + gjenskape brukerens klikk-sekvens mot en fersk scratch-container: + `update_hole` (hull-PATCH) manglet fortsatt det nye påkrevde + `strokes_received`-feltet i responsen sin (lagt til i GET-endepunktet i + runden rett over, glemt i PATCH) -- ga en 500 (Pydantic-valideringsfeil) + på HVER hull-lagring, ikke bare de avanserte feltene. Frontend svelger + feilresponsen stille, så symptomet så ut som "ingenting skjer" for + ALT, ikke bare avansert statistikk (brukeren merket det trolig først + der siden Slag/Putter fra tidligere runder allerede hadde lagrede + verdier som så riktige ut). Fikset: `update_hole` beregner nå + `strokes_received` for hullet som oppdateres, samme algoritme som + `list_holes`. 14 scratch-sjekker som gjenskaper eksakt klikk- + rekkefølgen, alle bestått. **Rullet ut live 2026-07-25**, bruker + bekreftet eksplisitt: kun `teecup_api` redeployet, `/health`/ + `/dashboard` → 200, `teeoff.no` upåvirket. +- **Rediger/Fullfør/Slett tonet ned + flyttet til toppen, auto-scroll ved + hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at + "Fullfør runde"/"Slett runde" var for lette å trykke på ved et uhell + (lå rett under "Neste hull"). Flyttet alle tre handlingene til en + nedtonet rad øverst -- må nå aktivt scrolles til. Samtidig: "Neste + hull"/"Forrige" scroller nå automatisk opp til toppen av hull-panelet + (også ved direkte hull-valg), så det nye hullets Slag-felt alltid er + synlig med en gang. Ren frontend-endring, ingen backend/migrasjon. + Rullet ut, `teeoff.no` upåvirket. +- **"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle- + statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme + dag (2026-07-25):** etterspurt av bruker. Ny `StatPill`-rutenett + (Slag/Til par/Netto/Stableford/Putt/Chip/Bunker/Straffeslag/ + Anywayslag), Stableford-kolonne i hull-tabellen, og to nye + `DistributionBar`-seksjoner (Fairwaytreff/Innspill, N-kategori-variant + av `SegmentedBar`-mønsteret fra tournament-leaderboard.tsx) + + gjennomsnittlig brutto score-til-par (to desimaler, fortegn) splittet + på treff-vs-bom for både fairway og innspill. Ingen backend-endring -- + alt beregnes klientside fra data `GET .../holes` allerede returnerer. + Stableford beregnes alltid ut fra netto score når mulig (appen har + ingen egen "spilleform"-innstilling). Logikk verifisert manuelt mot et + regnet eksempel før utrulling. Ren frontend-endring, ingen migrasjon, + rullet ut, `teeoff.no` upåvirket. +- **"Avstand første putt" flyttet rett under "Putter", LIVE samme dag + (2026-07-25):** lå tidligere lenger ned i "full"-statistikk-blokken + (etter kølle/retning/chip-gruppen) -- flyttet til å bli første felt i + den blokken, rett under Putter-NumberPickeren (som ligger utenfor + "full"-gaten). Ren omrokkering, ingen ny logikk. Ekte typesjekket + build, rullet ut, `teeoff.no` upåvirket. +- **V0-prompt for en rikere rundestatistikk-skjerm skrevet, IKKE bygget + ennå (2026-07-25):** brukeren lastet opp en skjermopptaksvideo + (`screen-20260724-133013-1784892586987.mp4`, 26 sek, av en KONKURRENT- + apps statistikkskjerm) og ba om et V0-prompt inspirert av innholdet, + eksplisitt IKKE et plagiat. Video analysert bilde for bilde (ffmpeg i + en engangs Docker-container, ikke installert på verten). Innholdet + identifisert dekkes i stor grad av data vi ALLEREDE sporer + (fairwaytreff/innspill-retning/putts/chip/bunker/straffeslag/putt- + lengde-bøtte) -- ingen backend-endring skulle trengtes for de fleste + målene. Prompt skrevet til bruker i chatten (ikke lagret som egen + fil) -- beskriver mål/informasjonsarkitektur/tilgjengelighetskrav + abstrakt (donut med sentertall, gauge-stolper for avvik fra par, + retnings-diagram for bom-retning) UTEN å kopiere konkurrentens + eksakte fargevalg/layout/ordlyd, og ber eksplisitt om TeeCups egen + merkevareidentitet (grønn/oransje) + en egen visuell vri. + **Én bevisst forskjell fra videoen, valgt for å unngå plagiat OG fordi + det matcher vårt eget datamodell:** videoens puttlengde-bøtter + (<1m/1-2/2-4/4-8/+) er ANDRE enn TeeCups allerede lagrede seks bøtter + (<1m/<2m/<3m/<5m/<8m/8m+, ADR-033) -- promptet ber om TeeCups egne + bøtter, ikke videoens. "Lengste drive" (krever GPS/avstandsmåling vi + ikke har) bevisst utelatt fra promptet. + **Bygget og rullet ut live 2026-07-25, samme dag:** zip 14 mottatt. + Diffet mot live-treet FØR noe ble tatt inn (samme rutine som alltid) -- + kun to reelt nye filer (`components/round-stats.tsx`, + `app/rounds/[id]/stats/page.tsx`), resten var V0s vanlige uvitende + reverts (bl.a. sin egen `/rounds/[id]`-ruteversjon fra FØR + `/my-rounds`-omdøpingen ADR-033 gjorde 2026-07-23 -- korrekt hoppet + over). Ruten lagt inn som `app/my-rounds/[id]/stats/page.tsx` i stedet + (samme kollisjon-unngåelse). `globals.css` sin nye `--chart-1..6` + data-viz-fargeskala slått sammen inn (V0 sine egne, mer omtrentlige + `--primary`/`--ring`/`--brand-orange`-verdier IKKE tatt inn -- beholdt + de presise OKLCH-verdiene fra ADR-016). + **Datalag skrevet fullstendig om fra mock:** henter `GET /rounds/{id}` + + `GET .../participants/{id}/holes` (samme endepunkter round-detail.tsx + allerede bruker) -- ingen ny backend. Ny `computeStats()`-funksjon + regner ut ALT fra rå hull-data ved lesing (score-kategorier, + snitt-til-par totalt/per hulltype, fairway-fordeling + score-splitt, + GIR totalt/per hulltype/kryss-fairway + score-splitt, bom-retning på + green, putt-fordeling 1/2/3-putt + snitt per hulltype + med/uten GIR, + én-putt% per TeeCups egne seks puttlengde-bøtter + lengdefordeling + hit/miss, chip-fordeling, scrambling%, sand save%, bunker/straffeslag + per runde + score-splitt). Hver seksjon skjules helt når det ikke + finnes nok data (samme "vis kun det som faktisk finnes"-prinsipp som + "Så langt i runden"). Ny enkel spillervelger (pill-rad) lagt til når + runden har flere deltakere -- default til eieren. + **"Se full rundestatistikk"-lenke** lagt inn i `CompletedBanner` i + round-detail.tsx (V0s egen versjon hadde denne, men i sin ellers + fullstendig reverterte fil -- portert manuelt inn i vår LIVE versjon + i stedet for å ta hele filen). + **Matematikken verifisert FØR utrulling:** `computeStats()`-logikken + portert til et frittstående Node-script og kjørt mot et + hånd-etterregnet 6-hulls syntetisk datasett (blandet par 3/4/5, + fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle 15+ utledede + tall stemte eksakt med manuell utregning. Ekte typesjekket + produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet + som ny rute). + **Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon, + `docker compose up -d --build teecup_frontend`, `/health`/`/my-rounds` + → 200, `teeoff.no` upåvirket. +- **Rundeliste + scorekort-redesign, LIVE samme dag (2026-07-25):** + brukeren delte en ny skjermopptaksvideo av "Egne runder"-listen (som + manglet ALL score-informasjon) og av scorekort-registreringen (rapportert + som "veldig dårlig designet" -- for mange ulike knapp-typer stablet + oppå hverandre) + "Runde fullført"-siden (rapportert som "veldig mye + dobbel informasjon"). Reflektert over problemstillingen (kort, per + brukerens ønske) før to V0-prompter ble skrevet. + **Fikset direkte, uten V0:** "Runde fullført"-duplikatet -- `ScoreSoFar` + ("Så langt i runden") skjules nå helt når runden er fullført, siden + den nye rundestatistikk-siden dekker akkurat det samme, langt + grundigere. Ny backend-beregning i `_load_round_out` + (`app/routers/rounds.py`): `owner_holes_played`/`owner_total_score`/ + `owner_score_to_par`, en enkel aggregatspørring mot `round_hole` for + eierens egen deltaker-rad -- verifisert med 10 scratch-sjekker + (inkl. at en gjests score IKKE påvirker eierens aggregat). + **Zip 15 og 16 mottatt** (samme v0.app-prosjekt, kontinuerlig -- + `round-card.tsx`/`own-rounds.tsx` identiske i begge, zip 16s + `round-detail.tsx` var den nyeste med selve scorekort-redesignet; zip + 15 brukt kun for å bekrefte at zip 16 var det riktige, endelige + eksportet). + `round-card.tsx` fikk en ny `ScoreTile` -- prominent resultat+til-par + for fullførte runder, hull-fremdrift-bar ("6/18 hull spilt") for + runder som pågår, pluss en HCP-differensial-chip når runden telte. + Datalag i `own-rounds.tsx` skrevet om fra mock til ekte fetch, kobler + de nye `owner_*`-feltene fra backend + eierens `score_differential` + (kun vist når `counts_for_handicap`). + `round-detail.tsx` fikk en ny kollapsbar "Flere detaljer"-seksjon + (lukket som default) som nå rommer kølle/utslag-retning/innspill- + retning/chip-bunker-straffeslag/anywayslag -- Slag/Putter/Avstand + første putt forblir alltid synlig over. Datalag/statLevel-gating/ + bucket-basert puttlengde/maxValue-capping (alt bygget tidligere denne + økten) bevisst IKKE revertert til V0s eldre mock-baseline, kun selve + kollaps-mekanismen og plasseringen ble hentet derfra. Lagt til en kort + kode-kommentar (ikke noe bygget UI ennå) som reserverer plass ved + siden av hull-headeren til en fremtidig avstandsmåling-indikator, + bekreftet av bruker at dette kommer senere. + Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle 19 ruter). + **Rullet ut live 2026-07-25**, ren frontend-endring (+ den lille + backend-tilføyelsen over), ingen migrasjon, `/health`/`/my-rounds` → + 200, `teeoff.no` upåvirket. +- **Reelt hull funnet og fikset SAMME dag, rapportert av bruker rett + etter forrige punkt ("Hvor er scorekortet?"):** da "Så langt i runden" + ble skjult for fullførte runder (se over), forsvant OGSÅ den eneste + plassen den rå hull-for-hull-tabellen (Hull/Par/Score/Netto/Sum) fantes + -- `round-stats.tsx` (den nye dedikerte statistikk-siden) hadde KUN + utledet/aggregert statistikk (donuter, stolper), ingen tabell med de + faktiske tallene per hull. Fikset ved å legge til en ny "Scorekort"- + seksjon FØRST på statistikk-siden (samme tabell-mønster som "Så + langt" hadde -- Hull/Par/Score/Netto/Stableford/Sum), åpen som + default. La til `start_hole` i `round-stats.tsx` sin `ApiRound`-type + og `strokes_received` i `ApiHole`-typen (sistnevnte kom allerede fra + backend, bare ikke lest av denne siden ennå) for å kunne vise hullene + i rundens FAKTISKE rekkefølge (samme sirkulære start_hole-logikk som + round-detail.tsx) i stedet for bare rå hullnummer 1-18. Ekte + typesjekket build kjørt og bekreftet. Rullet ut, ren frontend-endring, + ingen backend/migrasjon, `teeoff.no` upåvirket. +- **Ny scorekort-presentasjonsregel + V0-prompt skrevet, IKKE bygget + ennå (2026-07-25):** brukeren delte et referansebilde av et + tradisjonelt horisontalt golf-scorekort og formulerte en generell + regel: hull listet HORISONTALT (som kolonner) → sum TIL HØYRE; hull + listet VERTIKALT (som rader) → sum UNDER. Dagens "Scorekort"-tabell + (bygget rett over samme dag) er vertikal med sum som løpende KOLONNE + -- ikke i tråd med regelen. V0-prompt skrevet (horisontalt scorekort, + hull 1-9/10-18 som kolonner, Ut/Inn-sum til høyre for hver halvdel, + Hcp/Par/Score/Netto/Stableford-rader, håndterer både 9- og 18-hulls + runder med vilkårlig start_hole), bevisst IKKE et plagiat av + referansebildet (egne farger, egen "Hcp"-term i stedet for bildets + "Slope", ingen kopiert spiller-header). Full detalj i + ARCHITECTURE_DECISIONS.md. **Presisert samme dag, før noe ble sendt:** + brukeren spurte om et mykt "scroll hvis nødvendig"-unntak fanget opp + målet om ALDRI å måtte scrolle -- svart nei (reell breddekonflikt med + "lesbar uten briller"-kravet, ikke bare ordlyd) og skrevet om til et + hardt "ingen scroll"-krav som eksplisitt forteller V0 HVORDAN det + oppnås (kompakte fete høykontrast-siffer i selve rutenettet, smale + forkortede rad-labels i en trang gutter -- "lesbar uten briller" + avgrenset til labels/knapper, ikke enkeltsifre). Full detalj i + ARCHITECTURE_DECISIONS.md. + **Zip 17 mottatt og BYGGET/LIVE samme dag:** en ekte HTML `
` med + `` faste kolonnebredder -- ingen scroll-container i det hele + tatt, løst med kompakte celler i stedet. Score-cellene bruker FORM + (sirkel=under par, firkant=over par) + fylt/ufylt (2+ slag av) for å + aldri stole på farge alene, pluss en egen symbolforklaring. Egen, + NY dedikert side `/my-rounds/[id]/scorecard` + (`components/round-scorecard.tsx`) -- ikke slått sammen med + `round-stats.tsx`, siden V0 designet den med egen side-chrome (header, + rundesammendrag). Datalag skrevet om fra mock til ekte fetch; V0s egen + `strokesReceived()`-formel (generisk modulo) BEVISST forkastet til + fordel for backend sin allerede beregnede `strokes_received` (samme + `allocate_strokes_by_index()` som resten av appen -- unngår to ulike + HCP-slagfordelings-implementasjoner). Samme sirkulære + start_hole-rekkefølge som resten av rundeskjermene. Den gamle + vertikale "Scorekort"-tabellen i `round-stats.tsx` FJERNET (erstattet + med en lenke til den nye siden) -- `round-stats.tsx` er nå rendyrket + aggregert statistikk, det rå scorekortet bor kun ett sted. V0 la selv + til en "Se scorekort"-knapp i `CompletedBanner` (round-detail.tsx) ved + siden av den eksisterende "Se full rundestatistikk" -- tatt inn. + **Samtidig, rapportert av bruker:** Anywayslag manglet helt fra + rundestatistikk-siden sin "Chip, bunker og straffeslag"-seksjon -- + lagt til som en tredje per-runde-flis, og seksjonen omdøpt til "Annet" + (bredere navn, siden et notatfelt trolig havner der senere også). + Ekte typesjekket build kjørt og bekreftet (ny rute + `/my-rounds/[id]/scorecard` listet). Rullet ut, ren frontend-endring, + ingen backend/migrasjon, `teeoff.no` upåvirket. Neste steg: 1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 18f4a43..57e7333 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1366,7 +1366,7 @@ tom-skjermens endelige form kan bestemmes. --- -## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE (2026-07-23) +## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-25 (se ADR-033 for full detalj per dag) **Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema (`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner), @@ -1455,6 +1455,12 @@ en ekstern golf-GPS-database?) eller UI — kun fanget som en kjent, fremtidig ambisjon som statistikk-modellen (Beslutning B) og banedata-modellen (Beslutning C) bør ha i bakhodet, siden begge kan trenge en utvidelse den dagen dette faktisk designes. +**Reconfirmed 2026-07-25** (scorekort-redesign-runden): brukeren +gjentok at avstandsmåling "ligger i kortene". Fortsatt IKKE designet +eller bygget -- eneste konkrete tiltak er en kode-KOMMENTAR i +`round-detail.tsx` sin hull-header som reserverer visuell plass ved +siden av GIR-merket, slik at en fremtidig avstand-indikator kan legges +til uten en layout-endring. Ingen data, ingen funksjonalitet. **Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor). diff --git a/app/routers/rounds.py b/app/routers/rounds.py index ff493b7..4ce1b26 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -427,6 +427,14 @@ class RoundOut(BaseModel): started_at: str | None completed_at: str | None participants: list[RoundParticipantOut] + # Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) -- + # brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i + # det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker + # 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er + # registrert. + owner_holes_played: int + owner_total_score: int | None + owner_score_to_par: int | None async def _load_round_out(conn, round_id: str) -> RoundOut: @@ -448,6 +456,21 @@ async def _load_round_out(conn, round_id: str) -> RoundOut: """, round_id, ) + owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None) + owner_agg = await conn.fetchrow( + """ + SELECT COUNT(*) FILTER (WHERE played) AS played_count, + COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score, + COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par + FROM round_hole WHERE round_participant_id = $1 + """, + owner_id, + ) + owner_holes_played = owner_agg["played_count"] if owner_agg else 0 + owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None + owner_score_to_par = ( + owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None + ) return RoundOut( id=round_row["id"], course_source=round_row["course_source"], @@ -459,6 +482,9 @@ async def _load_round_out(conn, round_id: str) -> RoundOut: started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None, completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None, participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], + owner_holes_played=owner_holes_played, + owner_total_score=owner_total_score, + owner_score_to_par=owner_score_to_par, ) @@ -886,6 +912,12 @@ class RoundHoleOut(BaseModel): penalty_strokes: int | None first_putt_distance_bucket: str | None anyway_strokes: int | None + # Slag mottatt på dette hullet, utledet fra course_handicap_snapshot + # (samme allokeringsalgoritme som resten av appen) -- kun til visning + # av netto-score i en oversiktstabell, aldri lagret. None hvis + # deltakeren ikke har en beregnet course handicap (f.eks. gjest uten + # HCP). + strokes_received: int | None @router.get( @@ -899,11 +931,11 @@ async def list_holes( ) -> list[RoundHoleOut]: async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) - owner_check = await conn.fetchval( - "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", + participant_row = await conn.fetchrow( + "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id, ) - if owner_check is None: + if participant_row is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") rows = await conn.fetch( """ @@ -914,7 +946,21 @@ async def list_holes( """, participant_id, ) - return [RoundHoleOut(**dict(r)) for r in rows] + + strokes_received_by_hole: dict[int, int] | None = None + if participant_row["course_handicap_snapshot"] is not None: + allocation = allocate_strokes_by_index( + participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in rows] + ) + strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)} + + return [ + RoundHoleOut( + **dict(r), + strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None, + ) + for r in rows + ] class HoleUpdate(BaseModel): @@ -944,11 +990,11 @@ async def update_hole( ) -> RoundHoleOut: async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) - owner_check = await conn.fetchval( - "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", + participant_row = await conn.fetchrow( + "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id, ) - if owner_check is None: + if participant_row is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") async with translate_db_errors(): @@ -972,7 +1018,24 @@ async def update_hole( ) if row is None: raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") - return RoundHoleOut(**dict(row)) + + # strokes_received er utledet av HELE rundens stroke-indeks-rekkefølge + # (samme allokeringsalgoritme som list_holes/GET), ikke bare dette ene + # hullet -- må derfor hente alle 18 sin stroke_index for å plassere + # riktig antall mottatte slag på nøyaktig dette hullet. + strokes_received = None + if participant_row["course_handicap_snapshot"] is not None: + all_indexes = await conn.fetch( + "SELECT hole_number, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number", + participant_id, + ) + allocation = allocate_strokes_by_index( + participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_indexes] + ) + by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)} + strokes_received = by_hole[hole_number] + + return RoundHoleOut(**dict(row), strokes_received=strokes_received) # --------------------------------------------------------------------------- diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 253cc7e..4716261 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -14,6 +14,7 @@ --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); + --color-chart-6: var(--chart-6); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); @@ -70,11 +71,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); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + /* 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); + --chart-2: oklch(0.72 0.17 140); + --chart-3: oklch(0.68 0.13 210); + --chart-4: oklch(0.79 0.15 85); + --chart-5: oklch(0.68 0.2 45); + --chart-6: oklch(0.57 0.22 25); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -108,11 +112,13 @@ --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); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + /* 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); + --chart-3: oklch(0.75 0.13 210); + --chart-4: oklch(0.83 0.14 85); + --chart-5: oklch(0.73 0.19 45); + --chart-6: oklch(0.66 0.21 25); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); @@ -146,11 +152,12 @@ --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); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --chart-1: oklch(0.7 0.16 160); + --chart-2: oklch(0.79 0.17 140); + --chart-3: oklch(0.75 0.13 210); + --chart-4: oklch(0.83 0.14 85); + --chart-5: oklch(0.73 0.19 45); + --chart-6: oklch(0.66 0.21 25); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); diff --git a/frontend/app/my-rounds/[id]/scorecard/page.tsx b/frontend/app/my-rounds/[id]/scorecard/page.tsx new file mode 100644 index 0000000..5cb1fe4 --- /dev/null +++ b/frontend/app/my-rounds/[id]/scorecard/page.tsx @@ -0,0 +1,10 @@ +import { RoundScorecard } from "@/components/round-scorecard" + +export default async function RoundScorecardPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + return +} diff --git a/frontend/app/my-rounds/[id]/stats/page.tsx b/frontend/app/my-rounds/[id]/stats/page.tsx new file mode 100644 index 0000000..b5573cd --- /dev/null +++ b/frontend/app/my-rounds/[id]/stats/page.tsx @@ -0,0 +1,10 @@ +import { RoundStats } from "@/components/round-stats" + +export default async function RoundStatsPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + return +} diff --git a/frontend/components/own-rounds.tsx b/frontend/components/own-rounds.tsx index 091e4db..3b2bc95 100644 --- a/frontend/components/own-rounds.tsx +++ b/frontend/components/own-rounds.tsx @@ -17,6 +17,8 @@ type ApiRoundParticipant = { id: string is_owner: boolean guest_name: string | null + counts_for_handicap: boolean + score_differential: number | null } type ApiRound = { @@ -27,9 +29,14 @@ type ApiRound = { holes_planned: number completed_at: string | null participants: ApiRoundParticipant[] + owner_holes_played: number + owner_total_score: number | null + owner_score_to_par: number | null } function toRound(r: ApiRound): Round { + const owner = r.participants.find((p) => p.is_owner) + const differential = owner?.counts_for_handicap ? owner.score_differential : null return { id: r.id, courseName: r.course_name_snapshot, @@ -38,6 +45,10 @@ function toRound(r: ApiRound): Round { holes: r.holes_planned === 9 ? 9 : 18, date: r.played_at, playerCount: r.participants.length, + holesPlayed: r.owner_holes_played, + totalScore: r.owner_total_score ?? undefined, + toPar: r.owner_score_to_par ?? undefined, + differential, } } diff --git a/frontend/components/round-card.tsx b/frontend/components/round-card.tsx index 8d624d3..e69f419 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, MapPin, Users } from "lucide-react" +import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Users } from "lucide-react" import { cn } from "@/lib/utils" export type RoundStatus = "active" | "completed" @@ -12,6 +12,13 @@ export type Round = { holes: 9 | 18 date: string playerCount: number + // Progress for rounds still in play. + holesPlayed?: number + // Owner's score, present once at least one hole is recorded. + totalScore?: number + toPar?: number + // Handicap differential, only when a completed round counted (one decimal). + differential?: number | null } const STATUS_CONFIG: Record = { @@ -39,6 +46,17 @@ function formatDate(value: string) { return dateFormatter.format(parsed) } +// "+10", "-2" or "E" for level par. The sign carries the meaning (never color alone). +function formatToPar(toPar: number) { + if (toPar === 0) return "E" + return toPar > 0 ? `+${toPar}` : `${toPar}` +} + +function toParDescription(toPar: number) { + if (toPar === 0) return "på par" + return toPar > 0 ? `${toPar} over par` : `${Math.abs(toPar)} under par` +} + function RoundStatusBadge({ status }: { status: RoundStatus }) { const config = STATUS_CONFIG[status] return ( @@ -54,13 +72,87 @@ function RoundStatusBadge({ status }: { status: RoundStatus }) { ) } +// Prominent right-side tile: final score for completed/scored rounds, +// hole progress for rounds still in play. +function ScoreTile({ round }: { round: Round }) { + const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number" + + if (round.status === "active" || !hasScore) { + const played = round.holesPlayed ?? 0 + const pct = round.holes > 0 ? Math.min(100, Math.round((played / round.holes) * 100)) : 0 + return ( +
+ + {played} + /{round.holes} + + + hull spilt + +
+
+
+
+ ) + } + + const toPar = round.toPar as number + const overPar = toPar > 0 + const underPar = toPar < 0 + + return ( +
+ + {round.totalScore} + + + {formatToPar(toPar)} + + + til par + +
+ ) +} + export function RoundCard({ round }: { round: Round }) { const playerLabel = round.playerCount === 1 ? "spiller" : "spillere" + const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number" + const showDifferential = + round.status === "completed" && + typeof round.differential === "number" && + round.differential !== null + const differentialText = + showDifferential && round.differential != null + ? round.differential.toFixed(1).replace(".", ",") + : null + + // Concise summary so the compact score tile reads clearly for screen readers. + const scoreSummary = + round.status === "completed" && hasScore + ? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.` + : `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.` + const ariaLabel = `${round.courseName}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}` return (
@@ -88,9 +180,17 @@ export function RoundCard({ round }: { round: Round }) { {round.playerCount} {playerLabel} + {differentialText && ( + + + )}
+ +
+ + + + + + + {hasNetto && } + + + + + {orderedHoles.map((h) => { + const played = h.played && h.score !== null + if (played) running += h.score as number + const netto = played && h.strokes_received !== null ? (h.score as number) - h.strokes_received : null + const points = played && netto !== null ? stablefordPoints(netto, h.par) : null + return ( + + + + + + {hasNetto && ( + + )} + + + ) + })} + +
HullParScoreNettoStablefordSum
{h.hole_number}{h.par}{played ? h.score : "–"}{netto ?? "–"}{points ?? "–"}{played ? running : "–"}
+ + + {fairwayTracked.length > 0 && ( +
+ Fairwaytreff + + {(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && ( +
+ {avgToParFairwayHit !== null && ( + + Til par ved fairwaytreff:{" "} + {formatSignedAvg(avgToParFairwayHit)} + + )} + {avgToParFairwayMiss !== null && ( + + Til par ved bom på fairway:{" "} + {formatSignedAvg(avgToParFairwayMiss)} + + )} +
+ )} +
+ )} + + {approachTracked.length > 0 && ( +
+ Innspill + + {(avgToParApproachHit !== null || avgToParApproachMiss !== null) && ( +
+ {avgToParApproachHit !== null && ( + + Til par ved treff på innspill:{" "} + {formatSignedAvg(avgToParApproachHit)} + + )} + {avgToParApproachMiss !== null && ( + + Til par ved bom på innspill:{" "} + {formatSignedAvg(avgToParApproachMiss)} + + )} +
+ )} +
+ )} + + )} + + ) +} + +function StatPill({ label, value }: { label: string; value: string }) { + return ( +
+ {value} + {label} +
+ ) +} + +// Ett rektangel delt i fargede segmenter proporsjonalt med antall -- +// samme visuelle idé som SegmentedBar i tournament-leaderboard.tsx, men +// generalisert til N kategorier (ikke bare to lag). +function DistributionBar({ segments }: { segments: { label: string; count: number; colorClass: string }[] }) { + const total = segments.reduce((sum, s) => sum + s.count, 0) + if (total === 0) return null + return ( +
+
+ {segments + .filter((s) => s.count > 0) + .map((s) => ( + +
+ {segments + .filter((s) => s.count > 0) + .map((s) => ( + + + ))} +
) } @@ -1559,46 +1920,6 @@ function NumberPicker({ ) } -// --- Panel tabs (Score / Statistikk) ---------------------------------------- -// Erstatter den tidligere "Flere detaljer"-utvidbare seksjonen. Bevisst -// trykk-baserte faner, IKKE et sveip-panel -- skjermen har allerede to -// horisontalt scrollende rader (spillerfaner, hull-navigasjon), og enda en -// sveiperetning på samme skjerm ville vært forvirrende (V0s egen vurdering, -// 2026-07-24-runden, se FEATURE_BACKLOG.md for hele prompten). - -function PanelTabs({ value, onChange }: { value: "score" | "stats"; onChange: (value: "score" | "stats") => void }) { - const tabs: Array<{ value: "score" | "stats"; label: string }> = [ - { value: "score", label: "Score" }, - { value: "stats", label: "Statistikk" }, - ] - return ( -
- {tabs.map((tab) => { - const active = value === tab.value - return ( - - ) - })} -
- ) -} - // --- Retningskors (D-pad for Utslag / Innspill) ----------------------------- function DirButton({ diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx new file mode 100644 index 0000000..bef5855 --- /dev/null +++ b/frontend/components/round-scorecard.tsx @@ -0,0 +1,486 @@ +"use client" + +// Horisontalt scorekort for en frittstående runde (ADR-033) -- egen, +// dedikert side (ikke en del av round-stats.tsx sin aggregerte statistikk). +// Presentasjon fra V0 (zip 17), datalag skrevet om fra mock til ekte fetch +// mot samme `/rounds/{id}/participants/{id}/holes`-endepunkt som +// round-detail.tsx og round-stats.tsx allerede bruker. +// +// Bevisst forskjell fra V0s eksport: V0s egen `strokesReceived()` var en +// generisk modulo-formel -- byttet ut med backend sin ALLEREDE beregnede +// `strokes_received` per hull (samme `allocate_strokes_by_index()`- +// algoritme som resten av appen), for å unngå to ulike implementasjoner av +// samme HCP-slagfordeling. + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft } from "lucide-react" +import { cn } from "@/lib/utils" + +// --- API-typer --------------------------------------------------------------- + +type ApiParticipant = { + id: string + guest_name: string | null + is_owner: boolean +} + +type ApiRound = { + id: string + course_name_snapshot: string + tee_name_snapshot: string + played_at: string + start_hole: number + holes_planned: number + participants: ApiParticipant[] +} + +type ApiHole = { + hole_number: number + par: number + stroke_index: number + played: boolean + score: number | null + strokes_received: number | null +} + +const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) + +function participantLabel(p: ApiParticipant): string { + return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest") +} + +// --- Scoring helpers ------------------------------------------------------- + +function netScore(h: ApiHole): number | null { + return h.played && h.score !== null && h.strokes_received !== null ? h.score - h.strokes_received : null +} + +// Stableford: 2 pts for net par, +/- 1 per stroke, floored at 0. +function stablefordPoints(h: ApiHole): number | null { + const net = netScore(h) + if (net === null) return null + return Math.max(0, h.par - net + 2) +} + +type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" + +function classify(gross: number, par: number): ScoreClass { + const diff = gross - par + if (diff <= -2) return "eagle" + if (diff === -1) return "birdie" + if (diff === 0) return "par" + if (diff === 1) return "bogey" + return "double" +} + +function sum(values: number[]): number { + return values.reduce((a, b) => a + b, 0) +} + +function signedToPar(score: number, par: number): string { + const diff = score - par + if (diff === 0) return "E" + return diff > 0 ? `+${diff}` : `−${Math.abs(diff)}` +} + +// --- Score mark (the visual language for the Score row) -------------------- +// Shape encodes direction (circle = under par, square = over par) so meaning +// survives without color; fill encodes magnitude (filled = 2+ strokes off). + +function ScoreMark({ gross, par }: { gross: number | null; par: number }) { + const shared = + "flex size-6 items-center justify-center text-sm font-extrabold tabular-nums sm:size-7" + if (gross === null) { + return {"–"} + } + const kind = classify(gross, par) + if (kind === "eagle") { + return ( + {gross} + ) + } + if (kind === "birdie") { + return ( + + {gross} + + ) + } + if (kind === "bogey") { + return ( + + {gross} + + ) + } + if (kind === "double") { + return ( + + {gross} + + ) + } + // par + return {gross} +} + +// --- One nine-hole block (a real table for row/column semantics) ----------- + +function ScoreBlock({ + holes, + summaryLabel, + showStableford, +}: { + holes: ApiHole[] + summaryLabel: string + showStableford: boolean +}) { + const parSum = sum(holes.map((h) => h.par)) + const playedHoles = holes.filter((h) => h.played && h.score !== null) + const grossSum = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null + const nettoValues = holes.map(netScore).filter((n): n is number => n !== null) + const nettoSum = nettoValues.length > 0 ? sum(nettoValues) : null + const pointsValues = holes.map(stablefordPoints).filter((n): n is number => n !== null) + const pointsSum = pointsValues.length > 0 ? sum(pointsValues) : null + + const cell = "border border-border text-center tabular-nums" + const sumCell = "border border-border bg-muted text-center font-extrabold tabular-nums" + const rowLabel = + "border border-border px-1 text-left text-xs font-bold text-muted-foreground" + + return ( + + + + + {holes.map((h) => ( + + ))} + + + + + + {holes.map((h) => ( + + ))} + + + + + {/* Hcp (stroke index) */} + + + {holes.map((h) => ( + + ))} + + + {/* Par */} + + + {holes.map((h) => ( + + ))} + + + {/* Score — the hero row: highlighted band + shape marks */} + + + {holes.map((h) => ( + + ))} + + + {/* Netto */} + + + {holes.map((h) => ( + + ))} + + + {/* Stableford (only when relevant) */} + {showStableford && ( + + + {holes.map((h) => ( + + ))} + + + )} + +
+ Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number} +
+ Rad + + + {h.hole_number} + + {summaryLabel} +
+ Hcp + + {h.stroke_index} +
+ Par + + {h.par} + {parSum}
+ Score + + + + + {grossSum ?? "–"}
+ Netto + + {netScore(h) ?? "–"} + {nettoSum ?? "–"}
+ Poeng + + {stablefordPoints(h) ?? "–"} + {pointsSum ?? "–"}
+ ) +} + +// --- Legend ---------------------------------------------------------------- + +function Legend() { + return ( +
+ + + 3 + + Under par (sirkel) + + + + 5 + + Over par (firkant) + + Fylt symbol = 2 slag eller mer +
+ ) +} + +// --- Combined total -------------------------------------------------------- + +function TotalTile({ label, value, tone }: { label: string; value: string; tone?: "orange" | "green" }) { + return ( +
+ + {value} + + {label} +
+ ) +} + +// --- Screen ---------------------------------------------------------------- + +export function RoundScorecard({ roundId }: { roundId: string }) { + const [round, setRound] = useState(null) + const [participantId, setParticipantId] = useState(null) + const [holes, setHoles] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + const res = await fetch(`/rounds/${roundId}`, { credentials: "include" }) + if (res.status === 403 || res.status === 404) { + if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.") + return + } + if (!res.ok) { + if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.") + return + } + const data: ApiRound = await res.json() + if (cancelled) return + setRound(data) + setParticipantId(data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null) + } + void load() + return () => { + cancelled = true + } + }, [roundId]) + + useEffect(() => { + if (!participantId) return + let cancelled = false + async function loadHoles() { + const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" }) + if (!res.ok) return + const data: ApiHole[] = await res.json() + if (!cancelled) setHoles(data) + } + void loadHoles() + return () => { + cancelled = true + } + }, [roundId, participantId]) + + if (error) { + return ( +
+

{error}

+ + Tilbake til egne runder + +
+ ) + } + + if (!round || !holes) { + return ( +
+ + ) + } + + // Samme sirkulære start_hole-rekkefølge som round-detail.tsx/round-stats.tsx + // -- "Ut"/"Inn" er dermed første/andre halvdel AV SPILLEREKKEFØLGEN, ikke + // nødvendigvis fysisk hull 1-9/10-18 (relevant kun for en 18-hulls runde + // med et annet starthull enn 1, uvanlig men strukturelt tillatt). + const holeOrder = Array.from( + { length: round.holes_planned }, + (_, i) => ((round.start_hole - 1 + i) % 18) + 1, + ) + const orderedHoles = holeOrder + .map((n) => holes.find((h) => h.hole_number === n)) + .filter((h): h is ApiHole => h !== undefined) + const front = orderedHoles.slice(0, 9) + const back = orderedHoles.slice(9, 18) + + const showStableford = holes.some((h) => h.strokes_received !== null) + const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null) + const totalPar = sum(orderedHoles.map((h) => h.par)) + 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 + + return ( +
+ {/* Header */} +
+
+ +
+
+ +
+ {/* Round summary strip */} +
+
+ + {round.course_name_snapshot} + + + {round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "} + {dateFormatter.format(new Date(round.played_at))} + +
+ {totalGross !== null && ( +
+ {totalGross} + {signedToPar(totalGross, totalPar)} til par +
+ )} +
+ + {/* Spillervelger, kun når runden har flere deltakere */} + {round.participants.length > 1 && ( +
+
+ {round.participants.map((p) => { + const active = p.id === participantId + return ( + + ) + })} +
+
+ )} + + {/* Legend */} + + + {/* Scorecard blocks */} +
+ {front.length > 0 && ( + + )} + {back.length > 0 && } +
+ + {/* Combined total */} +
+ + + + {showStableford && } +
+
+
+ ) +} diff --git a/frontend/components/round-stats.tsx b/frontend/components/round-stats.tsx new file mode 100644 index 0000000..75f2448 --- /dev/null +++ b/frontend/components/round-stats.tsx @@ -0,0 +1,1018 @@ +"use client" + +// Rundestatistikk -- dypdykk-skjerm for én frittstående runde (ADR-033), +// inspirert av (ikke kopiert fra) en skjermopptaksvideo brukeren delte av en +// konkurrent-app 2026-07-25 -- se ARCHITECTURE_DECISIONS.md for hvilke +// bevisste designvalg som skiller denne fra referansen. Presentasjon fra V0 +// (zip 14), datalag skrevet om fra mock til ekte fetch mot samme +// `/rounds/{id}/participants/{id}/holes`-endepunkt som round-detail.tsx +// allerede bruker -- ingen ny backend nødvendig. + +import type React from "react" +import { useEffect, useState } from "react" +import Link from "next/link" +import { + ArrowLeft, + BarChart3, + ChevronDown, + ChevronRight, + Flag, + Info, + MoveHorizontal, + Target, + Wind, + Circle, + Waves, +} from "lucide-react" +import { cn } from "@/lib/utils" + +// --- Helpers --------------------------------------------------------------- + +// Signed value with two decimals, Norwegian comma. e.g. 0.89 -> "+0,89" +function signed(n: number): string { + const sign = n > 0 ? "+" : n < 0 ? "\u2212" : "\u00B1" + return `${sign}${Math.abs(n).toFixed(2).replace(".", ",")}` +} + +function pct(n: number): string { + return `${Math.round(n)} %` +} + +// Chart color variables (good -> bad scale defined in globals.css) +const C = { + eagle: "var(--chart-1)", + birdie: "var(--chart-2)", + par: "var(--chart-3)", + bogey: "var(--chart-4)", + dobbel: "var(--chart-5)", + verre: "var(--chart-6)", + hit: "var(--chart-2)", + miss: "var(--chart-5)", + neutral: "var(--muted-foreground)", +} as const + +// --- Collapsible card ------------------------------------------------------ + +function StatCard({ + title, + icon: Icon, + defaultOpen = true, + children, +}: { + title: string + icon: typeof BarChart3 + defaultOpen?: boolean + children: React.ReactNode +}) { + const [open, setOpen] = useState(defaultOpen) + const bodyId = `sect-${title.replace(/\s+/g, "-").toLowerCase()}` + return ( +
+

+ +

+ {open && ( +
+ {children} +
+ )} +
+ ) +} + +// --- Donut (conic-gradient) ------------------------------------------------ + +type Segment = { label: string; value: number; color: string } + +function Donut({ + segments, + centerTop, + centerBottom, + size = 168, +}: { + segments: Segment[] + centerTop: string + centerBottom: string + size?: number +}) { + const total = segments.reduce((s, x) => s + x.value, 0) || 1 + let acc = 0 + const stops = segments + .map((seg) => { + const start = (acc / total) * 100 + acc += seg.value + const end = (acc / total) * 100 + return `${seg.color} ${start}% ${end}%` + }) + .join(", ") + return ( +
`${s.label}: ${s.value}`).join(", ")} + > +
+
+ {centerTop} + + {centerBottom} + +
+
+ ) +} + +function Legend({ items }: { items: Array<{ label: string; color: string; value: string }> }) { + return ( +
    + {items.map((it) => ( +
  • +
  • + ))} +
+ ) +} + +// --- Category bar (percentage + raw count) --------------------------------- + +function CategoryBar({ + label, + count, + percentage, + color, + max, +}: { + label: string + count: number + percentage: number + color: string + max: number +}) { + const width = max > 0 ? (percentage / max) * 100 : 0 + return ( +
+
+ {label} + + {pct(percentage)} · {count} + +
+
+
0 ? 6 : 0)}%`, backgroundColor: color }} + /> +
+
+ ) +} + +// --- Deviation bar (signed, diverging from a center 0 line) ---------------- + +function DeviationBar({ label, value, max = 2 }: { label: string; value: number; max?: number }) { + const clamped = Math.max(-max, Math.min(max, value)) + const halfPct = (Math.abs(clamped) / max) * 50 + const positive = value > 0 + // positive = over par (worse) -> orange; negative = under par (better) -> green + const color = value > 0 ? C.dobbel : value < 0 ? C.birdie : C.neutral + return ( +
+ {label} +
+ + ) +} + +// --- Horizontal progress bar (single value %) ------------------------------ + +function ProgressRow({ + label, + percentage, + color = "var(--primary)", + suffix, +}: { + label: string + percentage: number + color?: string + suffix?: string +}) { + return ( +
+ {label} +
+
+
+ + {suffix ?? pct(percentage)} + +
+ ) +} + +// --- Row of N stat tiles (2 eller 3 -- StatTilePair er en fast to-tiles- +// variant, denne generaliserer for "Annet"-seksjonens tre per-runde-tall) --- + +function StatTileRow({ tiles }: { tiles: { label: string; value: string }[] }) { + return ( +
+ {tiles.map((t) => ( +
+ {t.value} + {t.label} +
+ ))} +
+ ) +} + +// --- Paired stat tiles ----------------------------------------------------- + +function StatTilePair({ + a, + b, +}: { + a: { label: string; value: string; hint?: string } + b: { label: string; value: string; hint?: string } +}) { + return ( +
+ {[a, b].map((t) => ( +
+ {t.value} + + {t.label} + +
+ ))} +
+ ) +} + +// --- Big number stat with info toggle -------------------------------------- + +function InfoStat({ + label, + value, + explanation, +}: { + label: string + value: string + explanation: string +}) { + const [showInfo, setShowInfo] = useState(false) + return ( +
+ {value} + + {showInfo && ( +

+ {explanation} +

+ )} +
+ ) +} + +// --- Mini distribution (small labelled bars) ------------------------------- + +function MiniDistribution({ + title, + data, + color, +}: { + title: string + data: Array<{ label: string; value: number }> + color: string +}) { + const max = Math.max(...data.map((d) => d.value), 1) + return ( +
+ {title} +
+ {data.map((d) => ( +
+ + {d.label} + +
+
+
+ + {d.value} + +
+ ))} +
+
+ ) +} + +// --- API-typer (samme form som round-detail.tsx allerede bruker) ----------- + +type ApiParticipant = { + id: string + guest_name: string | null + is_owner: boolean +} + +type ApiRound = { + id: string + course_name_snapshot: string + tee_name_snapshot: string + played_at: string + start_hole: number + holes_planned: number + participants: ApiParticipant[] +} + +type ApiHole = { + hole_number: number + par: number + played: boolean + score: number | null + putts: number | null + tee_shot_result: "fairway" | "left" | "right" | null + approach_result: "hit" | "long" | "short" | "left" | "right" | null + chip_count: number | null + bunker_shot_count: number | null + penalty_strokes: number | null + first_putt_distance_bucket: "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+" | null + anyway_strokes: number | null + strokes_received: number | null +} + +const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) +const PUTT_BUCKETS = ["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] as const + +function avg(values: number[]): number | null { + if (values.length === 0) return null + return values.reduce((s, v) => s + v, 0) / values.length +} + +// --- Alle utledede tall for statistikk-skjermen, regnet ut ÉN gang fra +// rå hull-data. Ingenting her lagres -- alt beregnes ved lesing, samme +// prinsipp som "Så langt i runden" i round-detail.tsx. +function computeStats(holes: ApiHole[]) { + const played = holes.filter((h) => h.played && h.score !== null) + const diff = (h: ApiHole) => (h.score as number) - h.par + + const categories = [ + { label: "Eagle+", test: (d: number) => d <= -2, color: C.eagle }, + { label: "Birdie", test: (d: number) => d === -1, color: C.birdie }, + { label: "Par", test: (d: number) => d === 0, color: C.par }, + { label: "Bogey", test: (d: number) => d === 1, color: C.bogey }, + { label: "Dobbel bogey", test: (d: number) => d === 2, color: C.dobbel }, + { label: "Verre", test: (d: number) => d >= 3, color: C.verre }, + ].map((c) => { + const count = played.filter((h) => c.test(diff(h))).length + const percentage = played.length > 0 ? (count / played.length) * 100 : 0 + return { label: c.label, count, percentage, color: c.color } + }) + + const avgToPar = avg(played.map(diff)) + const byPar = (parValue: number) => avg(played.filter((h) => h.par === parValue).map(diff)) + + const fairwayTracked = holes.filter((h) => h.par !== 3 && h.tee_shot_result !== null) + const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway") + const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left") + const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right") + const fwTotal = fairwayTracked.length || 1 + const avgToParFairwayHit = avg(fairwayHit.filter((h) => h.played && h.score !== null).map(diff)) + const avgToParFairwayMiss = avg( + [...fairwayLeft, ...fairwayRight].filter((h) => h.played && h.score !== null).map(diff), + ) + + // GIR: samme formel som round-detail.tsx sin showGir -- krever kjent putt-tall. + const girEligible = played.filter((h) => h.putts !== null) + const isGir = (h: ApiHole) => (h.score as number) - (h.putts as number) <= h.par - 2 + const girHit = girEligible.filter(isGir) + const girPct = girEligible.length > 0 ? (girHit.length / girEligible.length) * 100 : null + const girByPar = (parValue: number) => { + const subset = girEligible.filter((h) => h.par === parValue) + return subset.length > 0 ? (subset.filter(isGir).length / subset.length) * 100 : null + } + const girWithFairway = (want: "hit" | "miss") => { + const subset = girEligible.filter((h) => + want === "hit" ? h.tee_shot_result === "fairway" : h.tee_shot_result === "left" || h.tee_shot_result === "right", + ) + return subset.length > 0 ? (subset.filter(isGir).length / subset.length) * 100 : null + } + const avgToParWithGir = avg(girEligible.filter(isGir).map(diff)) + const avgToParWithoutGir = avg(girEligible.filter((h) => !isGir(h)).map(diff)) + + const missedGreen = holes.filter((h) => h.approach_result !== null && h.approach_result !== "hit") + const missTotal = missedGreen.length || 1 + const missDir = (dir: "long" | "short" | "left" | "right") => + (missedGreen.filter((h) => h.approach_result === dir).length / missTotal) * 100 + + const puttsEligible = played.filter((h) => h.putts !== null) + const totalPutts = puttsEligible.reduce((s, h) => s + (h.putts as number), 0) + const puttCategories = [ + { label: "1-putt", test: (p: number) => p === 1, color: C.birdie }, + { label: "2-putt", test: (p: number) => p === 2, color: C.par }, + { label: "3-putt+", test: (p: number) => p >= 3, color: C.dobbel }, + ].map((c) => { + const count = puttsEligible.filter((h) => c.test(h.putts as number)).length + const percentage = puttsEligible.length > 0 ? (count / puttsEligible.length) * 100 : 0 + return { label: c.label, count, percentage, color: c.color } + }) + const puttsByPar = (parValue: number) => avg(puttsEligible.filter((h) => h.par === parValue).map((h) => h.putts as number)) + const avgPuttsWithGir = avg(puttsEligible.filter(isGir).map((h) => h.putts as number)) + const avgPuttsWithoutGir = avg(puttsEligible.filter((h) => h.putts !== null && !isGir(h)).map((h) => h.putts as number)) + + const onePuttPctByBucket = PUTT_BUCKETS.map((bucket) => { + const subset = puttsEligible.filter((h) => h.first_putt_distance_bucket === bucket) + return { label: bucket, percentage: subset.length > 0 ? (subset.filter((h) => h.putts === 1).length / subset.length) * 100 : 0 } + }) + const lengthByGir = (want: boolean) => + PUTT_BUCKETS.map((bucket) => ({ + label: bucket, + value: girEligible.filter((h) => h.first_putt_distance_bucket === bucket && isGir(h) === want).length, + })) + + const chipEligible = played.filter((h) => h.chip_count !== null) + const avgChipsPerHole = avg(chipEligible.map((h) => h.chip_count as number)) + const chipCategories = [ + { label: "0 chip", test: (c: number) => c === 0, color: C.birdie }, + { label: "1 chip", test: (c: number) => c === 1, color: C.bogey }, + { label: "2+ chip", test: (c: number) => c >= 2, color: C.verre }, + ].map((c) => { + const count = chipEligible.filter((h) => c.test(h.chip_count as number)).length + const percentage = chipEligible.length > 0 ? (count / chipEligible.length) * 100 : 0 + return { label: c.label, count, percentage, color: c.color } + }) + + const girMissed = girEligible.filter((h) => !isGir(h)) + const scramblingPct = girMissed.length > 0 ? (girMissed.filter((h) => diff(h) <= 0).length / girMissed.length) * 100 : null + const sandEligible = girMissed.filter((h) => h.bunker_shot_count !== null && h.bunker_shot_count >= 1) + const sandSavePct = sandEligible.length > 0 ? (sandEligible.filter((h) => diff(h) <= 0).length / sandEligible.length) * 100 : null + + const bunkerEligible = played.filter((h) => h.bunker_shot_count !== null) + const bunkerPerRound = bunkerEligible.reduce((s, h) => s + (h.bunker_shot_count as number), 0) + const penaltyEligible = played.filter((h) => h.penalty_strokes !== null) + const penaltyPerRound = penaltyEligible.reduce((s, h) => s + (h.penalty_strokes as number), 0) + const avgToParWithBunker = avg(bunkerEligible.filter((h) => (h.bunker_shot_count as number) >= 1).map(diff)) + const avgToParWithoutBunker = avg(bunkerEligible.filter((h) => (h.bunker_shot_count as number) === 0).map(diff)) + const pctHolesWithPenalty = + penaltyEligible.length > 0 + ? (penaltyEligible.filter((h) => (h.penalty_strokes as number) >= 1).length / penaltyEligible.length) * 100 + : null + + const anywayEligible = played.filter((h) => h.anyway_strokes !== null) + const anywayPerRound = + anywayEligible.length > 0 ? anywayEligible.reduce((s, h) => s + (h.anyway_strokes as number), 0) : null + + return { + playedCount: played.length, + totalStrokes: played.reduce((s, h) => s + (h.score as number), 0), + totalToPar: played.reduce((s, h) => s + diff(h), 0), + categories, + avgToPar, + byPar, + fairwayTracked, + fairwayHit, + fairwayLeft, + fairwayRight, + fwTotal, + avgToParFairwayHit, + avgToParFairwayMiss, + girEligible, + girHit, + girPct, + girByPar, + girWithFairway, + avgToParWithGir, + avgToParWithoutGir, + missedGreen, + missDir, + totalPutts, + puttCategories, + puttsByPar, + avgPuttsWithGir, + avgPuttsWithoutGir, + onePuttPctByBucket, + lengthByGir, + avgChipsPerHole, + chipCategories, + scramblingPct, + sandSavePct, + bunkerPerRound, + penaltyPerRound, + avgToParWithBunker, + avgToParWithoutBunker, + pctHolesWithPenalty, + anywayPerRound, + } +} + +function participantLabel(p: ApiParticipant): string { + return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest") +} + +// --- Screen ---------------------------------------------------------------- + +export function RoundStats({ roundId }: { roundId: string }) { + const [round, setRound] = useState(null) + const [participantId, setParticipantId] = useState(null) + const [holes, setHoles] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + const res = await fetch(`/rounds/${roundId}`, { credentials: "include" }) + if (res.status === 403 || res.status === 404) { + if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.") + return + } + if (!res.ok) { + if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.") + return + } + const data: ApiRound = await res.json() + if (cancelled) return + setRound(data) + setParticipantId(data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null) + } + void load() + return () => { + cancelled = true + } + }, [roundId]) + + useEffect(() => { + if (!participantId) return + let cancelled = false + async function loadHoles() { + const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" }) + if (!res.ok) return + const data: ApiHole[] = await res.json() + if (!cancelled) setHoles(data) + } + void loadHoles() + return () => { + cancelled = true + } + }, [roundId, participantId]) + + if (error) { + return ( +
+

{error}

+ + Tilbake til egne runder + +
+ ) + } + + if (!round || !holes) { + return ( +
+ + ) + } + + const s = computeStats(holes) + const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1) + + return ( +
+ {/* Header */} +
+
+ +
+
+ +
+ {/* Round summary strip */} +
+
+ + {round.course_name_snapshot} + + + {round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))} + +
+
+ {s.totalStrokes || "–"} + {s.playedCount > 0 && ( + {signed(s.totalToPar)} til par + )} +
+
+ + {/* Selve hull-for-hull-scorekortet flyttet til en egen, dedikert + horisontal side 2026-07-25 (erstatter en tidligere vertikal + tabell her på statistikk-siden -- se ARCHITECTURE_DECISIONS.md + ADR-033) -- denne siden forblir aggregert/utledet statistikk. */} + + Se scorekort +
+
+ ) +} + +// --- Compass cell ---------------------------------------------------------- + +function CompassCell({ label, percentage }: { label: string; percentage: number }) { + return ( +
+ {pct(percentage)} + {label} +
+ ) +} diff --git a/screen-20260724-133013-1784892586987.mp4 b/screen-20260724-133013-1784892586987.mp4 new file mode 100644 index 0000000..0ad6721 Binary files /dev/null and b/screen-20260724-133013-1784892586987.mp4 differ diff --git a/screen-20260724-152328-1784899395856.mp4 b/screen-20260724-152328-1784899395856.mp4 new file mode 100644 index 0000000..c9706ff Binary files /dev/null and b/screen-20260724-152328-1784899395856.mp4 differ