Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.

Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.

Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
This commit is contained in:
Erol Haagenrud 2026-07-24 23:33:41 +02:00
parent f5d1491ceb
commit bba63b6cc9
15 changed files with 2899 additions and 216 deletions

View file

@ -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(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 -c \"import ast; ast.parse\\(open\\('/opt/teecup/app/routers/rounds.py'\\).read\\(\\)\\)\")",
"Bash(python3 test_round_edit_delete.py)", "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": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -2157,6 +2157,430 @@ migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new` begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new`
→ 200, `teeoff.no` upåvirket. → 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 && <ScoreSoFar .../>}`) -- 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 `<table>` med `<colgroup>` (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,** **Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå — som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i

225
CLAUDE.md
View file

@ -2314,6 +2314,231 @@ Ferdig og verifisert:
avstand mot alle 174 teeoff-anlegg, geolokasjon i nettleseren, feiler avstand mot alle 174 teeoff-anlegg, geolokasjon i nettleseren, feiler
stille hvis avslått). 14 nye scratch-sjekker inkl. et ekte nearby-kall stille hvis avslått). 14 nye scratch-sjekker inkl. et ekte nearby-kall
mot teeoff. Full detalj i ADR-033. 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 `<table>` med
`<colgroup>` 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: Neste steg:
1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første 1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første

View file

@ -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 **Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema
(`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner), (`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 fremtidig ambisjon som statistikk-modellen (Beslutning B) og
banedata-modellen (Beslutning C) bør ha i bakhodet, siden begge kan banedata-modellen (Beslutning C) bør ha i bakhodet, siden begge kan
trenge en utvidelse den dagen dette faktisk designes. 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 **Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor). arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).

View file

@ -427,6 +427,14 @@ class RoundOut(BaseModel):
started_at: str | None started_at: str | None
completed_at: str | None completed_at: str | None
participants: list[RoundParticipantOut] 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: 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, 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( return RoundOut(
id=round_row["id"], id=round_row["id"],
course_source=round_row["course_source"], 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, 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, completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], 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 penalty_strokes: int | None
first_putt_distance_bucket: str | None first_putt_distance_bucket: str | None
anyway_strokes: int | 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( @router.get(
@ -899,11 +931,11 @@ async def list_holes(
) -> list[RoundHoleOut]: ) -> list[RoundHoleOut]:
async with plain_connection() as conn: async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id) await _get_owned_round_or_404(conn, round_id, user.user_id)
owner_check = await conn.fetchval( participant_row = await conn.fetchrow(
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id, 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.") raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
rows = await conn.fetch( rows = await conn.fetch(
""" """
@ -914,7 +946,21 @@ async def list_holes(
""", """,
participant_id, 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): class HoleUpdate(BaseModel):
@ -944,11 +990,11 @@ async def update_hole(
) -> RoundHoleOut: ) -> RoundHoleOut:
async with plain_connection() as conn: async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id) await _get_owned_round_or_404(conn, round_id, user.user_id)
owner_check = await conn.fetchval( participant_row = await conn.fetchrow(
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id, 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.") raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
async with translate_db_errors(): async with translate_db_errors():
@ -972,7 +1018,24 @@ async def update_hole(
) )
if row is None: if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -14,6 +14,7 @@
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-6: var(--chart-6);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
@ -70,11 +71,14 @@
--ring: oklch(0.7512 0.1613 130.33); --ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53); --brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.99 0 0); --brand-orange-foreground: oklch(0.99 0 0);
--chart-1: oklch(0.87 0 0); /* Statistikk-fargeskala (rundestatistikk, ADR-033) -- brukt sammen med
--chart-2: oklch(0.556 0 0); tekst-/tall-etiketter, aldri farge alene som eneste signal. */
--chart-3: oklch(0.439 0 0); --chart-1: oklch(0.55 0.15 160);
--chart-4: oklch(0.371 0 0); --chart-2: oklch(0.72 0.17 140);
--chart-5: oklch(0.269 0 0); --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; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
@ -108,11 +112,13 @@
--ring: oklch(0.7512 0.1613 130.33); --ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53); --brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.16 0.02 40); --brand-orange-foreground: oklch(0.16 0.02 40);
--chart-1: oklch(0.87 0 0); /* Statistikk-fargeskala, løftet for kontrast på mørk bakgrunn. */
--chart-2: oklch(0.556 0 0); --chart-1: oklch(0.7 0.16 160);
--chart-3: oklch(0.439 0 0); --chart-2: oklch(0.79 0.17 140);
--chart-4: oklch(0.371 0 0); --chart-3: oklch(0.75 0.13 210);
--chart-5: oklch(0.269 0 0); --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: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
@ -146,11 +152,12 @@
--ring: oklch(0.7512 0.1613 130.33); --ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53); --brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.16 0.02 40); --brand-orange-foreground: oklch(0.16 0.02 40);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.7 0.16 160);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.79 0.17 140);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.75 0.13 210);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.83 0.14 85);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.73 0.19 45);
--chart-6: oklch(0.66 0.21 25);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);

View file

@ -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 <RoundScorecard roundId={id} />
}

View file

@ -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 <RoundStats roundId={id} />
}

View file

@ -17,6 +17,8 @@ type ApiRoundParticipant = {
id: string id: string
is_owner: boolean is_owner: boolean
guest_name: string | null guest_name: string | null
counts_for_handicap: boolean
score_differential: number | null
} }
type ApiRound = { type ApiRound = {
@ -27,9 +29,14 @@ type ApiRound = {
holes_planned: number holes_planned: number
completed_at: string | null completed_at: string | null
participants: ApiRoundParticipant[] participants: ApiRoundParticipant[]
owner_holes_played: number
owner_total_score: number | null
owner_score_to_par: number | null
} }
function toRound(r: ApiRound): Round { 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 { return {
id: r.id, id: r.id,
courseName: r.course_name_snapshot, courseName: r.course_name_snapshot,
@ -38,6 +45,10 @@ function toRound(r: ApiRound): Round {
holes: r.holes_planned === 9 ? 9 : 18, holes: r.holes_planned === 9 ? 9 : 18,
date: r.played_at, date: r.played_at,
playerCount: r.participants.length, playerCount: r.participants.length,
holesPlayed: r.owner_holes_played,
totalScore: r.owner_total_score ?? undefined,
toPar: r.owner_score_to_par ?? undefined,
differential,
} }
} }

View file

@ -1,5 +1,5 @@
import Link from "next/link" 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" import { cn } from "@/lib/utils"
export type RoundStatus = "active" | "completed" export type RoundStatus = "active" | "completed"
@ -12,6 +12,13 @@ export type Round = {
holes: 9 | 18 holes: 9 | 18
date: string date: string
playerCount: number 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<RoundStatus, { label: string; dot: string; badge: string }> = { const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = {
@ -39,6 +46,17 @@ function formatDate(value: string) {
return dateFormatter.format(parsed) 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 }) { function RoundStatusBadge({ status }: { status: RoundStatus }) {
const config = STATUS_CONFIG[status] const config = STATUS_CONFIG[status]
return ( 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 (
<div className="flex w-24 shrink-0 flex-col items-center gap-1.5 rounded-2xl bg-secondary px-3 py-3 sm:w-28">
<span className="text-2xl font-extrabold leading-none tabular-nums text-foreground">
{played}
<span className="text-base font-bold text-muted-foreground">/{round.holes}</span>
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
hull spilt
</span>
<div
className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-border"
role="progressbar"
aria-valuenow={played}
aria-valuemin={0}
aria-valuemax={round.holes}
aria-label={`${played} av ${round.holes} hull spilt`}
>
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
</div>
)
}
const toPar = round.toPar as number
const overPar = toPar > 0
const underPar = toPar < 0
return (
<div className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-2xl bg-secondary px-3 py-3 sm:w-28">
<span className="text-3xl font-extrabold leading-none tabular-nums text-foreground">
{round.totalScore}
</span>
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-base font-bold tabular-nums",
underPar && "bg-primary/20 text-foreground",
overPar && "bg-brand-orange/20 text-foreground",
!underPar && !overPar && "bg-muted text-foreground",
)}
>
{formatToPar(toPar)}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
til par
</span>
</div>
)
}
export function RoundCard({ round }: { round: Round }) { export function RoundCard({ round }: { round: Round }) {
const playerLabel = round.playerCount === 1 ? "spiller" : "spillere" 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 ( return (
<Link <Link
href={`/my-rounds/${round.id}`} href={`/my-rounds/${round.id}`}
className="group flex min-h-[88px] w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" aria-label={ariaLabel}
className="group flex min-h-[88px] w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:p-5"
> >
<div className="flex min-w-0 flex-1 flex-col gap-3"> <div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
@ -88,9 +180,17 @@ export function RoundCard({ round }: { round: Round }) {
{round.playerCount} {playerLabel} {round.playerCount} {playerLabel}
</span> </span>
</span> </span>
{differentialText && (
<span className="flex items-center gap-1.5">
<Gauge aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums">Diff {differentialText}</span>
</span>
)}
</div> </div>
</div> </div>
<ScoreTile round={round} />
<ChevronRight <ChevronRight
aria-hidden="true" aria-hidden="true"
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"

View file

@ -9,7 +9,7 @@
// aldri kun det ene feltet som ble endret. // aldri kun det ene feltet som ble endret.
import type React from "react" import type React from "react"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { import {
@ -19,6 +19,7 @@ import {
ArrowUp, ArrowUp,
CalendarDays, CalendarDays,
Check, Check,
ChevronDown,
Clock, Clock,
Trash2, Trash2,
ChevronLeft, ChevronLeft,
@ -174,6 +175,7 @@ type ApiHole = {
penalty_strokes: number | null penalty_strokes: number | null
first_putt_distance_bucket: PuttBucket | null first_putt_distance_bucket: PuttBucket | null
anyway_strokes: number | null anyway_strokes: number | null
strokes_received: number | null
} }
function apiHoleToStat(h: ApiHole): HoleStat { function apiHoleToStat(h: ApiHole): HoleStat {
@ -229,15 +231,23 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på // lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
// hull 1 uansett faktisk starthull). // hull 1 uansett faktisk starthull).
const [currentHole, setCurrentHole] = useState<number | null>(null) const [currentHole, setCurrentHole] = useState<number | null>(null)
// "score" (slag/putter) eller "stats" (kølle/retning/chip/bunker/
// straffeslag/putt-avstand/anywayslag) -- kun relevant når
// statLevel==="full", ellers vises "score"-innholdet direkte uten
// faneraden (se PanelTabs-bruken lenger ned).
const [panelTab, setPanelTab] = useState<"score" | "stats">("score")
const [showAddGuest, setShowAddGuest] = useState(false) const [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false) const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false) const [showEditRound, setShowEditRound] = useState(false)
// "Flere detaljer" er kollapset som default (etterspurt av bruker
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
// stablet oppå hverandre for et enkelt slag-registrering) slik at en
// rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt.
const [detailsOpen, setDetailsOpen] = useState(false)
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
// panelet) er utenfor skjermen (rapportert av bruker 2026-07-25).
const holePanelRef = useRef<HTMLElement>(null)
function scrollToHolePanel() {
holePanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
}
const loadRound = useCallback(async () => { const loadRound = useCallback(async () => {
try { try {
@ -357,12 +367,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
function goPrev() { function goPrev() {
const i = holeOrder.indexOf(activeHole) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length]) setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
setPanelTab("score") scrollToHolePanel()
} }
function goNext() { function goNext() {
const i = holeOrder.indexOf(activeHole) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % holeOrder.length]) setCurrentHole(holeOrder[(i + 1) % holeOrder.length])
setPanelTab("score") scrollToHolePanel()
} }
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) { async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
@ -525,18 +535,50 @@ export function RoundDetail({ roundId }: { roundId: string }) {
</p> </p>
)} )}
<div className="mb-5"> {/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme
{showEditRound ? ( nøytrale trigger-stil som hverandre) -- disse tidligere som
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} /> store, fremtredende knapper RETT under "Neste hull"-navigasjonen
) : ( nederst i hull-panelet, som gjorde det altfor lett å trykke feil
ved et uhell mens man bare skulle bla mellom hull (rapportert av
bruker 2026-07-25). Ved å flytte dem hit man aktivt scrolle OPP
forbi hull-registreringen for å dem i det hele tatt. */}
<div className="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={() => setShowEditRound(true)} onClick={() => setShowEditRound((v) => !v)}
aria-expanded={showEditRound}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
> >
<Settings2 aria-hidden="true" className="size-4" /> <Settings2 aria-hidden="true" className="size-4" />
Rediger runde Rediger runde
</button> </button>
{!completed && (
<button
type="button"
disabled={completing}
onClick={finishRound}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
>
<Check aria-hidden="true" className="size-4" />
{completing ? "Fullfører…" : "Fullfør runde"}
</button>
)}
<button
type="button"
disabled={deleting}
onClick={deleteRound}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
>
<Trash2 aria-hidden="true" className="size-4" />
{deleting ? "Sletter…" : "Slett runde"}
</button>
</div>
{showEditRound && (
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
)} )}
</div> </div>
@ -545,6 +587,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<CompletedBanner <CompletedBanner
players={players} players={players}
duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null} duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
roundId={roundId}
/> />
)} )}
@ -566,6 +609,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
/> />
)} )}
{/* Skjules når runden er fullført (2026-07-25, rapportert av bruker
som "veldig mye dobbel informasjon") -- CompletedBanner sin
"Se full rundestatistikk"-lenke dekker akkurat det samme,
langt grundigere. Fortsatt nyttig som live fremdriftsoversikt
mens runden pågår. */}
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
{showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />} {showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />}
{/* Hole navigation */} {/* Hole navigation */}
@ -581,144 +631,184 @@ export function RoundDetail({ roundId }: { roundId: string }) {
isPlayed={holeIsPlayed} isPlayed={holeIsPlayed}
onSelect={(n) => { onSelect={(n) => {
setCurrentHole(n) setCurrentHole(n)
setPanelTab("score") scrollToHolePanel()
}} }}
/> />
{/* Current hole panel */} {/* Current hole panel */}
{hole && ( {hole && (
<section className="mt-5 flex flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section
ref={holePanelRef}
className="mt-5 flex scroll-mt-28 flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
>
{/* Høyre kolonne reserverer plass ved siden av hull-headeren
til en fremtidig avstandsmåling-indikator (ikke bygget
ennå, men avklart 2026-07-25 at det kommer) -- GIR-merket
ligger under den reserverte plassen slik at et senere
avstand-chip her ikke krever noen layout-endring. */}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance"> <h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index} Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}
</h2> </h2>
{showGir && ( <div className="flex shrink-0 flex-col items-end gap-2">
<span className="shrink-0 rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground"> {showGir && (
GIR <span className="rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
</span> GIR
)} </span>
</div>
{activePlayer.statLevel === "full" && <PanelTabs value={panelTab} onChange={setPanelTab} />}
{(activePlayer.statLevel !== "full" || panelTab === "score") ? (
<div className="flex flex-col gap-6">
<NumberPicker
label="Slag"
value={currentStat.strokes}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+"
parValue={hole.par}
onChange={(v) => updateStat({ strokes: v, played: true })}
readOnly={readOnly}
/>
{activePlayer.statLevel !== "strokes_only" && (
<NumberPicker
label="Putter"
value={currentStat.putts}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ putts: v })}
readOnly={readOnly}
/>
)} )}
</div> </div>
) : ( </div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = currentStat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => updateStat({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
id="club"
value={currentStat.club}
onChange={(e) => updateStat({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && ( {/* Slag/Putter og (ved statLevel="full") alle detaljene vises
<DirectionCross ALLTID samlet under hverandre -- ikke bak en fane man
label="Utslag" oppdage og trykke (funnet 2026-07-25: brukeren huket
variant="horizontal" av "All statistikk", men fikk aldri se noe utover
centerLabel="Fairway" slag/putter siden det bak en fane). */}
value={currentStat.teeShot} <div className="flex flex-col gap-6">
onChange={(v) => updateStat({ teeShot: v as TeeShot })} <NumberPicker
readOnly={readOnly} label="Slag"
/> value={currentStat.strokes}
)} directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
<DirectionCross expandLabel="10+"
label="Innspill" parValue={hole.par}
variant="full" onChange={(v) => updateStat({ strokes: v, played: true })}
centerLabel="Traff" readOnly={readOnly}
value={currentStat.approach} />
onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
</div>
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={currentStat.firstPuttBucket}
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
{activePlayer.statLevel !== "strokes_only" && (
<NumberPicker <NumberPicker
label="Anywayslag" label="Putter"
value={currentStat.anywayStrokes} value={currentStat.putts}
directValues={[0, 1, 2, 3, 4, 5, 6]} directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]} expandValues={[7, 8, 9, 10]}
expandLabel="7+" expandLabel="7+"
maxValue={currentStat.strokes ?? undefined} maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ anywayStrokes: v })} onChange={(v) => updateStat({ putts: v })}
readOnly={readOnly} readOnly={readOnly}
/> />
</div> )}
)}
{activePlayer.statLevel === "full" && (
<>
{/* Rett under antall putter (etterspurt av bruker 2026-07-25) --
hører naturlig sammen med Putter-feltet rett over, ikke med
kølle/retning/chip-gruppen lenger ned. */}
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={currentStat.firstPuttBucket}
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
{/* Alt annet lever bak én kollapsbar seksjon, lukket som
default -- panelet forblir kort for det vanlige
slag-/putt-registreringstilfellet. */}
<div className="overflow-hidden rounded-2xl border border-border">
<button
type="button"
onClick={() => setDetailsOpen((v) => !v)}
aria-expanded={detailsOpen}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Flere detaljer
<ChevronDown
aria-hidden="true"
className={cn(
"size-5 shrink-0 text-muted-foreground transition-transform",
detailsOpen && "rotate-180",
)}
/>
</button>
{detailsOpen && (
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = currentStat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => updateStat({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
id="club"
value={currentStat.club}
onChange={(e) => updateStat({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={currentStat.teeShot}
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
value={currentStat.approach}
onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
</div>
<NumberPicker
label="Anywayslag"
value={currentStat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ anywayStrokes: v })}
readOnly={readOnly}
/>
</div>
)}
</div>
</>
)}
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold"> <Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
@ -735,23 +825,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
</> </>
)} )}
{!completed && (
<Button type="button" disabled={completing} onClick={finishRound} className="mt-6 h-14 w-full rounded-2xl text-base font-bold shadow-sm">
<Check aria-hidden="true" className="size-5" />
{completing ? "Fullfører…" : "Fullfør runde"}
</Button>
)}
<Button
type="button"
variant="ghost"
disabled={deleting}
onClick={deleteRound}
className="mt-3 h-12 w-full rounded-2xl text-base font-semibold text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-5" />
{deleting ? "Sletter…" : "Slett runde"}
</Button>
</main> </main>
</div> </div>
) )
@ -759,7 +832,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// --- Completed banner ------------------------------------------------------ // --- Completed banner ------------------------------------------------------
function CompletedBanner({ players, duration }: { players: Player[]; duration: string | null }) { function CompletedBanner({
players,
duration,
roundId,
}: {
players: Player[]
duration: string | null
roundId: string
}) {
return ( return (
<div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6"> <div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -800,6 +881,286 @@ function CompletedBanner({ players, duration }: { players: Player[]; duration: s
) )
})} })}
</ul> </ul>
<div className="flex flex-col gap-2 sm:flex-row">
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
<Link
href={`/my-rounds/${roundId}/stats`}
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Se full rundestatistikk
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</div>
</div>
)
}
// --- Score så langt (kompakt linje + utvidbar full oversikt) --------------
// Etterspurt av bruker 2026-07-25 med referanse til en annen golf-app --
// samme "Vis full oversikt"-mønster som allerede etablert i
// session-scorecard.tsx (turnering-scoring), tilpasset til en enkelt
// spillers frittstående runde (ingen motstander-side å vise her).
//
// Utvidet SAMME DAG, etterspurt av bruker: netto-/stableford-sum, putt-/
// kølle-statistikk-totaler, og grafisk fairway-/innspill-fordeling +
// gjennomsnittlig score-til-par splittet på treff/bom. Appen har ingen
// egen "spilleform"-innstilling for frittstående runder -- stableford
// beregnes derfor alltid ut fra netto score der det er mulig (krever
// registrert HCP), uavhengig av om brukeren "egentlig" spiller slagspill
// eller stableford -- rent informativt, ikke en offisiell poengsum.
// Standard stableford-poengtabell: netto dobbel bogey eller dårligere = 0,
// netto par = 2, hvert slag bedre/dårligere enn par gir ett poeng mer/mindre.
function stablefordPoints(netScore: number, par: number): number {
return Math.max(0, 2 - (netScore - par))
}
function avgScoreToPar(holes: ApiHole[]): number | null {
const played = holes.filter((h) => h.played && h.score !== null)
if (played.length === 0) return null
const diffSum = played.reduce((sum, h) => sum + ((h.score as number) - h.par), 0)
return diffSum / played.length
}
function formatSignedAvg(n: number): string {
const fixed = Math.abs(n).toFixed(2)
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
}
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
const [expanded, setExpanded] = useState(false)
if (!holes || holes.length === 0) return null
const orderedHoles = order
.map((n) => holes.find((h) => h.hole_number === n))
.filter((h): h is ApiHole => h !== undefined)
const playedSoFar = orderedHoles.filter((h) => h.played && h.score !== null)
if (playedSoFar.length === 0) return null
const totalStrokes = playedSoFar.reduce((sum, h) => sum + (h.score ?? 0), 0)
const totalPar = playedSoFar.reduce((sum, h) => sum + h.par, 0)
const toPar = totalStrokes - totalPar
const toParLabel = toPar === 0 ? "Par" : toPar > 0 ? `+${toPar}` : `${toPar}`
// Netto/stableford krever at deltakeren har en beregnet course handicap
// (strokes_received er da satt på ALLE hull, ellers på ingen) -- uten
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
const hasNetto = withNetto.length > 0
const nettoTotal = hasNetto
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
: null
const stablefordTotal = hasNetto
? withNetto.reduce(
(sum, h) => sum + stablefordPoints((h.score as number) - (h.strokes_received as number), h.par),
0,
)
: null
const puttsPlayed = orderedHoles.filter((h) => h.putts !== null)
const puttsTotal = puttsPlayed.length > 0 ? puttsPlayed.reduce((sum, h) => sum + (h.putts as number), 0) : null
const chipPlayed = orderedHoles.filter((h) => h.chip_count !== null)
const chipTotal = chipPlayed.length > 0 ? chipPlayed.reduce((sum, h) => sum + (h.chip_count as number), 0) : null
const bunkerPlayed = orderedHoles.filter((h) => h.bunker_shot_count !== null)
const bunkerTotal =
bunkerPlayed.length > 0 ? bunkerPlayed.reduce((sum, h) => sum + (h.bunker_shot_count as number), 0) : null
const penaltyPlayed = orderedHoles.filter((h) => h.penalty_strokes !== null)
const penaltyTotal =
penaltyPlayed.length > 0 ? penaltyPlayed.reduce((sum, h) => sum + (h.penalty_strokes as number), 0) : null
const anywayPlayed = orderedHoles.filter((h) => h.anyway_strokes !== null)
const anywayTotal =
anywayPlayed.length > 0 ? anywayPlayed.reduce((sum, h) => sum + (h.anyway_strokes as number), 0) : null
// Fairwaytreff spores kun på hull som ikke er par 3 (samme regel som
// registrerings-skjemaet -- DirectionCross for Utslag vises ikke der).
const fairwayTracked = orderedHoles.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 fairwayMiss = [...fairwayLeft, ...fairwayRight]
const avgToParFairwayHit = avgScoreToPar(fairwayHit)
const avgToParFairwayMiss = avgScoreToPar(fairwayMiss)
const approachTracked = orderedHoles.filter((h) => h.approach_result !== null)
const approachHit = approachTracked.filter((h) => h.approach_result === "hit")
const approachShort = approachTracked.filter((h) => h.approach_result === "short")
const approachLong = approachTracked.filter((h) => h.approach_result === "long")
const approachLeft = approachTracked.filter((h) => h.approach_result === "left")
const approachRight = approachTracked.filter((h) => h.approach_result === "right")
const approachMiss = [...approachShort, ...approachLong, ...approachLeft, ...approachRight]
const avgToParApproachHit = avgScoreToPar(approachHit)
const avgToParApproachMiss = avgScoreToPar(approachMiss)
let running = 0
return (
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
>
<span className="text-sm font-semibold text-foreground text-pretty">
langt: {playedSoFar.length} hull · {totalStrokes} slag · {toParLabel} til par
{hasNetto && ` · Netto ${nettoTotal! > 0 ? "+" : ""}${nettoTotal}`}
</span>
<span className="shrink-0 text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis full oversikt"}</span>
</button>
{expanded && (
<div className="border-t border-border">
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
<StatPill label="Slag" value={String(totalStrokes)} />
<StatPill label="Til par" value={toParLabel} />
{hasNetto && <StatPill label="Netto" value={nettoTotal! > 0 ? `+${nettoTotal}` : String(nettoTotal)} />}
{hasNetto && <StatPill label="Stableford" value={`${stablefordTotal} poeng`} />}
{puttsTotal !== null && <StatPill label="Putt" value={String(puttsTotal)} />}
{chipTotal !== null && <StatPill label="Chip" value={String(chipTotal)} />}
{bunkerTotal !== null && <StatPill label="Bunker" value={String(bunkerTotal)} />}
{penaltyTotal !== null && <StatPill label="Straffeslag" value={String(penaltyTotal)} />}
{anywayTotal !== null && <StatPill label="Anywayslag" value={String(anywayTotal)} />}
</div>
<div className="overflow-x-auto border-t border-border">
<table className="w-full min-w-[420px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs font-bold uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2">Hull</th>
<th className="px-3 py-2">Par</th>
<th className="px-3 py-2">Score</th>
<th className="px-3 py-2">Netto</th>
{hasNetto && <th className="px-3 py-2">Stableford</th>}
<th className="px-3 py-2">Sum</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={h.hole_number} className="border-b border-border last:border-b-0">
<td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td>
<td className="px-3 py-2 tabular-nums font-semibold text-foreground">{played ? h.score : ""}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? ""}</td>
{hasNetto && (
<td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? ""}</td>
)}
<td className="px-3 py-2 tabular-nums font-bold text-foreground">{played ? running : ""}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{fairwayTracked.length > 0 && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<span className="text-sm font-bold text-foreground">Fairwaytreff</span>
<DistributionBar
segments={[
{ label: "Fairway", count: fairwayHit.length, colorClass: "bg-emerald-500" },
{ label: "Venstre", count: fairwayLeft.length, colorClass: "bg-amber-500" },
{ label: "Høyre", count: fairwayRight.length, colorClass: "bg-rose-500" },
]}
/>
{(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
{avgToParFairwayHit !== null && (
<span>
Til par ved fairwaytreff:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayHit)}</strong>
</span>
)}
{avgToParFairwayMiss !== null && (
<span>
Til par ved bom fairway:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayMiss)}</strong>
</span>
)}
</div>
)}
</div>
)}
{approachTracked.length > 0 && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<span className="text-sm font-bold text-foreground">Innspill</span>
<DistributionBar
segments={[
{ label: "Traff", count: approachHit.length, colorClass: "bg-emerald-500" },
{ label: "Kort", count: approachShort.length, colorClass: "bg-amber-500" },
{ label: "Langt", count: approachLong.length, colorClass: "bg-orange-500" },
{ label: "Venstre", count: approachLeft.length, colorClass: "bg-sky-500" },
{ label: "Høyre", count: approachRight.length, colorClass: "bg-rose-500" },
]}
/>
{(avgToParApproachHit !== null || avgToParApproachMiss !== null) && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
{avgToParApproachHit !== null && (
<span>
Til par ved treff innspill:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParApproachHit)}</strong>
</span>
)}
{avgToParApproachMiss !== null && (
<span>
Til par ved bom innspill:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParApproachMiss)}</strong>
</span>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
function StatPill({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col items-center justify-center gap-0.5 rounded-xl border border-border bg-background px-2 py-2.5 text-center">
<span className="text-lg font-extrabold tabular-nums text-foreground">{value}</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
</div>
)
}
// 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 (
<div className="flex flex-col gap-2">
<div className="flex h-5 w-full overflow-hidden rounded-full border border-border">
{segments
.filter((s) => s.count > 0)
.map((s) => (
<div key={s.label} aria-hidden="true" style={{ width: `${(s.count / total) * 100}%` }} className={s.colorClass} />
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{segments
.filter((s) => s.count > 0)
.map((s) => (
<span key={s.label} className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<span aria-hidden="true" className={cn("inline-block size-2.5 rounded-full", s.colorClass)} />
{s.label} {Math.round((s.count / total) * 100)}% ({s.count})
</span>
))}
</div>
</div> </div>
) )
} }
@ -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 (
<div
role="tablist"
aria-label="Velg visning"
className="grid grid-cols-2 gap-1.5 rounded-2xl border border-border bg-muted/50 p-1.5"
>
{tabs.map((tab) => {
const active = value === tab.value
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(tab.value)}
className={cn(
"min-h-12 rounded-xl text-base font-bold transition-colors",
active ? "bg-primary text-primary-foreground shadow-sm" : "text-foreground hover:bg-accent/60",
)}
>
{tab.label}
</button>
)
})}
</div>
)
}
// --- Retningskors (D-pad for Utslag / Innspill) ----------------------------- // --- Retningskors (D-pad for Utslag / Innspill) -----------------------------
function DirButton({ function DirButton({

View file

@ -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 <span className={cn(shared, "text-muted-foreground")}>{""}</span>
}
const kind = classify(gross, par)
if (kind === "eagle") {
return (
<span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{gross}</span>
)
}
if (kind === "birdie") {
return (
<span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>
{gross}
</span>
)
}
if (kind === "bogey") {
return (
<span
className={cn(
shared,
"rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{gross}
</span>
)
}
if (kind === "double") {
return (
<span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>
{gross}
</span>
)
}
// par
return <span className={cn(shared, "font-bold text-foreground")}>{gross}</span>
}
// --- 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 (
<table className="w-full table-fixed border-collapse">
<caption className="sr-only">
Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number}
</caption>
<colgroup>
<col className="w-9" />
{holes.map((h) => (
<col key={h.hole_number} />
))}
<col className="w-[11%]" />
</colgroup>
<thead>
<tr>
<th scope="col" className="border border-border bg-muted px-1 py-1 text-left">
<span className="sr-only">Rad</span>
<span aria-hidden="true" className="text-xs font-extrabold text-muted-foreground">
Hull
</span>
</th>
{holes.map((h) => (
<th
key={h.hole_number}
scope="col"
className="border border-border bg-foreground py-1 text-center text-xs font-extrabold tabular-nums text-background"
>
{h.hole_number}
</th>
))}
<th
scope="col"
className="border border-border bg-primary py-1 text-center text-xs font-extrabold uppercase text-primary-foreground"
>
{summaryLabel}
</th>
</tr>
</thead>
<tbody>
{/* Hcp (stroke index) */}
<tr>
<th scope="row" className={rowLabel}>
Hcp
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.stroke_index}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-muted-foreground")} aria-hidden="true">
{""}
</td>
</tr>
{/* Par */}
<tr>
<th scope="row" className={rowLabel}>
Par
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{h.par}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{parSum}</td>
</tr>
{/* Score — the hero row: highlighted band + shape marks */}
<tr className="bg-primary/5">
<th scope="row" className={cn(rowLabel, "text-sm text-foreground")}>
Score
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "px-0.5 py-1")}>
<span className="flex items-center justify-center">
<ScoreMark gross={h.played ? h.score : null} par={h.par} />
</span>
</td>
))}
<td className={cn(sumCell, "py-1 text-base text-foreground")}>{grossSum ?? ""}</td>
</tr>
{/* Netto */}
<tr>
<th scope="row" className={cn(rowLabel, "text-foreground")}>
Netto
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-bold text-foreground")}>
{netScore(h) ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{nettoSum ?? ""}</td>
</tr>
{/* Stableford (only when relevant) */}
{showStableford && (
<tr>
<th scope="row" className={rowLabel} title="Stableford-poeng">
Poeng
</th>
{holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
{stablefordPoints(h) ?? ""}
</td>
))}
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{pointsSum ?? ""}</td>
</tr>
)}
</tbody>
</table>
)
}
// --- Legend ----------------------------------------------------------------
function Legend() {
return (
<div
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-border bg-card px-4 py-3"
aria-label="Symbolforklaring"
>
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full border-2 border-primary bg-primary/10 text-xs font-extrabold text-primary">
3
</span>
<span className="text-sm font-semibold text-foreground">Under par (sirkel)</span>
</span>
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-xs font-extrabold text-brand-orange">
5
</span>
<span className="text-sm font-semibold text-foreground">Over par (firkant)</span>
</span>
<span className="text-sm font-medium text-muted-foreground">Fylt symbol = 2 slag eller mer</span>
</div>
)
}
// --- Combined total --------------------------------------------------------
function TotalTile({ label, value, tone }: { label: string; value: string; tone?: "orange" | "green" }) {
return (
<div className="flex flex-col items-center gap-0.5 rounded-2xl border border-border bg-card px-2 py-3">
<span
className={cn(
"text-2xl font-extrabold tabular-nums",
tone === "orange" ? "text-brand-orange" : tone === "green" ? "text-primary" : "text-foreground",
)}
>
{value}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
</div>
)
}
// --- Screen ----------------------------------------------------------------
export function RoundScorecard({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [participantId, setParticipantId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
</Link>
</div>
)
}
if (!round || !holes) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
// 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 (
<div className="min-h-dvh bg-background">
{/* Header */}
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Scorekort
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip */}
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "}
{dateFormatter.format(new Date(round.played_at))}
</span>
</div>
{totalGross !== null && (
<div className="flex flex-col items-end">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{totalGross}</span>
<span className="text-sm font-bold text-brand-orange">{signedToPar(totalGross, totalPar)} til par</span>
</div>
)}
</div>
{/* Spillervelger, kun når runden har flere deltakere */}
{round.participants.length > 1 && (
<div role="tablist" aria-label="Velg spiller" className="-mx-4 overflow-x-auto px-4">
<div className="flex gap-2">
{round.participants.map((p) => {
const active = p.id === participantId
return (
<button
key={p.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setHoles(null)
setParticipantId(p.id)
}}
className={cn(
"flex min-h-11 shrink-0 items-center rounded-2xl border px-4 text-base font-bold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{participantLabel(p)}
</button>
)
})}
</div>
</div>
)}
{/* Legend */}
<Legend />
{/* Scorecard blocks */}
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4">
{front.length > 0 && (
<ScoreBlock holes={front} summaryLabel={back.length === 0 ? "Tot" : "Ut"} showStableford={showStableford} />
)}
{back.length > 0 && <ScoreBlock holes={back} summaryLabel="Inn" showStableford={showStableford} />}
</div>
{/* Combined total */}
<div className={cn("grid gap-2", showStableford ? "grid-cols-4" : "grid-cols-3")}>
<TotalTile label="Par" value={String(totalPar)} />
<TotalTile label="Score" value={totalGross !== null ? String(totalGross) : ""} />
<TotalTile
label="Til par"
value={totalGross !== null ? signedToPar(totalGross, totalPar) : ""}
tone="orange"
/>
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : ""} tone="green" />}
</div>
</main>
</div>
)
}

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.