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:
parent
f5d1491ceb
commit
bba63b6cc9
15 changed files with 2899 additions and 216 deletions
|
|
@ -379,7 +379,9 @@
|
|||
"Bash(grep -n \"class RoundCreate\\\\|class RoundOut\\\\|played_at\\\\|start_hole: int$\\\\|started_at\" /opt/teecup/app/routers/rounds.py)",
|
||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\('/opt/teecup/app/routers/rounds.py'\\).read\\(\\)\\)\")",
|
||||
"Bash(python3 test_round_edit_delete.py)",
|
||||
"Bash(python3 test_round_starthole_time_nearby.py)"
|
||||
"Bash(python3 test_round_starthole_time_nearby.py)",
|
||||
"Bash(python3 test_round_strokes_received.py)",
|
||||
"Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
|
|
@ -2157,6 +2157,430 @@ migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
|||
begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new`
|
||||
→ 200, `teeoff.no` upåvirket.
|
||||
|
||||
**Reell UX-bug funnet OG fikset, samme dag 2026-07-25, rapportert av
|
||||
bruker med skjermbilder av en konkurrerende golf-app (Golf Game Book)
|
||||
som referanse:** brukeren rapporterte at "All statistikk" ikke viste
|
||||
noe utover slag/putter under selve registreringen. **Bekreftet direkte
|
||||
mot ekte `teecup_db` (kun lesing) at dette IKKE var en datafeil** --
|
||||
brukerens faktiske runde hadde `stat_level='full'` lagret korrekt. Rot-
|
||||
årsaken var et REELT presentasjonsproblem: alle detaljfeltene (kølle,
|
||||
utslag, innspill, chip, bunker, straffeslag, putt-avstand, anywayslag)
|
||||
lå bak en "Score"/"Statistikk"-fane (V0-runden 2026-07-24s bevisste
|
||||
designvalg) som brukeren aldri oppdaget -- "aktivert full statistikk"
|
||||
ga i praksis ingen synlig endring uten et ekstra, ikke-annonsert
|
||||
tastetrykk. **Fikset ved å fjerne fane-løsningen helt:** alle feltene
|
||||
vises nå ALLTID samlet under slag/putter i én sammenhengende scroll når
|
||||
`statLevel==="full"` (samme prinsipp brukeren opprinnelig ba om FØR
|
||||
V0-rundens sveip/fane-vurdering) -- fjerner enhver tvetydighet om
|
||||
hvorvidt statistikken faktisk er slått på. `PanelTabs`-komponenten og
|
||||
`panelTab`-state fjernet som død kode.
|
||||
**Samtidig bygget: "Så langt i runden"-oversikt**, direkte etterspurt
|
||||
("jeg trenger et grensesnitt som viser scoren min så langt") med
|
||||
referansebildene som inspirasjon for FUNKSJONEN (ikke kopiert
|
||||
utseendemessig). Ny komponent `ScoreSoFar` i `round-detail.tsx`: en
|
||||
kompakt alltid-synlig linje ("Så langt: X hull · Y slag · +Z til par")
|
||||
rett under spillerfanene, pluss en "Vis full oversikt"-knapp (samme
|
||||
etablerte mønster som `session-scorecard.tsx` sin `HoleSummaryTable`
|
||||
for turnering-scoring) som åpner en tabell: hull, par, score, netto,
|
||||
løpende sum. **Ny backend-beregning for å muliggjøre netto-kolonnen:**
|
||||
`RoundHoleOut` fikk et nytt felt `strokes_received` (`list_holes` i
|
||||
`app/routers/rounds.py`) -- utledet fra deltakerens
|
||||
`course_handicap_snapshot` + hullenes `stroke_index` via den
|
||||
ALLEREDE eksisterende `allocate_strokes_by_index()` fra
|
||||
`handicap_engine.py` (samme allokeringsalgoritme som brukes overalt
|
||||
ellers i appen, ingen ny logikk) -- `None` for en deltaker uten
|
||||
beregnet HCP (f.eks. en gjest uten oppgitt handicap), aldri lagret,
|
||||
kun beregnet ved lesing.
|
||||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||
isolert scratch-MinIO + engangs API-container): 11 sjekker, inkl. et
|
||||
presist talleksempel (course rating 72.0/slope 113/HCP 10.0 → course
|
||||
handicap nøyaktig 10, bekreftet at de 10 laveste stroke-indeksene fikk
|
||||
nøyaktig 1 slag hver og de resterende 8 fikk 0, sum(strokes_received)
|
||||
== course_handicap), et registrert hull som ga korrekt netto (score 5 −
|
||||
1 mottatt slag = 4), og en gjest UTEN HCP som korrekt fikk
|
||||
`strokes_received: null` på alle 18 hull. `test_isolation.sql` 12/12
|
||||
(ingen skjemaendring). Ekte typesjekket produksjonsbuild kompilerte
|
||||
rent.
|
||||
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon, kun `teecup_api`+`teecup_frontend` redeployet.
|
||||
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
**Reell produksjonsregresjon rapportert AV BRUKEREN samme dag, rett
|
||||
etter utrulling over, funnet og fikset umiddelbart:** "Ingenting er
|
||||
klikkbart i den avanserte statistikken." Første antagelse (frontend-
|
||||
CSS/event-håndtering) ble IKKE bekreftet ved kodegjennomgang -- all
|
||||
onClick-kabling i `DirectionCross`/`Stepper`/`ChoiceRow`/`NumberPicker`
|
||||
var korrekt. Root cause funnet ved å faktisk GJENSKAPE brukerens
|
||||
klikk-sekvens mot en fersk, isolert scratch-container (samme mønster
|
||||
som resten av runden) i stedet for å gjette videre: `update_hole`
|
||||
(PATCH-endepunktet for hull-registrering) konstruerte fortsatt
|
||||
`RoundHoleOut(**dict(row))` UTEN det nye påkrevde
|
||||
`strokes_received`-feltet fra runden rett over -- en Pydantic
|
||||
`ValidationError` (500) på HVER ENESTE hull-lagring, ikke bare de
|
||||
"avanserte" feltene. Frontend sin `updateStat()` svelger `!res.ok`
|
||||
stille uten feilmelding, så symptomet fremsto nøyaktig som "ingenting
|
||||
skjer når jeg trykker" for ALLE felt (Slag/Putter inkludert) -- brukeren
|
||||
merket det trolig først på de avanserte feltene siden Slag/Putter fra
|
||||
TIDLIGERE runder allerede hadde lagrede verdier som så riktige ut ved
|
||||
åpning.
|
||||
**Fikset:** `update_hole` beregner nå `strokes_received` for akkurat det
|
||||
oppdaterte hullet (henter deltakerens `course_handicap_snapshot` +
|
||||
ALLE 18 sine `stroke_index` -- samme allokeringsalgoritme som
|
||||
`list_holes`, siden fordelingen avhenger av hele rundens
|
||||
stroke-indeks-rekkefølge, ikke bare ett hull) og sender den med i
|
||||
responsen.
|
||||
**Scratch-verifisert på nytt, presist mot akkurat denne regresjonen:**
|
||||
14 sjekker som gjenskaper brukerens EKSAKTE klikk-rekkefølge (Slag →
|
||||
kølle → Utslag-retning → Innspill-retning → Chip → første putt-bøtte →
|
||||
Anywayslag, pluss et klikk på et avansert felt FØR Slag i det hele tatt
|
||||
er satt, og en gjest uten HCP) -- alle 200 med riktig ekko, `GET` etterpå
|
||||
bekrefter faktisk lagring, gjest uten HCP gir korrekt `strokes_received:
|
||||
null` uten å krasje. `test_isolation.sql` uendret (ingen skjemaendring,
|
||||
ren Python-fiks). **Rullet ut live 2026-07-25**, bruker bekreftet
|
||||
eksplisitt: kun `teecup_api` redeployet, `/health`/`/dashboard` → 200,
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
**Rediger/Fullfør/Slett tonet ned og flyttet til toppen + auto-scroll ved
|
||||
hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at
|
||||
"Fullfør runde"/"Slett runde" lå som store, fremtredende knapper RETT
|
||||
under "Neste hull"-navigasjonen nederst i hull-panelet -- altfor lett å
|
||||
trykke feil ved et uhell mens man bare skulle bla mellom hull. Flyttet
|
||||
alle tre (Rediger/Fullfør/Slett) til en samlet rad ØVERST på siden,
|
||||
tonet ned til samme nøytrale "trigger"-stil (ikke lenger store,
|
||||
fargede knapper) -- brukeren må nå aktivt scrolle OPP forbi hele
|
||||
hull-registreringen for å nå dem. Samtidig rapportert, i samme runde:
|
||||
"Neste hull"/"Forrige" lastet nytt innhold, men brukeren ble stående
|
||||
scrollet nede der knappene er, med det nye hullets Slag-felt utenfor
|
||||
skjermen. Løst med `holePanelRef` + `scrollIntoView({block:"start"})`
|
||||
kalt fra `goPrev`/`goNext` OG fra hull-navigasjonens direkte hull-valg,
|
||||
med `scroll-mt-28` på panelet for å unngå at den sticky headeren dekker
|
||||
toppen. Ren frontend-endring, ingen backend/migrasjon. Ekte typesjekket
|
||||
build kjørt og bekreftet (alle 19 ruter), rullet ut, `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
**"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle-
|
||||
statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme
|
||||
dag (2026-07-25):** brukeren etterspurte flere detaljer i
|
||||
rundeoversikten: netto- og stableford-sum, hvor man kan se totalt antall
|
||||
putter/chip/bunker/straffeslag/anywayslag for runden, grafisk fremstilt
|
||||
prosentvis fordeling av fairwaytreff og innspillstreff, og gjennomsnittlig
|
||||
score til par (to desimaler) splittet på fairwaytreff-vs-bom og
|
||||
innspill-treff-vs-bom.
|
||||
**Ingen backend-endring nødvendig** -- alt beregnes klientside fra data
|
||||
`GET .../holes` allerede returnerer (score/par/strokes_received/putts/
|
||||
chip_count/bunker_shot_count/penalty_strokes/tee_shot_result/
|
||||
approach_result/anyway_strokes). `ScoreSoFar`-komponenten
|
||||
(round-detail.tsx) utvidet med: en `StatPill`-rutenett (Slag/Til par/
|
||||
Netto/Stableford/Putt/Chip/Bunker/Straffeslag/Anywayslag -- hver vist
|
||||
kun hvis det faktisk finnes registrert data for akkurat det feltet), en
|
||||
ny Stableford-kolonne i hull-for-hull-tabellen (kun når netto er
|
||||
beregnbart), og to nye `DistributionBar`-seksjoner (Fairwaytreff/
|
||||
Innspill) -- generalisert N-kategori-variant av samme visuelle idé som
|
||||
`SegmentedBar` i tournament-leaderboard.tsx (ett fargesegmentert
|
||||
rektangel + prosent-legend), ikke noe chart-bibliotek lagt til.
|
||||
**Presisert i kode-kommentar, bevisst valg:** appen har ingen egen
|
||||
"spilleform"-innstilling for frittstående runder -- stableford beregnes
|
||||
derfor alltid ut fra netto score når det er mulig (krever registrert
|
||||
HCP, samme forutsetning som netto), uavhengig av om brukeren
|
||||
"egentlig" spiller slagspill eller stableford. Standard stableford-
|
||||
poengtabell brukt (netto par = 2 poeng, ett poeng mer/mindre per slag
|
||||
bedre/dårligere enn par, gulv på 0).
|
||||
Gjennomsnittlig score-til-par (fairway/innspill, treff-vs-bom) bruker
|
||||
BRUTTO score (ikke netto) relativt til par, formatert med fortegn og to
|
||||
desimaler (`formatSignedAvg`), matcher brukerens eksplisitte
|
||||
spesifikasjon. Fairwaytreff ekskluderer par-3-hull (samme regel som
|
||||
registrerings-skjemaet, som aldri viser Utslag-retning der).
|
||||
**Logikken verifisert manuelt mot et regnet eksempel** (4 hull, blandet
|
||||
par 3/4/5, ulike utslag-/innspillresultater) FØR utrulling -- stableford-
|
||||
sum, netto-sum og begge gjennomsnitts-splittene stemte med
|
||||
håndregning. Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle
|
||||
19 ruter). **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt:
|
||||
ren frontend-endring, ingen migrasjon, `/health`/`/my-rounds` → 200,
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
**Rundestatistikk-skjerm (dypdykk), inspirert av en konkurrent-video,
|
||||
BYGGET OG LIVE samme dag (2026-07-25):** brukeren lastet opp en 26
|
||||
sekunders skjermopptaksvideo av en KONKURRENT-apps statistikkskjerm og
|
||||
ba eksplisitt om et V0-prompt inspirert av innholdet, IKKE et plagiat.
|
||||
Video analysert bilde for bilde (ffmpeg kjørt i en engangs Docker-
|
||||
container, ikke installert på verten -- ryddet opp etterpå). Innhold
|
||||
identifisert: score-fordeling (par/bogey/dobbel bogey/verre),
|
||||
snitt-til-par per hulltype, fairwayfordeling + score-splitt, GIR-donut +
|
||||
per-hulltype + kryss med fairway + score-splitt, bom-retning på green
|
||||
som et kompass-diagram, putt-fordeling (1/2/3-putt) + snitt per hulltype
|
||||
+ med/uten GIR, én-putt% etter puttlengde + lengdefordeling hit/miss,
|
||||
chip-fordeling, scrambling%/sand save%, bunker/straffeslag per runde +
|
||||
score-splitt.
|
||||
**Bevisste valg for å unngå plagiat, skrevet inn i selve V0-promptet:**
|
||||
egen visuell identitet (TeeCups presise grønn/oransje, ikke konkurrentens
|
||||
fargekoding), fritt valg av chart-type/layout/rekkefølge til V0 selv,
|
||||
INGEN kopiering av konkurrentens eksakte ordlyd/fargekoding/ikonografi.
|
||||
Puttlengde-bøttene i promptet er TeeCups EGNE seks bøtter
|
||||
(<1m/<2m/<3m/<5m/<8m/8m+, samme som ADR-033 allerede lagrer) -- bevisst
|
||||
ANDRE enn videoens fem bøtter (<1m/1-2/2-4/4-8/+), både fordi det unngår
|
||||
en direkte kopi og fordi det er det datamodellen faktisk allerede
|
||||
produserer. "Lengste drive" (krever GPS/avstandsmåling appen ikke har)
|
||||
utelatt fra promptet.
|
||||
**Zip 14 mottatt og integrert samme dag:** diffet mot live-treet FØR noe
|
||||
ble tatt inn (samme rutine som alltid) -- kun to reelt nye filer
|
||||
(`components/round-stats.tsx`, en ny `RoundStats`-skjerm bygget med
|
||||
kollapsbare `StatCard`-seksjoner, conic-gradient-donuter med sentertall,
|
||||
avviks-stolper fra en null-linje, og et kompass-rutenett for bom-retning
|
||||
-- en tydelig ANNEN visuell løsning enn konkurrentens skjermbilder, ikke
|
||||
en klone). Resten av eksporten var V0s vanlige uvitende reverts, inkl.
|
||||
sin egen `/rounds/[id]`-ruteversjon fra FØR `/my-rounds`-omdøpingen
|
||||
(se lenger opp, "Reell produksjonsbug...") -- korrekt hoppet over. Ny
|
||||
rute lagt til som `app/my-rounds/[id]/stats/page.tsx` (ikke
|
||||
`app/rounds/[id]/stats` som eksporten foreslo), samme
|
||||
kollisjon-unngåelse. `app/globals.css` sin nye `--chart-1..6`
|
||||
data-viz-fargeskala (god->dårlig-skala, brukt sammen med
|
||||
tekst-/tall-etiketter) slått sammen inn i alle tre temablokkene;
|
||||
V0s egne, mer omtrentlige `--primary`/`--ring`/`--brand-orange`-verdier
|
||||
i SAMME diff ble bevisst IKKE tatt inn -- beholdt de presise OKLCH-
|
||||
verdiene utregnet i ADR-016.
|
||||
**Datalag skrevet fullstendig om fra mock:** ny `computeStats()`-
|
||||
funksjon i `round-stats.tsx` regner ut alt fra rå `GET .../holes`-data
|
||||
(samme endepunkt round-detail.tsx allerede bruker -- ingen ny backend).
|
||||
Hver seksjon skjules helt når det ikke finnes nok data for den (samme
|
||||
"vis kun det som faktisk finnes"-prinsipp som `ScoreSoFar`). Ny enkel
|
||||
spillervelger (pill-rad) lagt til når runden har flere deltakere,
|
||||
default til eieren. `CompletedBanner` i round-detail.tsx fikk en "Se
|
||||
full rundestatistikk"-lenke (fantes i V0s egen, ellers reverterte fil --
|
||||
portert manuelt inn i vår live versjon i stedet for å ta hele filen).
|
||||
**Matematikken verifisert FØR utrulling, ikke bare "kompilerer":**
|
||||
`computeStats()`-logikken portert til et frittstående Node-script og
|
||||
kjørt mot et hånd-etterregnet 6-hulls syntetisk datasett (blandet par
|
||||
3/4/5, fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle utledede
|
||||
tall (score-kategorier, snitt-til-par per hulltype, fairway-splitt,
|
||||
GIR%/kryss-fairway/score-splitt, bom-retning, putt-fordeling,
|
||||
scrambling%, sand save%) stemte med manuell utregning. Ekte typesjekket
|
||||
produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet som
|
||||
ny rute).
|
||||
**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon,
|
||||
`/health`/`/my-rounds` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
**Rundeliste-info + scorekort-redesign, BYGGET OG LIVE samme dag
|
||||
(2026-07-25):** brukeren delte en ny skjermopptaksvideo (video 2, 18
|
||||
frames analysert ved 1,5 fps) av to problemer: "Egne runder"-listen
|
||||
manglet ALL score-informasjon (kun tee/hull/dato/spillerantall/status),
|
||||
og selve scorekort-registreringen (rapportert "veldig dårlig
|
||||
designet") stablet fem ulike kontrolltyper (numpad, retningskors,
|
||||
steppere, pill-rutenett, pill-rad) uten hierarki, pluss at "Runde
|
||||
fullført"-siden gjentok NØYAKTIG samme tall tre ganger (CompletedBanner-
|
||||
differensial, "Så langt"-oppsummeringslinjen, og StatPill-rutenettet
|
||||
under). Bedt om å reflektere kort og eventuelt skrive V0-prompt(er).
|
||||
**Reflektert og handlet i samme runde** (ikke bare skrevet ned): pekte ut
|
||||
at score/til-par mangler helt fra listekortet som det klareste,
|
||||
konkrete hullet; anbefalte å samle alt utover Slag/Putter bak én
|
||||
kollapsbar "Flere detaljer"-seksjon som den viktigste enkeltfiksen for
|
||||
scorekort-rotet; og fikset duplikat-problemet PÅ "Runde fullført"-siden
|
||||
DIREKTE (ikke via V0) siden det bare er snakk om å SKJULE innhold, ikke
|
||||
designe noe nytt: `ScoreSoFar` skjules nå helt når runden er fullført
|
||||
(`{!completed && <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,**
|
||||
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
||||
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
|
||||
|
|
|
|||
225
CLAUDE.md
225
CLAUDE.md
|
|
@ -2314,6 +2314,231 @@ Ferdig og verifisert:
|
|||
avstand mot alle 174 teeoff-anlegg, geolokasjon i nettleseren, feiler
|
||||
stille hvis avslått). 14 nye scratch-sjekker inkl. et ekte nearby-kall
|
||||
mot teeoff. Full detalj i ADR-033.
|
||||
- **Reell UX-bug fikset + "Så langt i runden"-oversikt bygget, samme
|
||||
dag (2026-07-25), klar for utrulling:** brukeren rapporterte at "All
|
||||
statistikk" ikke viste noe utover slag/putter -- bekreftet (kun
|
||||
lesing) mot ekte `teecup_db` at `stat_level='full'` FAKTISK var
|
||||
lagret riktig, så feilen var presentasjonen: alle detaljfeltene lå
|
||||
bak en "Score"/"Statistikk"-fane fra forrige V0-runde som brukeren
|
||||
aldri oppdaget. Fikset ved å fjerne faneløsningen helt -- alt vises nå
|
||||
alltid samlet. Samtidig bygget en ny "Så langt"-oversikt
|
||||
(`ScoreSoFar`-komponent: kompakt linje + utvidbar full-oversikt-
|
||||
tabell med netto per hull), med et nytt backend-felt
|
||||
`strokes_received` (`allocate_strokes_by_index()`, ingen ny
|
||||
algoritme). 11 nye scratch-sjekker (inkl. et presist tall-eksempel på
|
||||
slagfordelingen), ren build. Full detalj i ADR-033.
|
||||
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: kun
|
||||
`teecup_api`+`teecup_frontend` redeployet, ingen migrasjon,
|
||||
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
- **Reell produksjonsregresjon rapportert av bruker rett etter utrullingen
|
||||
over, funnet og fikset umiddelbart samme dag (2026-07-25):**
|
||||
"Ingenting er klikkbart i den avanserte statistikken." Root cause var
|
||||
IKKE frontend (all onClick-kabling var korrekt) -- funnet ved å faktisk
|
||||
gjenskape brukerens klikk-sekvens mot en fersk scratch-container:
|
||||
`update_hole` (hull-PATCH) manglet fortsatt det nye påkrevde
|
||||
`strokes_received`-feltet i responsen sin (lagt til i GET-endepunktet i
|
||||
runden rett over, glemt i PATCH) -- ga en 500 (Pydantic-valideringsfeil)
|
||||
på HVER hull-lagring, ikke bare de avanserte feltene. Frontend svelger
|
||||
feilresponsen stille, så symptomet så ut som "ingenting skjer" for
|
||||
ALT, ikke bare avansert statistikk (brukeren merket det trolig først
|
||||
der siden Slag/Putter fra tidligere runder allerede hadde lagrede
|
||||
verdier som så riktige ut). Fikset: `update_hole` beregner nå
|
||||
`strokes_received` for hullet som oppdateres, samme algoritme som
|
||||
`list_holes`. 14 scratch-sjekker som gjenskaper eksakt klikk-
|
||||
rekkefølgen, alle bestått. **Rullet ut live 2026-07-25**, bruker
|
||||
bekreftet eksplisitt: kun `teecup_api` redeployet, `/health`/
|
||||
`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
- **Rediger/Fullfør/Slett tonet ned + flyttet til toppen, auto-scroll ved
|
||||
hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at
|
||||
"Fullfør runde"/"Slett runde" var for lette å trykke på ved et uhell
|
||||
(lå rett under "Neste hull"). Flyttet alle tre handlingene til en
|
||||
nedtonet rad øverst -- må nå aktivt scrolles til. Samtidig: "Neste
|
||||
hull"/"Forrige" scroller nå automatisk opp til toppen av hull-panelet
|
||||
(også ved direkte hull-valg), så det nye hullets Slag-felt alltid er
|
||||
synlig med en gang. Ren frontend-endring, ingen backend/migrasjon.
|
||||
Rullet ut, `teeoff.no` upåvirket.
|
||||
- **"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle-
|
||||
statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme
|
||||
dag (2026-07-25):** etterspurt av bruker. Ny `StatPill`-rutenett
|
||||
(Slag/Til par/Netto/Stableford/Putt/Chip/Bunker/Straffeslag/
|
||||
Anywayslag), Stableford-kolonne i hull-tabellen, og to nye
|
||||
`DistributionBar`-seksjoner (Fairwaytreff/Innspill, N-kategori-variant
|
||||
av `SegmentedBar`-mønsteret fra tournament-leaderboard.tsx) +
|
||||
gjennomsnittlig brutto score-til-par (to desimaler, fortegn) splittet
|
||||
på treff-vs-bom for både fairway og innspill. Ingen backend-endring --
|
||||
alt beregnes klientside fra data `GET .../holes` allerede returnerer.
|
||||
Stableford beregnes alltid ut fra netto score når mulig (appen har
|
||||
ingen egen "spilleform"-innstilling). Logikk verifisert manuelt mot et
|
||||
regnet eksempel før utrulling. Ren frontend-endring, ingen migrasjon,
|
||||
rullet ut, `teeoff.no` upåvirket.
|
||||
- **"Avstand første putt" flyttet rett under "Putter", LIVE samme dag
|
||||
(2026-07-25):** lå tidligere lenger ned i "full"-statistikk-blokken
|
||||
(etter kølle/retning/chip-gruppen) -- flyttet til å bli første felt i
|
||||
den blokken, rett under Putter-NumberPickeren (som ligger utenfor
|
||||
"full"-gaten). Ren omrokkering, ingen ny logikk. Ekte typesjekket
|
||||
build, rullet ut, `teeoff.no` upåvirket.
|
||||
- **V0-prompt for en rikere rundestatistikk-skjerm skrevet, IKKE bygget
|
||||
ennå (2026-07-25):** brukeren lastet opp en skjermopptaksvideo
|
||||
(`screen-20260724-133013-1784892586987.mp4`, 26 sek, av en KONKURRENT-
|
||||
apps statistikkskjerm) og ba om et V0-prompt inspirert av innholdet,
|
||||
eksplisitt IKKE et plagiat. Video analysert bilde for bilde (ffmpeg i
|
||||
en engangs Docker-container, ikke installert på verten). Innholdet
|
||||
identifisert dekkes i stor grad av data vi ALLEREDE sporer
|
||||
(fairwaytreff/innspill-retning/putts/chip/bunker/straffeslag/putt-
|
||||
lengde-bøtte) -- ingen backend-endring skulle trengtes for de fleste
|
||||
målene. Prompt skrevet til bruker i chatten (ikke lagret som egen
|
||||
fil) -- beskriver mål/informasjonsarkitektur/tilgjengelighetskrav
|
||||
abstrakt (donut med sentertall, gauge-stolper for avvik fra par,
|
||||
retnings-diagram for bom-retning) UTEN å kopiere konkurrentens
|
||||
eksakte fargevalg/layout/ordlyd, og ber eksplisitt om TeeCups egen
|
||||
merkevareidentitet (grønn/oransje) + en egen visuell vri.
|
||||
**Én bevisst forskjell fra videoen, valgt for å unngå plagiat OG fordi
|
||||
det matcher vårt eget datamodell:** videoens puttlengde-bøtter
|
||||
(<1m/1-2/2-4/4-8/+) er ANDRE enn TeeCups allerede lagrede seks bøtter
|
||||
(<1m/<2m/<3m/<5m/<8m/8m+, ADR-033) -- promptet ber om TeeCups egne
|
||||
bøtter, ikke videoens. "Lengste drive" (krever GPS/avstandsmåling vi
|
||||
ikke har) bevisst utelatt fra promptet.
|
||||
**Bygget og rullet ut live 2026-07-25, samme dag:** zip 14 mottatt.
|
||||
Diffet mot live-treet FØR noe ble tatt inn (samme rutine som alltid) --
|
||||
kun to reelt nye filer (`components/round-stats.tsx`,
|
||||
`app/rounds/[id]/stats/page.tsx`), resten var V0s vanlige uvitende
|
||||
reverts (bl.a. sin egen `/rounds/[id]`-ruteversjon fra FØR
|
||||
`/my-rounds`-omdøpingen ADR-033 gjorde 2026-07-23 -- korrekt hoppet
|
||||
over). Ruten lagt inn som `app/my-rounds/[id]/stats/page.tsx` i stedet
|
||||
(samme kollisjon-unngåelse). `globals.css` sin nye `--chart-1..6`
|
||||
data-viz-fargeskala slått sammen inn (V0 sine egne, mer omtrentlige
|
||||
`--primary`/`--ring`/`--brand-orange`-verdier IKKE tatt inn -- beholdt
|
||||
de presise OKLCH-verdiene fra ADR-016).
|
||||
**Datalag skrevet fullstendig om fra mock:** henter `GET /rounds/{id}`
|
||||
+ `GET .../participants/{id}/holes` (samme endepunkter round-detail.tsx
|
||||
allerede bruker) -- ingen ny backend. Ny `computeStats()`-funksjon
|
||||
regner ut ALT fra rå hull-data ved lesing (score-kategorier,
|
||||
snitt-til-par totalt/per hulltype, fairway-fordeling + score-splitt,
|
||||
GIR totalt/per hulltype/kryss-fairway + score-splitt, bom-retning på
|
||||
green, putt-fordeling 1/2/3-putt + snitt per hulltype + med/uten GIR,
|
||||
én-putt% per TeeCups egne seks puttlengde-bøtter + lengdefordeling
|
||||
hit/miss, chip-fordeling, scrambling%, sand save%, bunker/straffeslag
|
||||
per runde + score-splitt). Hver seksjon skjules helt når det ikke
|
||||
finnes nok data (samme "vis kun det som faktisk finnes"-prinsipp som
|
||||
"Så langt i runden"). Ny enkel spillervelger (pill-rad) lagt til når
|
||||
runden har flere deltakere -- default til eieren.
|
||||
**"Se full rundestatistikk"-lenke** lagt inn i `CompletedBanner` i
|
||||
round-detail.tsx (V0s egen versjon hadde denne, men i sin ellers
|
||||
fullstendig reverterte fil -- portert manuelt inn i vår LIVE versjon
|
||||
i stedet for å ta hele filen).
|
||||
**Matematikken verifisert FØR utrulling:** `computeStats()`-logikken
|
||||
portert til et frittstående Node-script og kjørt mot et
|
||||
hånd-etterregnet 6-hulls syntetisk datasett (blandet par 3/4/5,
|
||||
fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle 15+ utledede
|
||||
tall stemte eksakt med manuell utregning. Ekte typesjekket
|
||||
produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet
|
||||
som ny rute).
|
||||
**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon,
|
||||
`docker compose up -d --build teecup_frontend`, `/health`/`/my-rounds`
|
||||
→ 200, `teeoff.no` upåvirket.
|
||||
- **Rundeliste + scorekort-redesign, LIVE samme dag (2026-07-25):**
|
||||
brukeren delte en ny skjermopptaksvideo av "Egne runder"-listen (som
|
||||
manglet ALL score-informasjon) og av scorekort-registreringen (rapportert
|
||||
som "veldig dårlig designet" -- for mange ulike knapp-typer stablet
|
||||
oppå hverandre) + "Runde fullført"-siden (rapportert som "veldig mye
|
||||
dobbel informasjon"). Reflektert over problemstillingen (kort, per
|
||||
brukerens ønske) før to V0-prompter ble skrevet.
|
||||
**Fikset direkte, uten V0:** "Runde fullført"-duplikatet -- `ScoreSoFar`
|
||||
("Så langt i runden") skjules nå helt når runden er fullført, siden
|
||||
den nye rundestatistikk-siden dekker akkurat det samme, langt
|
||||
grundigere. Ny backend-beregning i `_load_round_out`
|
||||
(`app/routers/rounds.py`): `owner_holes_played`/`owner_total_score`/
|
||||
`owner_score_to_par`, en enkel aggregatspørring mot `round_hole` for
|
||||
eierens egen deltaker-rad -- verifisert med 10 scratch-sjekker
|
||||
(inkl. at en gjests score IKKE påvirker eierens aggregat).
|
||||
**Zip 15 og 16 mottatt** (samme v0.app-prosjekt, kontinuerlig --
|
||||
`round-card.tsx`/`own-rounds.tsx` identiske i begge, zip 16s
|
||||
`round-detail.tsx` var den nyeste med selve scorekort-redesignet; zip
|
||||
15 brukt kun for å bekrefte at zip 16 var det riktige, endelige
|
||||
eksportet).
|
||||
`round-card.tsx` fikk en ny `ScoreTile` -- prominent resultat+til-par
|
||||
for fullførte runder, hull-fremdrift-bar ("6/18 hull spilt") for
|
||||
runder som pågår, pluss en HCP-differensial-chip når runden telte.
|
||||
Datalag i `own-rounds.tsx` skrevet om fra mock til ekte fetch, kobler
|
||||
de nye `owner_*`-feltene fra backend + eierens `score_differential`
|
||||
(kun vist når `counts_for_handicap`).
|
||||
`round-detail.tsx` fikk en ny kollapsbar "Flere detaljer"-seksjon
|
||||
(lukket som default) som nå rommer kølle/utslag-retning/innspill-
|
||||
retning/chip-bunker-straffeslag/anywayslag -- Slag/Putter/Avstand
|
||||
første putt forblir alltid synlig over. Datalag/statLevel-gating/
|
||||
bucket-basert puttlengde/maxValue-capping (alt bygget tidligere denne
|
||||
økten) bevisst IKKE revertert til V0s eldre mock-baseline, kun selve
|
||||
kollaps-mekanismen og plasseringen ble hentet derfra. Lagt til en kort
|
||||
kode-kommentar (ikke noe bygget UI ennå) som reserverer plass ved
|
||||
siden av hull-headeren til en fremtidig avstandsmåling-indikator,
|
||||
bekreftet av bruker at dette kommer senere.
|
||||
Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle 19 ruter).
|
||||
**Rullet ut live 2026-07-25**, ren frontend-endring (+ den lille
|
||||
backend-tilføyelsen over), ingen migrasjon, `/health`/`/my-rounds` →
|
||||
200, `teeoff.no` upåvirket.
|
||||
- **Reelt hull funnet og fikset SAMME dag, rapportert av bruker rett
|
||||
etter forrige punkt ("Hvor er scorekortet?"):** da "Så langt i runden"
|
||||
ble skjult for fullførte runder (se over), forsvant OGSÅ den eneste
|
||||
plassen den rå hull-for-hull-tabellen (Hull/Par/Score/Netto/Sum) fantes
|
||||
-- `round-stats.tsx` (den nye dedikerte statistikk-siden) hadde KUN
|
||||
utledet/aggregert statistikk (donuter, stolper), ingen tabell med de
|
||||
faktiske tallene per hull. Fikset ved å legge til en ny "Scorekort"-
|
||||
seksjon FØRST på statistikk-siden (samme tabell-mønster som "Så
|
||||
langt" hadde -- Hull/Par/Score/Netto/Stableford/Sum), åpen som
|
||||
default. La til `start_hole` i `round-stats.tsx` sin `ApiRound`-type
|
||||
og `strokes_received` i `ApiHole`-typen (sistnevnte kom allerede fra
|
||||
backend, bare ikke lest av denne siden ennå) for å kunne vise hullene
|
||||
i rundens FAKTISKE rekkefølge (samme sirkulære start_hole-logikk som
|
||||
round-detail.tsx) i stedet for bare rå hullnummer 1-18. Ekte
|
||||
typesjekket build kjørt og bekreftet. Rullet ut, ren frontend-endring,
|
||||
ingen backend/migrasjon, `teeoff.no` upåvirket.
|
||||
- **Ny scorekort-presentasjonsregel + V0-prompt skrevet, IKKE bygget
|
||||
ennå (2026-07-25):** brukeren delte et referansebilde av et
|
||||
tradisjonelt horisontalt golf-scorekort og formulerte en generell
|
||||
regel: hull listet HORISONTALT (som kolonner) → sum TIL HØYRE; hull
|
||||
listet VERTIKALT (som rader) → sum UNDER. Dagens "Scorekort"-tabell
|
||||
(bygget rett over samme dag) er vertikal med sum som løpende KOLONNE
|
||||
-- ikke i tråd med regelen. V0-prompt skrevet (horisontalt scorekort,
|
||||
hull 1-9/10-18 som kolonner, Ut/Inn-sum til høyre for hver halvdel,
|
||||
Hcp/Par/Score/Netto/Stableford-rader, håndterer både 9- og 18-hulls
|
||||
runder med vilkårlig start_hole), bevisst IKKE et plagiat av
|
||||
referansebildet (egne farger, egen "Hcp"-term i stedet for bildets
|
||||
"Slope", ingen kopiert spiller-header). Full detalj i
|
||||
ARCHITECTURE_DECISIONS.md. **Presisert samme dag, før noe ble sendt:**
|
||||
brukeren spurte om et mykt "scroll hvis nødvendig"-unntak fanget opp
|
||||
målet om ALDRI å måtte scrolle -- svart nei (reell breddekonflikt med
|
||||
"lesbar uten briller"-kravet, ikke bare ordlyd) og skrevet om til et
|
||||
hardt "ingen scroll"-krav som eksplisitt forteller V0 HVORDAN det
|
||||
oppnås (kompakte fete høykontrast-siffer i selve rutenettet, smale
|
||||
forkortede rad-labels i en trang gutter -- "lesbar uten briller"
|
||||
avgrenset til labels/knapper, ikke enkeltsifre). Full detalj i
|
||||
ARCHITECTURE_DECISIONS.md.
|
||||
**Zip 17 mottatt og BYGGET/LIVE samme dag:** en ekte HTML `<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:
|
||||
1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første
|
||||
|
|
|
|||
|
|
@ -1366,7 +1366,7 @@ tom-skjermens endelige form kan bestemmes.
|
|||
|
||||
---
|
||||
|
||||
## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE (2026-07-23)
|
||||
## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-25 (se ADR-033 for full detalj per dag)
|
||||
|
||||
**Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema
|
||||
(`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner),
|
||||
|
|
@ -1455,6 +1455,12 @@ en ekstern golf-GPS-database?) eller UI — kun fanget som en kjent,
|
|||
fremtidig ambisjon som statistikk-modellen (Beslutning B) og
|
||||
banedata-modellen (Beslutning C) bør ha i bakhodet, siden begge kan
|
||||
trenge en utvidelse den dagen dette faktisk designes.
|
||||
**Reconfirmed 2026-07-25** (scorekort-redesign-runden): brukeren
|
||||
gjentok at avstandsmåling "ligger i kortene". Fortsatt IKKE designet
|
||||
eller bygget -- eneste konkrete tiltak er en kode-KOMMENTAR i
|
||||
`round-detail.tsx` sin hull-header som reserverer visuell plass ved
|
||||
siden av GIR-merket, slik at en fremtidig avstand-indikator kan legges
|
||||
til uten en layout-endring. Ingen data, ingen funksjonalitet.
|
||||
|
||||
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
|
||||
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
||||
|
|
|
|||
|
|
@ -427,6 +427,14 @@ class RoundOut(BaseModel):
|
|||
started_at: str | None
|
||||
completed_at: str | None
|
||||
participants: list[RoundParticipantOut]
|
||||
# Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) --
|
||||
# brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i
|
||||
# det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker
|
||||
# 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er
|
||||
# registrert.
|
||||
owner_holes_played: int
|
||||
owner_total_score: int | None
|
||||
owner_score_to_par: int | None
|
||||
|
||||
|
||||
async def _load_round_out(conn, round_id: str) -> RoundOut:
|
||||
|
|
@ -448,6 +456,21 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
|
|||
""",
|
||||
round_id,
|
||||
)
|
||||
owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None)
|
||||
owner_agg = await conn.fetchrow(
|
||||
"""
|
||||
SELECT COUNT(*) FILTER (WHERE played) AS played_count,
|
||||
COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score,
|
||||
COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par
|
||||
FROM round_hole WHERE round_participant_id = $1
|
||||
""",
|
||||
owner_id,
|
||||
)
|
||||
owner_holes_played = owner_agg["played_count"] if owner_agg else 0
|
||||
owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None
|
||||
owner_score_to_par = (
|
||||
owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None
|
||||
)
|
||||
return RoundOut(
|
||||
id=round_row["id"],
|
||||
course_source=round_row["course_source"],
|
||||
|
|
@ -459,6 +482,9 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
|
|||
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
|
||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
owner_holes_played=owner_holes_played,
|
||||
owner_total_score=owner_total_score,
|
||||
owner_score_to_par=owner_score_to_par,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -886,6 +912,12 @@ class RoundHoleOut(BaseModel):
|
|||
penalty_strokes: int | None
|
||||
first_putt_distance_bucket: str | None
|
||||
anyway_strokes: int | None
|
||||
# Slag mottatt på dette hullet, utledet fra course_handicap_snapshot
|
||||
# (samme allokeringsalgoritme som resten av appen) -- kun til visning
|
||||
# av netto-score i en oversiktstabell, aldri lagret. None hvis
|
||||
# deltakeren ikke har en beregnet course handicap (f.eks. gjest uten
|
||||
# HCP).
|
||||
strokes_received: int | None
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -899,11 +931,11 @@ async def list_holes(
|
|||
) -> list[RoundHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
owner_check = await conn.fetchval(
|
||||
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
participant_row = await conn.fetchrow(
|
||||
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
participant_id, round_id,
|
||||
)
|
||||
if owner_check is None:
|
||||
if participant_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
|
|
@ -914,7 +946,21 @@ async def list_holes(
|
|||
""",
|
||||
participant_id,
|
||||
)
|
||||
return [RoundHoleOut(**dict(r)) for r in rows]
|
||||
|
||||
strokes_received_by_hole: dict[int, int] | None = None
|
||||
if participant_row["course_handicap_snapshot"] is not None:
|
||||
allocation = allocate_strokes_by_index(
|
||||
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in rows]
|
||||
)
|
||||
strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)}
|
||||
|
||||
return [
|
||||
RoundHoleOut(
|
||||
**dict(r),
|
||||
strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class HoleUpdate(BaseModel):
|
||||
|
|
@ -944,11 +990,11 @@ async def update_hole(
|
|||
) -> RoundHoleOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
owner_check = await conn.fetchval(
|
||||
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
participant_row = await conn.fetchrow(
|
||||
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
participant_id, round_id,
|
||||
)
|
||||
if owner_check is None:
|
||||
if participant_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||
|
||||
async with translate_db_errors():
|
||||
|
|
@ -972,7 +1018,24 @@ async def update_hole(
|
|||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
|
||||
return RoundHoleOut(**dict(row))
|
||||
|
||||
# strokes_received er utledet av HELE rundens stroke-indeks-rekkefølge
|
||||
# (samme allokeringsalgoritme som list_holes/GET), ikke bare dette ene
|
||||
# hullet -- må derfor hente alle 18 sin stroke_index for å plassere
|
||||
# riktig antall mottatte slag på nøyaktig dette hullet.
|
||||
strokes_received = None
|
||||
if participant_row["course_handicap_snapshot"] is not None:
|
||||
all_indexes = await conn.fetch(
|
||||
"SELECT hole_number, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
|
||||
participant_id,
|
||||
)
|
||||
allocation = allocate_strokes_by_index(
|
||||
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_indexes]
|
||||
)
|
||||
by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)}
|
||||
strokes_received = by_hole[hole_number]
|
||||
|
||||
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-6: var(--chart-6);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
|
|
@ -70,11 +71,14 @@
|
|||
--ring: oklch(0.7512 0.1613 130.33);
|
||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||
--brand-orange-foreground: oklch(0.99 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
/* Statistikk-fargeskala (rundestatistikk, ADR-033) -- brukt sammen med
|
||||
tekst-/tall-etiketter, aldri farge alene som eneste signal. */
|
||||
--chart-1: oklch(0.55 0.15 160);
|
||||
--chart-2: oklch(0.72 0.17 140);
|
||||
--chart-3: oklch(0.68 0.13 210);
|
||||
--chart-4: oklch(0.79 0.15 85);
|
||||
--chart-5: oklch(0.68 0.2 45);
|
||||
--chart-6: oklch(0.57 0.22 25);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
|
|
@ -108,11 +112,13 @@
|
|||
--ring: oklch(0.7512 0.1613 130.33);
|
||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||
--brand-orange-foreground: oklch(0.16 0.02 40);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
/* Statistikk-fargeskala, løftet for kontrast på mørk bakgrunn. */
|
||||
--chart-1: oklch(0.7 0.16 160);
|
||||
--chart-2: oklch(0.79 0.17 140);
|
||||
--chart-3: oklch(0.75 0.13 210);
|
||||
--chart-4: oklch(0.83 0.14 85);
|
||||
--chart-5: oklch(0.73 0.19 45);
|
||||
--chart-6: oklch(0.66 0.21 25);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
|
|
@ -146,11 +152,12 @@
|
|||
--ring: oklch(0.7512 0.1613 130.33);
|
||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||
--brand-orange-foreground: oklch(0.16 0.02 40);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--chart-1: oklch(0.7 0.16 160);
|
||||
--chart-2: oklch(0.79 0.17 140);
|
||||
--chart-3: oklch(0.75 0.13 210);
|
||||
--chart-4: oklch(0.83 0.14 85);
|
||||
--chart-5: oklch(0.73 0.19 45);
|
||||
--chart-6: oklch(0.66 0.21 25);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
|
|
|
|||
10
frontend/app/my-rounds/[id]/scorecard/page.tsx
Normal file
10
frontend/app/my-rounds/[id]/scorecard/page.tsx
Normal 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} />
|
||||
}
|
||||
10
frontend/app/my-rounds/[id]/stats/page.tsx
Normal file
10
frontend/app/my-rounds/[id]/stats/page.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ type ApiRoundParticipant = {
|
|||
id: string
|
||||
is_owner: boolean
|
||||
guest_name: string | null
|
||||
counts_for_handicap: boolean
|
||||
score_differential: number | null
|
||||
}
|
||||
|
||||
type ApiRound = {
|
||||
|
|
@ -27,9 +29,14 @@ type ApiRound = {
|
|||
holes_planned: number
|
||||
completed_at: string | null
|
||||
participants: ApiRoundParticipant[]
|
||||
owner_holes_played: number
|
||||
owner_total_score: number | null
|
||||
owner_score_to_par: number | null
|
||||
}
|
||||
|
||||
function toRound(r: ApiRound): Round {
|
||||
const owner = r.participants.find((p) => p.is_owner)
|
||||
const differential = owner?.counts_for_handicap ? owner.score_differential : null
|
||||
return {
|
||||
id: r.id,
|
||||
courseName: r.course_name_snapshot,
|
||||
|
|
@ -38,6 +45,10 @@ function toRound(r: ApiRound): Round {
|
|||
holes: r.holes_planned === 9 ? 9 : 18,
|
||||
date: r.played_at,
|
||||
playerCount: r.participants.length,
|
||||
holesPlayed: r.owner_holes_played,
|
||||
totalScore: r.owner_total_score ?? undefined,
|
||||
toPar: r.owner_score_to_par ?? undefined,
|
||||
differential,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Link from "next/link"
|
||||
import { CalendarDays, ChevronRight, Flag, MapPin, Users } from "lucide-react"
|
||||
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Users } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type RoundStatus = "active" | "completed"
|
||||
|
|
@ -12,6 +12,13 @@ export type Round = {
|
|||
holes: 9 | 18
|
||||
date: string
|
||||
playerCount: number
|
||||
// Progress for rounds still in play.
|
||||
holesPlayed?: number
|
||||
// Owner's score, present once at least one hole is recorded.
|
||||
totalScore?: number
|
||||
toPar?: number
|
||||
// Handicap differential, only when a completed round counted (one decimal).
|
||||
differential?: number | null
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = {
|
||||
|
|
@ -39,6 +46,17 @@ function formatDate(value: string) {
|
|||
return dateFormatter.format(parsed)
|
||||
}
|
||||
|
||||
// "+10", "-2" or "E" for level par. The sign carries the meaning (never color alone).
|
||||
function formatToPar(toPar: number) {
|
||||
if (toPar === 0) return "E"
|
||||
return toPar > 0 ? `+${toPar}` : `${toPar}`
|
||||
}
|
||||
|
||||
function toParDescription(toPar: number) {
|
||||
if (toPar === 0) return "på par"
|
||||
return toPar > 0 ? `${toPar} over par` : `${Math.abs(toPar)} under par`
|
||||
}
|
||||
|
||||
function RoundStatusBadge({ status }: { status: RoundStatus }) {
|
||||
const config = STATUS_CONFIG[status]
|
||||
return (
|
||||
|
|
@ -54,13 +72,87 @@ function RoundStatusBadge({ status }: { status: RoundStatus }) {
|
|||
)
|
||||
}
|
||||
|
||||
// Prominent right-side tile: final score for completed/scored rounds,
|
||||
// hole progress for rounds still in play.
|
||||
function ScoreTile({ round }: { round: Round }) {
|
||||
const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number"
|
||||
|
||||
if (round.status === "active" || !hasScore) {
|
||||
const played = round.holesPlayed ?? 0
|
||||
const pct = round.holes > 0 ? Math.min(100, Math.round((played / round.holes) * 100)) : 0
|
||||
return (
|
||||
<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 }) {
|
||||
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 (
|
||||
<Link
|
||||
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 flex-wrap items-center gap-x-3 gap-y-2">
|
||||
|
|
@ -88,9 +180,17 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
{round.playerCount} {playerLabel}
|
||||
</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>
|
||||
|
||||
<ScoreTile round={round} />
|
||||
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// aldri kun det ene feltet som ble endret.
|
||||
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
ArrowUp,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
|
|
@ -174,6 +175,7 @@ type ApiHole = {
|
|||
penalty_strokes: number | null
|
||||
first_putt_distance_bucket: PuttBucket | null
|
||||
anyway_strokes: number | null
|
||||
strokes_received: number | null
|
||||
}
|
||||
|
||||
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å
|
||||
// hull 1 uansett faktisk starthull).
|
||||
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 [completing, setCompleting] = useState(false)
|
||||
const [deleting, setDeleting] = 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 () => {
|
||||
try {
|
||||
|
|
@ -357,12 +367,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
function goPrev() {
|
||||
const i = holeOrder.indexOf(activeHole)
|
||||
setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
|
||||
setPanelTab("score")
|
||||
scrollToHolePanel()
|
||||
}
|
||||
function goNext() {
|
||||
const i = holeOrder.indexOf(activeHole)
|
||||
setCurrentHole(holeOrder[(i + 1) % holeOrder.length])
|
||||
setPanelTab("score")
|
||||
scrollToHolePanel()
|
||||
}
|
||||
|
||||
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
|
||||
|
|
@ -525,18 +535,50 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
{showEditRound ? (
|
||||
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
|
||||
) : (
|
||||
{/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme
|
||||
nøytrale trigger-stil som hverandre) -- disse lå tidligere som
|
||||
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 må man aktivt scrolle OPP
|
||||
forbi hull-registreringen for å nå dem i det hele tatt. */}
|
||||
<div className="mb-5 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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"
|
||||
>
|
||||
<Settings2 aria-hidden="true" className="size-4" />
|
||||
Rediger runde
|
||||
</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>
|
||||
|
||||
|
|
@ -545,6 +587,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
<CompletedBanner
|
||||
players={players}
|
||||
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 nå 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)} />}
|
||||
|
||||
{/* Hole navigation */}
|
||||
|
|
@ -581,27 +631,39 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
isPlayed={holeIsPlayed}
|
||||
onSelect={(n) => {
|
||||
setCurrentHole(n)
|
||||
setPanelTab("score")
|
||||
scrollToHolePanel()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Current hole panel */}
|
||||
{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">
|
||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}
|
||||
</h2>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{showGir && (
|
||||
<span className="shrink-0 rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
|
||||
<span className="rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
|
||||
GIR
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activePlayer.statLevel === "full" && <PanelTabs value={panelTab} onChange={setPanelTab} />}
|
||||
|
||||
{(activePlayer.statLevel !== "full" || panelTab === "score") ? (
|
||||
{/* Slag/Putter og (ved statLevel="full") alle detaljene vises
|
||||
ALLTID samlet under hverandre -- ikke bak en fane man må
|
||||
oppdage og trykke på (funnet 2026-07-25: brukeren huket
|
||||
av "All statistikk", men fikk aldri se noe utover
|
||||
slag/putter siden det lå bak en fane). */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<NumberPicker
|
||||
label="Slag"
|
||||
|
|
@ -626,9 +688,48 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{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 ? (
|
||||
|
|
@ -692,21 +793,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
<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}
|
||||
/>
|
||||
|
||||
<NumberPicker
|
||||
label="Anywayslag"
|
||||
value={currentStat.anywayStrokes}
|
||||
|
|
@ -719,6 +805,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -759,7 +832,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
|
||||
// --- Completed banner ------------------------------------------------------
|
||||
|
||||
function CompletedBanner({ players, duration }: { players: Player[]; duration: string | null }) {
|
||||
function CompletedBanner({
|
||||
players,
|
||||
duration,
|
||||
roundId,
|
||||
}: {
|
||||
players: Player[]
|
||||
duration: string | null
|
||||
roundId: string
|
||||
}) {
|
||||
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="flex items-center gap-3">
|
||||
|
|
@ -800,6 +881,286 @@ function CompletedBanner({ players, duration }: { players: Player[]; duration: s
|
|||
)
|
||||
})}
|
||||
</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">
|
||||
Så 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 på 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 på innspill:{" "}
|
||||
<strong className="text-foreground">{formatSignedAvg(avgToParApproachHit)}</strong>
|
||||
</span>
|
||||
)}
|
||||
{avgToParApproachMiss !== null && (
|
||||
<span>
|
||||
Til par ved bom på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) -----------------------------
|
||||
|
||||
function DirButton({
|
||||
|
|
|
|||
486
frontend/components/round-scorecard.tsx
Normal file
486
frontend/components/round-scorecard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1018
frontend/components/round-stats.tsx
Normal file
1018
frontend/components/round-stats.tsx
Normal file
File diff suppressed because it is too large
Load diff
BIN
screen-20260724-133013-1784892586987.mp4
Normal file
BIN
screen-20260724-133013-1784892586987.mp4
Normal file
Binary file not shown.
BIN
screen-20260724-152328-1784899395856.mp4
Normal file
BIN
screen-20260724-152328-1784899395856.mp4
Normal file
Binary file not shown.
Loading…
Reference in a new issue