Live. Kort oppsummert:

Taltastaturet for Slag viser nå Eagle/Birdie/Par/Bogey/Dobbel bogey under hvert tall, regnet ut fra hullets par — samme prinsipp som videoen, ingen hoderegning nødvendig.
Automatisk fremdrift: når en spillers registrering er "god nok" (slag, eller slag+putter avhengig av statistikknivå), viser knappen nederst enten "Neste: {navn}" (bytter aktiv spiller på samme hull) eller "Neste hull" (er hen den siste, går videre og starter på spiller 1 igjen) — ingen manuell skrolling opp til spillerlisten for å bytte spiller.
Bevisst forskjell fra GameBook: ikke en full skjermovertakende veiviser (det ville vært et større og mer risikofylt bygg uten visuell testing) — samme side, men med den friksjonsreduserende logikken lagt inn. Test gjerne selv, spesielt hvordan det føles å gå gjennom begge spillerne på et hull etter hverandre.
This commit is contained in:
Erol Haagenrud 2026-07-26 15:48:07 +02:00
parent 5d319f1b68
commit e2ea0c0fe3
9 changed files with 1371 additions and 241 deletions

View file

@ -49,7 +49,39 @@
"Bash(grep -n \"participants/\\\\${\" /opt/teecup/frontend/components/round-detail.tsx)", "Bash(grep -n \"participants/\\\\${\" /opt/teecup/frontend/components/round-detail.tsx)",
"Bash(python3 -)", "Bash(python3 -)",
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)", "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")" "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")",
"Bash(tee /tmp/ui_round_setup.json)",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/ui_round_setup.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/ui_round_setup.json'\\)\\)['session_cookie']\\)\")",
"Bash(docker cp *)",
"Bash(mkdir -p /opt/teecup/frontend/app/my-rounds/\\\\[id\\\\]/leaderboard)",
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_leaderboard_rank_logic.mjs)",
"Bash(tee /tmp/lb_setup.json)",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup.json'\\)\\)['session_cookie']\\)\")",
"Bash(grep -n '^---$' /opt/teecup/FEATURE_BACKLOG.md)",
"Bash(awk -F: '$1 > 1225 && $1 < 1260')",
"Bash(tee /tmp/lb_setup2.json)",
"Bash(python3 -c ' *)",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup2.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup2.json'\\)\\)['session_cookie']\\)\")",
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_leaderboard_points_mode.mjs)",
"Bash(tee /tmp/lb_setup3.json)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(d['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup3.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup3.json'\\)\\)['session_cookie']\\)\")",
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hole_secondary.mjs)",
"Bash(tee /tmp/lb_setup4.json)",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup4.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup4.json'\\)\\)['session_cookie']\\)\")",
"Bash(ffprobe -v error -show_entries format=duration,size -show_entries stream=width,height,codec_name -of default=noprint_wrappers=0 /opt/teecup/screen-20260726-152057-1785072037213.mp4)",
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_logic.mjs)",
"Bash(tee /tmp/wizard_setup.json)",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")"
],
"additionalDirectories": [
"/opt/teecup/frontend/app/my-rounds/[id]"
] ]
} }
} }

227
CLAUDE.md
View file

@ -3205,19 +3205,224 @@ Ferdig og verifisert:
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d `test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
--build teecup_api teecup_frontend`. Begge containere boot-et rent, --build teecup_api teecup_frontend`. Begge containere boot-et rent,
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket. `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
- **Spillerliste-redesign HÅNDKODET (2026-07-26), IKKE ENNÅ RULLET UT:**
brukeren gikk tom for V0-credits rett etter at prompten over ble sendt.
Spurt eksplisitt (AskUserQuestion): bygg direkte, eller vent på fornyede
credits — svar: bygg direkte. Bygget nøyaktig etter samme prompt/data-
kontrakt som allerede var skrevet (se FEATURE_BACKLOG.md for full
detalj), i samme Tailwind/shadcn-stil som resten av `round-detail.tsx`.
Ny `PlayerList` (erstatter `PlayerTabs`) — vertikal liste av spillerkort,
hvert kort en stor "velg som aktiv spiller"-knapp + separate Rediger-/
fjern-knapper (unngår nestede interaktive elementer), "Deg"/"Eier" som
`Badge`-komponenter. `EditParticipantPanel` utvidet med statistikknivå
(den frittstående `StatLevelPicker` er nå død kode, fjernet) og — kun
for gjester — Navn/Kjønn/E-post, med reaktivt utslagsfilter ved
kjønnsendring. "+ Medspiller"-flyten flyttet inn i selve `PlayerList`.
**Verifisert:** ekte typesjekket produksjonsbuild kompilerte rent, pluss
en engangs `next dev`-container mot scratch-backend (ekte runde med en
gjest med avvikende utslag/kjønn/e-post) — siden ga 200, ingen Next.js-
feilside. **Ærlig begrensning:** siden er en klient-komponent (data
hentes etter hydrering), så server-HTML-en viser kun last-skjelettet —
INGEN ekte nettleser-interaksjonstest utført (intet nettleserverktøy
tilgjengelig).
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen ny migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket. **Bruker bør selv klikke gjennom flyten**
(spesielt gjeste-kjønnsendringens reaktive utslagsfilter og "velg vs.
rediger"-trykkflatene) før full tillit.
- **Tildelte slag + jevn korthøyde + rundeleaderboard, HÅNDKODET, IKKE ENNÅ
RULLET UT (2026-07-26):** to oppfølgingspunkter samme dag, full detalj i
FEATURE_BACKLOG.md. (1) Spillerkortet manglet "tildelte slag" (course
handicap for runden) og hadde variabel høyde — rettet: `Player` fikk
`courseHandicap`, kortet fikk fast `min-h-[68px]` med begge tekstlinjer
trunkert til én linje (ikke wrap), alle kort like høye uansett innhold.
(2) Brukeren spurte om jeg "med designerbrillene på" trodde jeg kunne få
rundeleaderboardet til å se like profesjonelt ut som V0 — svarte ja
(gjenbruk av etablerte mønstre, ikke fri utforskning) og bygget det: ny
`components/round-leaderboard.tsx` + rute `/my-rounds/[id]/leaderboard`,
gjenbruker `ScoreMark`s "form + farge"-språk fra `round-scorecard.tsx`
(som `ToParMark`), samme WS-sanntid-mønster som `round-stats.tsx`, delt
plassering ("T-N"), brutto/netto-veksling, mini-variant på rundens egen
side. Rangeringslogikken VERIFISERT UAVHENGIG i et frittstående
Node-script (19/19, inkl. tie-håndtering og uspilte spillere), pluss en
fersk scratch-runde med ekte API-kall som bekreftet leaderboard-JSON-en
stemte. Ekte typesjekket produksjonsbuild kompilerte rent. Samme ærlige
begrensning som over: ingen ekte nettleser-interaksjonstest.
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket.
- **Hullscorer i leaderboardet + lenke fra rundelisten, BYGGET, IKKE ENNÅ
RULLET UT (2026-07-26):** brukeren presiserte rett etter forrige rulling
tre krav: leaderboardet skal ha en egen visning (allerede tilfellet, se
over), med lenke fra scoreføringssiden (allerede der, `RoundLeaderboardMini`)
OG fra rundelisten (`/my-rounds`, manglet), og skal vise hullscorer
(manglet helt -- kun aggregerte tall fantes).
**Backend:** `LeaderboardEntryOut` (`GET /rounds/{id}/leaderboard`) fikk
et nytt `holes: list[LeaderboardHoleOut]`-felt (hole_number/par/
stroke_index/played/score) -- data var ALLEREDE hentet per deltaker for
å beregne total_score/net_score_to_par, bare aldri eksponert rått før nå.
Ren tilføyelse, ingen migrasjon.
**Frontend:** `round-leaderboard.tsx` sine rader er nå klikkbare
(utvider/kollapser, default kollapset -- samme "trekkspill starter
lukket"-konvensjon som `round-stats.tsx`) og viser en horisontal strip
med per-hull-merker (`HoleMark`/`HoleStrip`, egen lokal variant av
`ScoreMark`s "form + farge"-språk fra `round-scorecard.tsx`, tilpasset en
kompakt flerspiller-liste i stedet for én tabell). `round-card.tsx`
(brukt av BÅDE `/my-rounds`-listen og dashbordets "Kommende runder") fikk
en ny "Se leaderboard"-lenke for runder med mer enn én deltaker --
krevde å gjøre om kortets ytre element fra selve lenken til en `<div>`
med lenken som ETT av to barn (unngår nestet `<a>`, samme feilklasse som
tidligere nestet-form-/knapp-feller i prosjektet).
**Verifisert:** backend-endringen bekreftet mot en fersk scratch-runde
(ekte API-kall, `holes`-arrayet inneholder riktige 18 rader per deltaker
inkl. korrekt `played`/`score` for både spilte og uspilte hull), full
regresjon av forrige rundes 35-punkts co-player-testsuite fortsatt grønn,
`test_isolation.sql` 12/12 (uendret, ingen migrasjon). Ekte typesjekket
produksjonsbuild kompilerte rent (ingen nye ruter, kun endrede
komponenter). Samme engangs `next dev`-container-sjekk som tidligere --
`/my-rounds`, `/my-rounds/[id]` og `/my-rounds/[id]/leaderboard` ga alle
200, ingen Next.js-feilside.
**Samme ærlige begrensning som tidligere håndkodede runder:** ingen ekte
nettleser-interaksjonstest (klikk for å utvide en rad, se hull-stripen,
se "Se leaderboard"-lenken i rundelisten) er utført.
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket.
- **Leaderboard-oppfølging: poeng-modus + minimalistiske rader, BYGGET, IKKE
ENNÅ RULLET UT (2026-07-26):** brukeren påpekte at brutto/netto ble
presentert forskjellig (bryteren byttet BÅDE hvilket tall som vises OG
hele rangeringsrekkefølgen -- forvirrende hopp), og spurte om et eget
stableford-alternativ burde vært med. Foreslo og fikk bekreftet: gi alle
tre modiene (nå: Brutto/Netto/**Poeng**) IDENTISK radform (rangering,
navn, ETT tall, ferdig) i stedet for å prøve å vise flere tall samtidig.
Samtidig ba brukeren om å fjerne alt fra den kollapsede raden utover
navn+tall (Deg/Eier/pokal/hull-spilt-tekst) og fjerne selve
"kort"-følelsen -- radene skal flyte sømløst sammen, kun ekspandere ved
klikk.
**Backend:** `LeaderboardHoleOut` fikk `strokes_received` (samme
allerede-beregnede allokering som `net_score_to_par`, nå eksponert per
hull også). `LeaderboardEntryOut` fikk `total_points` (stableford-total,
samme formel/betingelse som `net_score_to_par` -- null uten HCP). Ingen
migrasjon.
**Frontend (`round-leaderboard.tsx`):** `Mode` utvidet til tre verdier,
poeng rangeres SYNKENDE (motsatt av til-par). Ny `PointsMark`/`ValueMark`
(poeng har ingen retning, kun fylt/uthevet for lederen). Listen er nå ÉN
sammenhengende `<ul>` (`divide-y`, kun ytterkanten avrundet/rammet) i
stedet for separate kort med mellomrom mellom hver rad -- ingen
per-rad-bakgrunn utenom en svak tone på en UTVIDET rad. Kollapset rad:
kun rangering+navn+tall+pil. Deg/Eier/pokal/fremdrift FLYTTET (ikke
fjernet) inn i den utvidbare seksjonen sammen med hull-stripen.
**Verifisert:** rangeringslogikken for poeng-modus (synkende sortering,
delt plassering, uten-HCP sortert nederst) UAVHENGIG testet i Node
(10/10). Selve stableford-formelen verifisert mot en fersk scratch-runde
med HÅNDREGNET forventet resultat (course handicap 9 over 18 hull →
slag KUN på de 9 laveste stroke-index-hullene, ikke jevnt fordelt --
fanget en feilaktig antakelse i testens FØRSTE versjon, rettet før
den ble rapportert som bestått) -- 11/11, inkl. kryssjekk av
`total_points`/`net_score_to_par` mot uavhengig rekalkulering fra de
samme rå hull-dataene API-et returnerte. Full regresjon (35-punkts
co-player-testsuite) fortsatt grønn, `test_isolation.sql` 12/12. Ekte
typesjekket produksjonsbuild kompilerte rent. Samme engangs `next
dev`-container-sjekk som tidligere -- alle tre rutene ga 200.
**Samme ærlige begrensning:** ingen ekte nettleser-interaksjonstest av
det faktiske "sømløse rader"-uttrykket eller poeng-modusen.
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket.
- **Hullscorer i leaderboardet: netto/poeng under brutto, BYGGET, IKKE ENNÅ
RULLET UT (2026-07-26):** brukeren viste to referansebilder fra en
konkurrentapp (Golf GameBook) sin leaderboard-visning -- brutto fremhevet
(fylt/farget merke) på én rad, netto/stableford i vanlig tekst RETT under,
eksplisitt presisert at det skal se likt STRUKTURELT ut men IKKE være en
kopi visuelt. Ren frontend-endring, ingen backend-endring (all data --
`strokes_received` per hull -- var allerede lagt til forrige runde samme
dag).
**`HoleMark`** (`round-leaderboard.tsx`) fikk en tredje, umerket tekstlinje
RETT under det fremhevede brutto-merket, som viser netto eller
stableford-poeng for AKKURAT det hullet -- kun i netto-/poeng-modus (ingen
ny linje i brutto-modus, ingenting nytt å vise der). Nye lokale
`netForHole()`/`pointsForHole()`-hjelpefunksjoner (samme formel som
backend sin `total_points`, men per hull -- bruker `strokes_received`
direkte fra API-et, regner ALDRI ut egen slagfordeling). `HoleStrip` fikk
en liten "Slag · Netto"/"Slag · Poeng"-bildetekst over stripen når en
sekundærrad faktisk vises.
**Bevisst IKKE en kopi:** beholder TeeCups egne farger/former (sirkel/
firkant, grønn/oransje) fra `ScoreMark`-språket som allerede var etablert
-- endret ikke fargevalg for å etterligne referansebildets blåtoner, kun
gjenskapte det STRUKTURELLE prinsippet (fremhevet brutto øverst, rolig
sekundærtall under).
**Verifisert:** netto/poeng-per-hull-formelen testet UAVHENGIG i Node
(9/9, inkl. et gulv-på-0-tilfelle og manglende-HCP/uspilt-hull), samme
tall som det håndregnede scratch-scenarioet fra forrige runde samme dag
(hull med slag: netto 3/poeng 3, hull uten slag: netto 5/poeng 1). Ekte
typesjekket produksjonsbuild kompilerte rent. Samme engangs `next
dev`-container-sjekk som tidligere -- leaderboard-ruten ga 200.
**Samme ærlige begrensning som resten av de håndkodede rundene:** ingen
ekte nettleser-interaksjonstest av selve det visuelle uttrykket.
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket.
- **Scoringsflyt: samlebånd-fremdrift + golf-term-taltastatur, BYGGET, IKKE
ENNÅ RULLET UT (2026-07-26), inspirert av en konkurrentapp (Golf
GameBook):** brukeren delte en skjermopptaksvideo av GameBooks
score-registrering og spurte om jeg forstod HVORFOR den flyten fungerer
bedre enn TeeCups, og om jeg kunne bygge det selv (ingen V0-credits
igjen). Video analysert bilde for bilde (ffmpeg i en engangs Docker-
container, samme mønster som en tidligere videoanalyse i prosjektet) --
identifiserte presist samlebånd-mønsteret: taltastatur med kontekstuelle
golf-termer per tall (Eagle/Birdie/Par/Bogey ut fra hullets par, ikke
bare "par"-knappen merket), og at fullført registrering for én spiller
automatisk åpner NESTE spillers registrering for samme hull -- uten å
måtte navigere manuelt tilbake til en spillerliste.
**Bevisst IKKE en full skjermovertakende steg-for-steg-modal** (GameBooks
egen løsning) -- vurdert som unødvendig risikofylt å bygge korrekt uten
visuell testing. I stedet: samme etablerte inline-side beholdt, men to
konkrete forbedringer lagt til:
1. `NumberPicker` sin Slag-instans fikk en ny `showGolfTerms`-modus --
HVERT synlig tall viser nå Albatross/Eagle/Birdie/Par/Bogey/Dobbel
bogey relativt til hullets par (ikke bare selve par-knappen som før).
Ny lokal `golfTermForScore()`-hjelpefunksjon.
2. Ny `isEntryComplete()`-sjekk (krever kun det `stat_level` faktisk gjør
obligatorisk -- slag alene, eller slag+putter; "full"-nivåets ekstra
detaljer forblir valgfrie og blokkerer ALDRI fremdrift) + ny
`advanceToNextPlayerOrHole()`. Bunnknappraden "Forrige/Neste hull"
endret til "Forrige hull" (uendret) + en kontekstsensitiv primærknapp
som enten viser "Neste: {navn på neste spiller}" (bytter aktiv
spiller på SAMME hull) eller "Neste hull" (er aktiv spiller den
siste, går videre til neste hull OG starter på spiller 1 igjen) --
disabled med forklarende hjelpetekst til de påkrevde feltene er fylt.
**Verifisert:** golf-term-tabellen og fullført-sjekken UAVHENGIG testet
i Node (matcher videoens egne eksempler nøyaktig, f.eks. par 4 + slag
6 = "Dobbel bogey"), samt selve samlebånds-syklusen simulert for 3
spillere over flere hull-grenser OG for en solo-runde (ingen spillerbytte,
kun hull-fremgang) -- 21/21 sjekker. Full regresjon (35-punkts
co-player-testsuite) fortsatt grønn, `test_isolation.sql` 12/12 (ingen
backend-endring denne runden). Ekte typesjekket produksjonsbuild
kompilerte rent, samme engangs `next dev`-container-sjekk som tidligere
ga 200.
**Samme ærlige begrensning som resten av de håndkodede rundene:** ingen
ekte nettleser-interaksjonstest av selve fremdrifts-følelsen (kun logikk
+ server-render bekreftet).
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket.
Neste steg: Neste steg:
0a. **Spillerliste-redesign — venter på V0-zip (2026-07-26):** utslag/HCP/ 0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet
Rediger flyttet inn i selve spillerkortet, vertikal liste, gjeste-navn/ (2026-07-26):** håndkodet (V0 tom for credits), rullet ut. Ekte
kjønn/e-post redigerbart. Backenden er live (migrasjon 029), prompten nettleser-interaksjonstest ikke utført av meg (intet slikt verktøy
er skrevet og sendt til bruker (se FEATURE_BACKLOG.md), ikke kjørt i tilgjengelig) — bruker bør selv klikke gjennom flyten (spesielt
v0.app ennå. gjeste-kjønnsendringens reaktive utslagsfilter, "velg vs. rediger"-
0b. **Rundeleaderboard — venter på V0-zip (2026-07-26):** backenden trykkflatene) før full tillit.
(`GET /rounds/{id}/leaderboard`) er live siden tidligere samme dag. 0b. **Rundeleaderboard — LIVE, men ikke nettleser-testet (2026-07-26):**
V0-prompten for selve VISNINGEN er skrevet, sendt til bruker, og håndkodet (ikke V0), backend+frontend rullet ut. Bruker bør selv teste
rettet ("Deg"/"Eier"-merke, se status over) — men ikke kjørt i brutto/netto-veksling og visuelt uttrykk før full tillit.
v0.app ennå. Leaderboardets frontend kommer i en senere runde når den
zip-en er klar.
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og 1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
venner/kategorisert deling fase 1 (ADR-036) — begge designet venner/kategorisert deling fase 1 (ADR-036) — begge designet
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE

View file

@ -1053,7 +1053,7 @@ over. Fortsatt ikke kjørt i v0.app.
--- ---
## Spillerliste-redesign (vertikal, utslag/HCP/rediger inline) — backend BYGGET, V0-prompt sendt 2026-07-26 ## Spillerliste-redesign (vertikal, utslag/HCP/rediger inline) — ✅ HELT FERDIG, håndkodet (V0 tom for credits), live 2026-07-26
Samme dag som HCP-i-søk/rediger-utslag-per-deltaker (se ADR-033-loggen i Samme dag som HCP-i-søk/rediger-utslag-per-deltaker (se ADR-033-loggen i
CLAUDE.md), rett etter at den runden var rullet ut live. Brukeren viste et CLAUDE.md), rett etter at den runden var rullet ut live. Brukeren viste et
@ -1192,12 +1192,95 @@ body: ENTEN { user_id, tee_name?, stat_level? }
ELLER { guest_name, gender, handicap_index?, guest_email?, tee_name?, stat_level? } ELLER { guest_name, gender, handicap_index?, guest_email?, tee_name?, stat_level? }
``` ```
**Ikke avgjort ennå, avklares når zip-en er klar til integrering:** nøyaktig **Oppdatering 2026-07-26: bygget HÅNDKODET i stedet for via V0.** Brukeren
plassering av "+ Medspiller"-knappen i den nye vertikale listen (nederst gikk tom for V0-credits rett etter at prompten over ble skrevet, og valgte
som et eget kort, eller en fast knapp over/under listen — din vurdering eksplisitt (spurt via AskUserQuestion) at jeg bygger det direkte fremfor å
når du ser V0s forslag), og om Rediger-flyten skal være en inline- vente på fornyede credits. Implementert etter nøyaktig samme prompt/
utvidelse av kortet eller et eget ark/modal (V0 fikk begge alternativer data-kontrakt som over, i samme Tailwind/shadcn-stil som resten av
åpne i prompten over, bevisst). `round-detail.tsx`: ny `PlayerList`-komponent (erstatter `PlayerTabs`) —
vertikal liste, hvert kort en stor `<button>` for "velg som aktiv spiller"
+ separate `Rediger`/fjern-knapper (bevisst IKKE nestede interaktive
elementer), "Deg"/"Eier" som `Badge`-komponenter (shadcn, gjenbrukt fra
`components/ui/badge.tsx` som fantes men ikke var i bruk i denne filen fra
før). `EditParticipantPanel` utvidet med statistikknivå (samme tre-valg
som den fjernede frittstående `StatLevelPicker`, nå død kode og fjernet)
og — kun for gjester (`player.userId === null`) — Navn/Kjønn/E-post,
kjønnsendring filtrerer utslagslisten reaktivt. "+ Medspiller"-flyten
(søk/gjesteskjema) flyttet fra en fast plassering utenfor spillerlisten
til å rendres INNI `PlayerList` selv, nederst i den vertikale listen.
Rediger-flyten ble en inline-utvidelse av kortet (ikke et eget ark/modal)
-- enklest å implementere korrekt uten et nytt UI-primitiv, og konsistent
med `EditRoundPanel`s eksisterende inline-mønster i samme fil.
**Ny `Player`-type-detalj funnet nødvendig under bygging:** `player.name`
er allerede viewer-relativt ("Deg" for egen rad, se `playerLabel()`) --
måtte legge til et eget `rawName` (urørt `display_name`) for å
forhåndsutfylle gjeste-navnefeltet korrekt, siden "Deg" åpenbart ikke skal
havne i et redigerbart tekstfelt.
**Verifisert:** ekte typesjekket produksjonsbuild (samme `Dockerfile`-steg
som deployes) kompilerte rent. Satte i tillegg opp en engangs `next dev`-
container mot en scratch-backend med en ekte runde (gjest med avvikende
utslag+kjønn+e-post, aktiv spiller med et registrert slag på et hull med
mottatte slag) og hentet siden sin server-rendrede HTML med en ekte
sesjonscookie -- bekreftet 200 (ikke en Next.js-feilside/digest), ruten
matcher riktig. **Viktig begrensning, ærlig flagget:** siden er en
klient-komponent (all spillerdata hentes via `fetch` i `useEffect` ETTER
hydrering) -- den server-rendrede HTML-en viser derfor kun last-skjelettet
(spinner), ikke selve spillerkortene/Rediger-panelet. Ingen ekte
nettleser-basert interaksjonstest (klikk "Rediger", bytt kjønn og se
utslagslisten filtrere reaktivt, lagre og se kortet oppdatere) er utført
-- intet nettleserverktøy tilgjengelig i denne økten. **Bruker bør selv
klikke gjennom flyten** (spesielt gjeste-kjønnsendringens reaktive
utslagsfilter og "velg vs. rediger"-trykkflatene) før full tillit.
**Oppfølging samme dag: tildelte slag + jevn korthøyde, HÅNDKODET OG LIVE.**
Brukeren rapporterte to ting rett etter forrige rullings: (1) spillerkortet
manglet "tildelte slag" (course handicap for runden, ikke selve HCP-
indeksen) når det spilles med HCP, (2) kortene burde ha lik høyde og ikke
være høyere enn nødvendig, slik at man havner under score-tastaturet uten
unødvendig scrolling. Begge rettet i `PlayerList`: `Player`-typen fikk
`courseHandicap: number | null` (fra `course_handicap_snapshot`, allerede
i API-et), lagt til i info-linjen som "Tildelte slag: N" -- KUN vist når
verdien faktisk finnes (samme betingelse som "spilles med HCP"). Kort-
knappen fikk en fast `min-h-[68px]` (var variabel `min-h-16` + fri
wrapping) med BEGGE tekstlinjer (navn+merker, utslag/HCP/tildelte slag)
trunkert til én linje hver (`truncate`, ikke wrap) -- alle kort blir dermed
like høye uansett antall merker eller lengde på navn/utslag, samtidig som
selve listen tar minst mulig vertikal plass.
**Oppfølging samme dag: rundeleaderboardet HÅNDKODET OG LIVE.** Brukeren
spurte eksplisitt om jeg, "med designerbrillene på", trodde jeg kunne få
det til å se like profesjonelt ut som V0 -- svarte ja (gjenbruk av
etablerte mønstre, ikke fri visuell utforskning) og bygget det. Ny
`components/round-leaderboard.tsx` + rute `/my-rounds/[id]/leaderboard`.
Gjenbrukte bevisst EKSISTERENDE etablerte mønstre fremfor å finne opp nye:
`signedToPar`-formateringen og "form + farge, aldri kun farge"-språket fra
`ScoreMark` i `round-scorecard.tsx` (sirkel = under par, firkant = over
par, her som et `ToParMark` for et rundetotal-tall i stedet for ett hull),
samme WS-sanntid-/viewerId-oppkoblingsmønster som `round-stats.tsx`/
`round-scorecard.tsx` (`refreshKey` bumpet av `/ws/rounds/{id}/live`),
`Badge`-komponenten fra spillerliste-redesignet over for "Deg"/"Eier".
Rangeringslogikk (delt plassering vist som "T-N", riktig "hopp over"
rangeringstall ved tie, ulik rangering brutto vs. netto, uspilte/uten
netto-tall sortert samlet nederst uten rangeringstall) og
`formatToPar`-formateringen VERIFISERT UAVHENGIG i et frittstående
Node-script (samme "test beregningen i Node"-mønster som tidligere brukt
for `round-stats.tsx` sin `computeStats()`) -- 19/19 sjekker, inkl. et
scenario med to spillere tidd for ledelsen på brutto men IKKE tidd på
netto, og et scenario med en spiller som ikke har startet ennå. Ny
`RoundLeaderboardMini`-variant (topp 3 + "og N til", hele kortet en lenke
til full side) lagt inn i `round-detail.tsx` rett under headeren -- viser
seg ikke i det hele tatt for en solo-runde (komponenten returnerer `null`
når runden har færre enn 2 deltakere).
**Verifisert:** ekte typesjekket produksjonsbuild kompilerte rent (ny rute
`/my-rounds/[id]/leaderboard` listet). Satte opp en fersk scratch-runde med
tre deltakere via ekte API-kall (to tidd på brutto til par 0, ulik netto
pga. ulik HCP, én som ikke har spilt ennå) og bekreftet leaderboard-JSON-en
matchet forventningen presist. Samme engangs `next dev`-container-sjekk
som spillerliste-redesignet -- begge rutene (`/my-rounds/[id]` med den nye
mini-varianten, `/my-rounds/[id]/leaderboard`) ga 200, ingen Next.js-
feilside. **Samme ærlige begrensning som over:** ingen ekte nettleser-
interaksjonstest (brutto/netto-veksling, faktisk visuell høyde/form-språk)
er utført.
--- ---

View file

@ -1250,6 +1250,17 @@ async def list_holes(
] ]
class LeaderboardHoleOut(BaseModel):
hole_number: int
par: int
stroke_index: int
played: bool
score: int | None
# None når deltakeren ikke har noen beregnet course handicap (2026-07-26,
# samme betingelse som net_score_to_par/total_points under).
strokes_received: int | None
class LeaderboardEntryOut(BaseModel): class LeaderboardEntryOut(BaseModel):
participant_id: str participant_id: str
display_name: str display_name: str
@ -1261,6 +1272,14 @@ class LeaderboardEntryOut(BaseModel):
# gjest uten registrert HCP) -- samme begrensning som strokes_received # gjest uten registrert HCP) -- samme begrensning som strokes_received
# i RoundHoleOut/list_holes, samme algoritme (allocate_strokes_by_index). # i RoundHoleOut/list_holes, samme algoritme (allocate_strokes_by_index).
net_score_to_par: int | None net_score_to_par: int | None
# Stableford-poengsum (2026-07-26, etterspurt av bruker) -- samme
# betingelse/algoritme som net_score_to_par (krever course handicap),
# summert over spilte hull med Math.max(0, par - netto + 2) per hull.
total_points: int | None
# Hullscorer (2026-07-26, etterspurt av bruker) -- samme rader som
# allerede ble hentet for å beregne total_score/net_score_to_par over,
# bare også eksponert rått slik at leaderboardet kan vise dem per hull.
holes: list[LeaderboardHoleOut]
class LeaderboardOut(BaseModel): class LeaderboardOut(BaseModel):
@ -1300,14 +1319,28 @@ async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current
total_par = sum(h["par"] for h in played_holes) if holes_played else None total_par = sum(h["par"] for h in played_holes) if holes_played else None
score_to_par = total_score - total_par if total_score is not None else None score_to_par = total_score - total_par if total_score is not None else None
net_score_to_par = None # Beregnes uavhengig av om noen hull faktisk er spilt ennå --
if total_score is not None and p["course_handicap_snapshot"] is not None: # brukt BÅDE for net_score_to_par/total_points under OG for
# strokes_received per hull i den rå hull-listen som returneres.
strokes_received_by_hole: dict[int, int] = {}
if p["course_handicap_snapshot"] is not None:
allocation = allocate_strokes_by_index( allocation = allocate_strokes_by_index(
p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows] p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows]
) )
strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)} strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)}
net_score_to_par = None
total_points = None
if total_score is not None and strokes_received_by_hole:
strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes) strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes)
net_score_to_par = score_to_par - strokes_received_total net_score_to_par = score_to_par - strokes_received_total
# Samme stableford-formel som round-scorecard.tsx sin
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
# gulvet på 0.
total_points = sum(
max(0, h["par"] - (h["score"] - strokes_received_by_hole[h["hole_number"]]) + 2)
for h in played_holes
)
# Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier # Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier
# OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et # OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et
@ -1322,6 +1355,18 @@ async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current
total_score=total_score, total_score=total_score,
score_to_par=score_to_par, score_to_par=score_to_par,
net_score_to_par=net_score_to_par, net_score_to_par=net_score_to_par,
total_points=total_points,
holes=[
LeaderboardHoleOut(
hole_number=h["hole_number"],
par=h["par"],
stroke_index=h["stroke_index"],
played=h["played"],
score=h["score"],
strokes_received=strokes_received_by_hole.get(h["hole_number"]),
)
for h in hole_rows
],
) )
) )

View file

@ -0,0 +1,10 @@
import { RoundLeaderboard } from "@/components/round-leaderboard"
export default async function RoundLeaderboardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <RoundLeaderboard roundId={id} />
}

View file

@ -1,5 +1,5 @@
import Link from "next/link" import Link from "next/link"
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Users } from "lucide-react" import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Trophy, Users } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type RoundStatus = "active" | "completed" export type RoundStatus = "active" | "completed"
@ -151,6 +151,13 @@ export function RoundCard({ round }: { round: Round }) {
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}` const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
return ( return (
// Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve
// lenken til rundesiden; en leaderboard-lenke kan derfor ikke ligge
// NESTET inni den (ugyldig HTML, samme klasse feil som tidligere
// nestede-form-/knapp-feller i prosjektet). Hovedinnholdet er fortsatt
// én stor lenke til rundesiden, leaderboard-lenken en egen, separat
// rad under.
<div className="flex flex-col gap-2">
<Link <Link
href={`/my-rounds/${round.id}`} href={`/my-rounds/${round.id}`}
aria-label={ariaLabel} aria-label={ariaLabel}
@ -199,5 +206,19 @@ export function RoundCard({ round }: { round: Round }) {
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/> />
</Link> </Link>
{/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn
én deltaker. Vises for BÅDE pågående og fullførte runder (en
fullført rundes sluttstilling er fortsatt nyttig å se). */}
{round.playerCount > 1 && (
<Link
href={`/my-rounds/${round.id}/leaderboard`}
className="flex min-h-11 items-center gap-1.5 self-start rounded-xl px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
>
<Trophy aria-hidden="true" className="size-4" />
Se leaderboard
</Link>
)}
</div>
) )
} }

View file

@ -34,7 +34,9 @@ import {
Trophy, Trophy,
X, X,
} from "lucide-react" } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -50,9 +52,20 @@ type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"
type Player = { type Player = {
id: string id: string
// Visningsnavn -- "Deg" for spilleren SOM SER PÅ (viewer-relativt, se
// playerLabel()), IKKE nødvendigvis den faktiske display_name.
name: string name: string
// Den faktiske, urørte display_name -- ALDRI "Deg". Brukt til å
// forhåndsutfylle "Rediger"-flytens navnefelt for en gjest (2026-07-26).
rawName: string
userId: string | null
guestEmail: string | null
isOwner: boolean
gender: Gender gender: Gender
hcp: number | null hcp: number | null
// Tildelte slag for RUNDEN (course handicap, ikke selve HCP-indeksen) --
// null når HCP ikke spores for spilleren (2026-07-26).
courseHandicap: number | null
teeName: string teeName: string
isSelf: boolean isSelf: boolean
countsForHandicap: boolean countsForHandicap: boolean
@ -100,6 +113,18 @@ function statKey(playerId: string, holeNumber: number) {
return `${playerId}:${holeNumber}` return `${playerId}:${holeNumber}`
} }
// "Er dette godt nok registrert til å gå videre til neste spiller?"
// (2026-07-26, inspirert av en konkurrentapps samlebånd-flyt: én spiller
// om gangen, automatisk videre uten å måtte navigere manuelt tilbake til
// spillerlisten). Krever KUN det stat_level faktisk gjør obligatorisk --
// "full"-nivåets ekstra detaljer (kølle/retning/chip osv.) er fortsatt
// valgfrie og blokkerer ALDRI videre fremdrift, samme som før.
function isEntryComplete(stat: HoleStat, statLevel: StatLevel): boolean {
if (stat.strokes === null) return false
if (statLevel === "strokes_only") return true
return stat.putts !== null
}
function apiGenderToUi(g: ApiGender): Gender { function apiGenderToUi(g: ApiGender): Gender {
return g === "m" ? "male" : g === "f" ? "female" : "other" return g === "m" ? "male" : g === "f" ? "female" : "other"
} }
@ -120,6 +145,8 @@ type ApiParticipant = {
id: string id: string
user_id: string | null user_id: string | null
guest_name: string | null guest_name: string | null
// Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
guest_email: string | null
// Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått // Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
// visningsnavn for en lenket bruker (eier eller medspiller). // visningsnavn for en lenket bruker (eier eller medspiller).
display_name: string display_name: string
@ -265,7 +292,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const [completing, setCompleting] = useState(false) const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false) const [showEditRound, setShowEditRound] = useState(false)
const [showEditParticipant, setShowEditParticipant] = useState(false) // Hvilken spillers "Rediger"-panel som er åpent, om noen (2026-07-26 --
// panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan
// derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere).
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
// "Flere detaljer" er kollapset som default (etterspurt av bruker // "Flere detaljer" er kollapset som default (etterspurt av bruker
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye // 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
// stablet oppå hverandre for et enkelt slag-registrering) slik at en // stablet oppå hverandre for et enkelt slag-registrering) slik at en
@ -368,8 +398,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
round?.participants.map((p) => ({ round?.participants.map((p) => ({
id: p.id, id: p.id,
name: playerLabel(p, viewerId), name: playerLabel(p, viewerId),
rawName: p.display_name,
userId: p.user_id,
guestEmail: p.guest_email,
isOwner: p.is_owner,
gender: apiGenderToUi(p.gender), gender: apiGenderToUi(p.gender),
hcp: p.handicap_index_snapshot, hcp: p.handicap_index_snapshot,
courseHandicap: p.course_handicap_snapshot,
teeName: p.tee_name_snapshot, teeName: p.tee_name_snapshot,
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner, isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
countsForHandicap: p.counts_for_handicap, countsForHandicap: p.counts_for_handicap,
@ -411,6 +446,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
hole !== null && hole !== null &&
currentStat.strokes - currentStat.putts <= hole.par - 2 currentStat.strokes - currentStat.putts <= hole.par - 2
// Samlebånd-fremdrift (2026-07-26): når aktiv spiller er ferdig registrert
// for hullet, gå til NESTE spiller i rekkefølgen -- er hen den siste,
// gå til neste hull og start på spiller 1 igjen. Fjerner behovet for å
// manuelt skrolle opp til spillerlisten og velge neste spiller for hånd.
const activePlayerIndex = activePlayer ? players.findIndex((p) => p.id === activePlayer.id) : -1
const isLastPlayer = activePlayerIndex === -1 || activePlayerIndex === players.length - 1
const nextPlayer = !isLastPlayer ? players[activePlayerIndex + 1] : null
const canAdvance = !readOnly && activePlayer !== null && isEntryComplete(currentStat, activePlayer.statLevel)
async function updateStat(patch: Partial<HoleStat>) { async function updateStat(patch: Partial<HoleStat>) {
if (readOnly || !activePlayerId || !currentApiHole) return if (readOnly || !activePlayerId || !currentApiHole) return
const merged: HoleStat = { ...currentStat, ...patch } const merged: HoleStat = { ...currentStat, ...patch }
@ -443,6 +487,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
scrollToHolePanel() scrollToHolePanel()
} }
function advanceToNextPlayerOrHole() {
if (nextPlayer) {
setActivePlayerId(nextPlayer.id)
scrollToHolePanel()
} else {
setActivePlayerId(players[0]?.id ?? null)
goNext()
}
}
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) { async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants`, { const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST", method: "POST",
@ -491,21 +545,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
await loadRound() await loadRound()
} }
async function updateStatLevel(participantId: string, statLevel: StatLevel) { // Utslag+HCP+statistikknivå (og navn/kjønn/e-post for en gjest) for en
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { // enkelt deltaker (2026-07-26) -- gjelder KUN denne
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stat_level: statLevel }),
})
if (!res.ok) return
const updated: ApiParticipant = await res.json()
setRound((prev) =>
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
)
}
// Utslag+HCP for en enkelt deltaker (2026-07-26) -- gjelder KUN denne
// runden, rører aldri spillerens egen profil. Bygget for å dekke at en // runden, rører aldri spillerens egen profil. Bygget for å dekke at en
// medspiller kan spille fra et annet utslag enn eieren, og at en foreslått // medspiller kan spille fra et annet utslag enn eieren, og at en foreslått
// HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen. // HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen.
@ -652,6 +693,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
</p> </p>
)} )}
{/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for
runder med flere enn én deltaker (komponenten returnerer selv
null ellers), lenke til hele leaderboardet. */}
<RoundLeaderboardMini roundId={roundId} />
{/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme {/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme
nøytrale trigger-stil som hverandre) -- disse tidligere som nøytrale trigger-stil som hverandre) -- disse tidligere som
store, fremtredende knapper RETT under "Neste hull"-navigasjonen store, fremtredende knapper RETT under "Neste hull"-navigasjonen
@ -712,8 +758,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
/> />
)} )}
{/* Participant tabs */} {/* Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad)
<PlayerTabs -- vertikal, ett kort per spiller. Utslag/HCP/statistikknivå +
"Rediger" ligger INNI hvert kort (samme kort er også selve
velg-aktiv-spiller-trykkflaten), i stedet for en egen rad under
listen som kun gjaldt aktiv spiller. */}
<PlayerList
roundId={roundId}
players={players} players={players}
activePlayerId={activePlayer.id} activePlayerId={activePlayer.id}
onSelect={setActivePlayerId} onSelect={setActivePlayerId}
@ -722,51 +773,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
addOpen={showAddGuest} addOpen={showAddGuest}
readOnly={readOnly} readOnly={readOnly}
canManage={isOwnerViewer} canManage={isOwnerViewer}
editingPlayerId={editingPlayerId}
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
onPatchParticipant={patchParticipant}
onAddGuest={addGuest}
onAddSearched={addSearchedParticipant}
/> />
{!readOnly && (
<StatLevelPicker
value={activePlayer.statLevel}
onChange={(v) => updateStatLevel(activePlayer.id, v)}
/>
)}
{/* Utslag+HCP for DENNE runden (2026-07-26) -- ikke alle deltakere
spiller nødvendigvis fra samme utslag som eieren, og en
auto-foreslått HCP (fra profilen ved søk) kan være feil for
akkurat denne dagen. Endrer aldri spillerens egen profil. */}
{!readOnly && (
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm text-muted-foreground">
Utslag: <span className="font-semibold text-foreground">{activePlayer.teeName}</span>
{" · "}
HCP:{" "}
<span className="font-semibold text-foreground">
{activePlayer.hcp !== null ? activePlayer.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
</span>
</span>
{isOwnerViewer && (
<button
type="button"
onClick={() => setShowEditParticipant((v) => !v)}
aria-expanded={showEditParticipant}
className="text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
{showEditParticipant ? "Lukk" : "Rediger for denne runden"}
</button>
)}
</div>
)}
{showEditParticipant && isOwnerViewer && !readOnly && (
<EditParticipantPanel
roundId={roundId}
player={activePlayer}
onPatch={patchParticipant}
onClose={() => setShowEditParticipant(false)}
/>
)}
{/* Skjules når runden er fullført (2026-07-25, rapportert av bruker {/* Skjules når runden er fullført (2026-07-25, rapportert av bruker
som "veldig mye dobbel informasjon") -- CompletedBanner sin som "veldig mye dobbel informasjon") -- CompletedBanner sin
"Se full rundestatistikk"-lenke dekker akkurat det samme, "Se full rundestatistikk"-lenke dekker akkurat det samme,
@ -774,14 +787,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
mens runden pågår. */} mens runden pågår. */}
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />} {!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
{showAddGuest && !readOnly && isOwnerViewer && (
<AddParticipantForm
onAddGuest={addGuest}
onAddSearched={addSearchedParticipant}
onCancel={() => setShowAddGuest(false)}
/>
)}
{/* Hole navigation */} {/* Hole navigation */}
{holes.length === 0 ? ( {holes.length === 0 ? (
<div className="mt-5 flex justify-center py-8"> <div className="mt-5 flex justify-center py-8">
@ -844,6 +849,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]} expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+" expandLabel="10+"
parValue={hole.par} parValue={hole.par}
showGolfTerms
onChange={(v) => updateStat({ strokes: v, played: true })} onChange={(v) => updateStat({ strokes: v, played: true })}
readOnly={readOnly} readOnly={readOnly}
/> />
@ -982,16 +988,35 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)} )}
</div> </div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold"> <Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" /> <ChevronLeft aria-hidden="true" className="size-5" />
Forrige Forrige hull
</Button> </Button>
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold"> {/* Samlebånd-knapp (2026-07-26): går til NESTE SPILLER på
Neste hull samme hull, eller -- er aktiv spiller den siste --
videre til neste hull og starter spiller 1 igjen.
Erstatter den manuelle "skroll opp, velg neste
spiller"-runden man tidligere måtte gjøre for hånd. */}
<Button
type="button"
disabled={!canAdvance}
onClick={advanceToNextPlayerOrHole}
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
>
{isLastPlayer ? "Neste hull" : `Neste: ${nextPlayer?.name}`}
<ChevronRight aria-hidden="true" className="size-5" /> <ChevronRight aria-hidden="true" className="size-5" />
</Button> </Button>
</div> </div>
{!readOnly && !canAdvance && (
<p className="text-center text-sm text-muted-foreground">
{activePlayer?.statLevel === "strokes_only"
? "Registrer slag for å gå videre."
: "Registrer slag og putter for å gå videre."}
</p>
)}
</div>
</section> </section>
)} )}
</> </>
@ -1339,7 +1364,14 @@ function DistributionBar({ segments }: { segments: { label: string; count: numbe
// --- Player tabs ----------------------------------------------------------- // --- Player tabs -----------------------------------------------------------
function PlayerTabs({ // --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) ---
// Vertikal liste av spillerkort -- hvert kort er BÅDE "velg meg som aktiv
// spiller" (hovedtrykkflaten) og bærer et eget "Rediger"-panel (utslag/HCP/
// statistikk, pluss navn/kjønn/e-post for en gjest) som en utvidbar
// underseksjon. Skalerer til mange spillere uten fane-scroll.
function PlayerList({
roundId,
players, players,
activePlayerId, activePlayerId,
onSelect, onSelect,
@ -1348,7 +1380,13 @@ function PlayerTabs({
addOpen, addOpen,
readOnly, readOnly,
canManage, canManage,
editingPlayerId,
onToggleEdit,
onPatchParticipant,
onAddGuest,
onAddSearched,
}: { }: {
roundId: string
players: Player[] players: Player[]
activePlayerId: string activePlayerId: string
onSelect: (id: string) => void onSelect: (id: string) => void
@ -1356,59 +1394,132 @@ function PlayerTabs({
onAdd: () => void onAdd: () => void
addOpen: boolean addOpen: boolean
readOnly: boolean readOnly: boolean
// Kun runde-eieren kan legge til/fjerne medspillere (ADR-036 fase // Kun runde-eieren kan legge til/fjerne/redigere medspillere (ADR-036
// 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt BYTTE // fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt
// mellom faner for å registrere score for hele flighten. // VELGE kort for å registrere score for hele flighten.
canManage: boolean canManage: boolean
editingPlayerId: string | null
onToggleEdit: (id: string) => void
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
}) { }) {
return ( return (
<div className="-mx-5 overflow-x-auto px-5"> <div className="flex flex-col gap-3" role="tablist" aria-label="Velg spiller">
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
{players.map((player) => { {players.map((player) => {
const active = player.id === activePlayerId const active = player.id === activePlayerId
const editing = editingPlayerId === player.id
const canRemove = !player.isSelf && !readOnly && canManage const canRemove = !player.isSelf && !readOnly && canManage
const canEdit = canManage && !readOnly
return ( return (
<div <div
key={player.id} key={player.id}
className={cn( className={cn(
"flex min-h-12 shrink-0 items-center gap-1 rounded-2xl border pl-4 transition-colors", "overflow-hidden rounded-2xl border transition-colors",
canRemove ? "pr-1.5" : "pr-4", active ? "border-primary" : "border-border",
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
)} )}
> >
<button type="button" role="tab" aria-selected={active} onClick={() => onSelect(player.id)} className="text-base font-bold"> <div className={cn("flex items-stretch gap-1", active ? "bg-primary/10" : "bg-card")}>
{player.name} <button
type="button"
role="tab"
aria-selected={active}
onClick={() => onSelect(player.id)}
className="flex min-h-[68px] flex-1 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-xl px-4 py-2 text-left transition-colors hover:bg-accent/50"
>
{/* Fast kort-høyde uansett innhold (2026-07-26, rapportert av
bruker) -- begge rader er ÉN linje med trunkering (ikke
wrap), slik at kort med ulikt antall merker/lange
navn/utslag likevel blir like høye, og listen samlet tar
minst mulig plass over score-tastaturet lenger ned. */}
<span className="flex w-full min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-base font-bold text-foreground">{player.name}</span>
{player.isSelf && (
<Badge variant="default" className="shrink-0">
Deg
</Badge>
)}
{player.isOwner && (
<Badge variant="outline" className="shrink-0">
Eier
</Badge>
)}
</span>
<span className="w-full truncate text-sm text-muted-foreground">
Utslag: <span className="font-semibold text-foreground">{player.teeName}</span>
{" · "}
HCP:{" "}
<span className="font-semibold text-foreground">
{player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
</span>
{/* Tildelte slag (course handicap) -- KUN vist når HCP
faktisk spores for spilleren (samme betingelse som
"spilles med hcp" -- er den null, spores ikke HCP her). */}
{player.courseHandicap !== null && (
<>
{" · "}
Tildelte slag: <span className="font-semibold text-foreground">{player.courseHandicap}</span>
</>
)}
</span>
</button> </button>
<div className="flex shrink-0 flex-col items-center justify-center gap-1 py-1.5 pr-1.5">
{canEdit && (
<button
type="button"
onClick={() => onToggleEdit(player.id)}
aria-expanded={editing}
aria-label={`Rediger ${player.name} for denne runden`}
className={cn(
"flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-xl px-2.5 text-sm font-semibold transition-colors",
editing ? "bg-primary text-primary-foreground" : "text-foreground hover:bg-accent",
)}
>
<Settings2 aria-hidden="true" className="size-4" />
<span className="hidden sm:inline">Rediger</span>
</button>
)}
{canRemove && ( {canRemove && (
<button <button
type="button" type="button"
onClick={() => onRemove(player.id)} onClick={() => onRemove(player.id)}
aria-label={`Fjern ${player.name}`} aria-label={`Fjern ${player.name}`}
className={cn( className="flex min-h-11 min-w-11 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
"flex size-8 shrink-0 items-center justify-center rounded-xl transition-colors",
active ? "text-primary-foreground/80 hover:bg-primary-foreground/20" : "text-muted-foreground hover:bg-accent",
)}
> >
<X aria-hidden="true" className="size-4" /> <X aria-hidden="true" className="size-4" />
</button> </button>
)} )}
</div> </div>
</div>
{editing && canEdit && (
<EditParticipantPanel
roundId={roundId}
player={player}
onPatch={onPatchParticipant}
onClose={() => onToggleEdit(player.id)}
/>
)}
</div>
) )
})} })}
{!readOnly && canManage && ( {!readOnly && canManage && (
addOpen ? (
<AddParticipantForm onAddGuest={onAddGuest} onAddSearched={onAddSearched} onCancel={onAdd} />
) : (
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
aria-label="Legg til medspiller" aria-label="Legg til medspiller"
aria-expanded={addOpen} className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-dashed border-border bg-card text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
className="flex min-h-12 shrink-0 items-center gap-1.5 rounded-2xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
> >
<Plus aria-hidden="true" className="size-5" /> <Plus aria-hidden="true" className="size-5" />
Medspiller Medspiller
</button> </button>
)
)} )}
</div> </div>
</div>
) )
} }
@ -1844,9 +1955,19 @@ function EditParticipantPanel({
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }> onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onClose: () => void onClose: () => void
}) { }) {
// En "midlertidig spiller" (gjest uten TeeCup-konto) har ikke egen profil
// -- navn/kjønn/e-post kan derfor redigeres her. En lenket bruker har sin
// egen konto, og disse feltene gir ingen mening (backend avviser dem også
// eksplisitt, 2026-07-26).
const isGuest = player.userId === null
const [teeOptions, setTeeOptions] = useState<ApiTeeOption[] | null>(null) const [teeOptions, setTeeOptions] = useState<ApiTeeOption[] | null>(null)
const [teeName, setTeeName] = useState(player.teeName) const [teeName, setTeeName] = useState(player.teeName)
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "") const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
const [guestName, setGuestName] = useState(player.rawName)
const [gender, setGender] = useState<Gender>(player.gender)
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -1865,7 +1986,10 @@ function EditParticipantPanel({
} }
}, [roundId]) }, [roundId])
const apiGender = uiGenderToApi(player.gender) // Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da
// umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket
// bruker er `gender` uendret gjennom hele panelets levetid.
const apiGender = uiGenderToApi(gender)
const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender)) const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender))
async function save() { async function save() {
@ -1876,6 +2000,15 @@ function EditParticipantPanel({
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
if (statLevel !== player.statLevel) body.stat_level = statLevel
if (isGuest) {
const trimmedName = guestName.trim()
if (trimmedName && trimmedName !== player.rawName) body.guest_name = trimmedName
if (gender !== player.gender) body.gender = apiGender
const trimmedEmail = guestEmail.trim()
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail
}
if (Object.keys(body).length === 0) { if (Object.keys(body).length === 0) {
setSaving(false) setSaving(false)
onClose() onClose()
@ -1891,26 +2024,63 @@ function EditParticipantPanel({
} }
return ( return (
<div className="mt-3 flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5"> <div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Rediger {player.name} for denne runden</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
</p>
{error && <p className="text-sm font-medium text-destructive">{error}</p>} {error && <p className="text-sm font-medium text-destructive">{error}</p>}
{isGuest && (
<>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="edit-participant-tee" className="text-sm font-semibold"> <Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
Navn
</Label>
<Input
id={`edit-guest-name-${player.id}`}
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
<ChoiceRow
label="Kjønn"
options={[
{ value: "male", label: "Mann" },
{ value: "female", label: "Kvinne" },
{ value: "other", label: "Annet" },
]}
value={gender}
onChange={(v) => setGender(v as Gender)}
readOnly={false}
/>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-email-${player.id}`} className="text-sm font-semibold">
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-guest-email-${player.id}`}
type="email"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
placeholder="navn@eksempel.no"
className="h-11 rounded-xl text-base"
/>
</div>
<div className="border-t border-border" />
</>
)}
<p className="text-sm text-muted-foreground">
Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-tee-${player.id}`} className="text-sm font-semibold">
Utslagssted Utslagssted
</Label> </Label>
<select <select
id="edit-participant-tee" id={`edit-tee-${player.id}`}
value={teeName} value={teeName}
onChange={(e) => setTeeName(e.target.value)} onChange={(e) => setTeeName(e.target.value)}
disabled={teeOptions === null} disabled={teeOptions === null}
@ -1924,16 +2094,16 @@ function EditParticipantPanel({
))} ))}
</select> </select>
{teeOptions !== null && availableTees.length === 0 && ( {teeOptions !== null && availableTees.length === 0 && (
<p className="text-sm text-muted-foreground">Fant ingen andre utslag med rating for dette kjønnet denne banen.</p> <p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet denne banen.</p>
)} )}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="edit-participant-hcp" className="text-sm font-semibold"> <Label htmlFor={`edit-hcp-${player.id}`} className="text-sm font-semibold">
HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span> HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label> </Label>
<Input <Input
id="edit-participant-hcp" id={`edit-hcp-${player.id}`}
inputMode="decimal" inputMode="decimal"
type="number" type="number"
step="0.1" step="0.1"
@ -1944,10 +2114,27 @@ function EditParticipantPanel({
/> />
</div> </div>
<Button type="button" disabled={saving} onClick={save} className="h-11 rounded-xl text-sm font-bold"> <ChoiceRow
label="Statistikk for denne spilleren"
options={[
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]}
value={statLevel}
onChange={(v) => setStatLevel(v as StatLevel)}
readOnly={false}
/>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" onClick={onClose} className="h-11 rounded-xl px-5 text-sm font-semibold">
Avbryt
</Button>
<Button type="button" disabled={saving} onClick={save} className="h-11 rounded-xl px-5 text-sm font-bold">
{saving ? "Lagrer…" : "Lagre endringer"} {saving ? "Lagrer…" : "Lagre endringer"}
</Button> </Button>
</div> </div>
</div>
) )
} }
@ -2260,39 +2447,23 @@ function HoleNav({
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet // slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
// 2026-07-24 på brukerens eksplisitte bekreftelse. // 2026-07-24 på brukerens eksplisitte bekreftelse.
function StatLevelPicker({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
const options: { value: StatLevel; label: string }[] = [
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Statistikk</span>
<div className="flex gap-1.5">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
// --- Number picker --------------------------------------------------------- // --- Number picker ---------------------------------------------------------
// Golf-term per slagtall relativt til par (2026-07-26, inspirert av en
// konkurrentapps taltastatur der HVER knapp er merket kontekstuelt --
// fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller
// dobbel bogey?" i hodet). Kun brukt for Slag-velgeren.
function golfTermForScore(n: number, par: number): string | null {
const diff = n - par
if (diff <= -3) return "Albatross"
if (diff === -2) return "Eagle"
if (diff === -1) return "Birdie"
if (diff === 0) return "Par"
if (diff === 1) return "Bogey"
if (diff === 2) return "Dobbel bogey"
return null
}
function NumberPicker({ function NumberPicker({
label, label,
value, value,
@ -2300,6 +2471,7 @@ function NumberPicker({
expandValues, expandValues,
expandLabel, expandLabel,
parValue, parValue,
showGolfTerms,
maxValue, maxValue,
onChange, onChange,
readOnly, readOnly,
@ -2309,10 +2481,12 @@ function NumberPicker({
directValues: number[] directValues: number[]
expandValues: number[] expandValues: number[]
expandLabel: string expandLabel: string
// Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst // Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt.
// (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten
// å måtte huske det fra hull-overskriften.
parValue?: number parValue?: number
// Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til
// parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt
// for Slag-velgeren, ikke Putter/Anywayslag.
showGolfTerms?: boolean
// Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige // Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som // antall slag registrert på hullet) -- ingen vits i å tilby et tall som
// uansett ville vært selvmotsigende. // uansett ville vært selvmotsigende.
@ -2338,14 +2512,14 @@ function NumberPicker({
<div className="grid max-w-sm grid-cols-3 gap-2.5"> <div className="grid max-w-sm grid-cols-3 gap-2.5">
{visibleValues.map((n) => { {visibleValues.map((n) => {
const selected = value === n const selected = value === n
const isPar = n === parValue const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
return ( return (
<button <button
key={n} key={n}
type="button" type="button"
disabled={readOnly} disabled={readOnly}
onClick={() => onChange(n)} onClick={() => onChange(n)}
aria-label={isPar ? `${label} ${n}, par` : `${label} ${n}`} aria-label={term ? `${label} ${n}, ${term}` : `${label} ${n}`}
aria-pressed={selected} aria-pressed={selected}
className={cn( className={cn(
"flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100", "flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
@ -2356,9 +2530,9 @@ function NumberPicker({
)} )}
> >
<span>{n}</span> <span>{n}</span>
{isPar && ( {term && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}> <span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
par {term}
</span> </span>
)} )}
</button> </button>

View file

@ -0,0 +1,560 @@
"use client"
// Leaderboard for en frittstående runde (ADR-033), 2026-07-26.
// Håndkodet (ikke via V0 -- brukeren gikk tom for credits, ba meg bygge
// direkte etter samme designspesifikasjon som var skrevet til V0-prompten,
// se FEATURE_BACKLOG.md). Gjenbruker etablerte mønstre fra
// round-scorecard.tsx (signedToPar-formatering, form-før-farge-språket fra
// ScoreMark, stableford-formelen) og round-stats.tsx/round-scorecard.tsx sin
// WS-sanntid-/viewerId-oppkobling, i stedet for å finne opp nye.
//
// Radene er BEVISST minimale i kollapset tilstand (kun rangering/navn/tall,
// 2026-07-26 -- brukeren ba om å fjerne "Deg"/"Eier"/pokal/hull-spilt-
// teksten derfra og la radene flyte sømløst i hverandre uten egne
// kort-rammer) -- den informasjonen flyttet inn i den utvidbare seksjonen
// sammen med hullscorene, ikke fjernet helt.
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Trophy } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
type ApiLeaderboardHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
strokes_received: number | null
}
type ApiLeaderboardEntry = {
participant_id: string
display_name: string
is_owner: boolean
holes_played: number
total_score: number | null
score_to_par: number | null
net_score_to_par: number | null
total_points: number | null
holes: ApiLeaderboardHole[]
}
type ApiLeaderboard = {
holes_planned: number
completed: boolean
entries: ApiLeaderboardEntry[]
}
type ApiRoundParticipant = {
id: string
user_id: string | null
}
type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
participants: ApiRoundParticipant[]
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
// --- Rangering ---------------------------------------------------------------
// Delt plassering (likt resultat) skal vises som "T-N", ikke to ulike tall.
// Deltakere uten et tall for gjeldende visning (brutto/netto/poeng) havner
// samlet nederst, uten rangeringstall -- IKKE skjult, IKKE en krasj.
// Poeng (stableford) rangeres SYNKENDE (høyest vinner) -- motsatt av
// brutto/netto til-par (lavest vinner).
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiLeaderboardEntry & {
rank: number | null
tied: boolean
isSelf: boolean
}
function valueFor(entry: ApiLeaderboardEntry, mode: Mode): number | null {
if (mode === "gross") return entry.score_to_par
if (mode === "net") return entry.net_score_to_par
return entry.total_points
}
function rankEntries(entries: ApiLeaderboardEntry[], mode: Mode, viewerParticipantId: string | null): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied, isSelf: e.participant_id === viewerParticipantId })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false, isSelf: e.participant_id === viewerParticipantId })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
// --- Verdi-merke (brutto/netto til-par, eller poeng) ------------------------
// Samme "form + farge, aldri kun farge"-språk som ScoreMark i
// round-scorecard.tsx: sirkel = under par, firkant = over par, ren tekst ved
// jevnt med par. Poeng har ingen retning (høyere er alltid bedre) -- én fast
// sirkelform, men samme visuelle vekt/fyll-mønster for lederen. Lederen
// (emphasize) får en FYLT variant for litt ekstra visuell vekt.
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
if (value === 0) {
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
}
const under = value < 0
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
under ? "rounded-full" : "rounded-[6px]",
emphasize
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary bg-primary/10 text-primary"
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{formatToPar(value)}
</span>
)
}
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
)}
>
{value}
</span>
)
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
aria-hidden="true"
>
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : ""}
</span>
)
}
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const options: { value: Mode; label: string }[] = [
{ value: "gross", label: "Brutto" },
{ value: "net", label: "Netto" },
{ value: "points", label: "Poeng" },
]
return (
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={mode === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
)
}
// --- Hullscorer per deltaker (2026-07-26, etterspurt av bruker) -----------
// Samme "form + farge, aldri kun farge"-visuelle språk som ScoreMark i
// round-scorecard.tsx (sirkel/firkant/fylt-ved-2+-av) -- egen, liten
// lokal versjon her (ikke en delt import) siden round-scorecard.tsx sin
// ScoreMark er skreddersydd for ETT hull om gangen i en tabell, ikke en
// kompakt horisontal strip for MANGE deltakere i en liste.
type HoleScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
function classifyHoleScore(gross: number, par: number): HoleScoreClass {
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"
}
// Netto/poeng PER HULL (2026-07-26, etterspurt av bruker etter å ha vist
// referansebilder fra en konkurrentapp -- bevisst IKKE en kopi av den appens
// layout/farger, kun det strukturelle prinsippet "brutto fremhevet øverst,
// netto/poeng plassert rolig under" gjenskapt i TeeCups egen stil).
// `strokes_received` er allerede backend sin ferdigberegnede allokering
// (samme som resten av appen) -- ingen egen slagfordeling regnes ut her.
function netForHole(hole: ApiLeaderboardHole): number | null {
if (!hole.played || hole.score === null || hole.strokes_received === null) return null
return hole.score - hole.strokes_received
}
function pointsForHole(hole: ApiLeaderboardHole): number | null {
const net = netForHole(hole)
if (net === null) return null
return Math.max(0, hole.par - net + 2)
}
function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondaryValue: number | null | undefined }) {
const shared = "flex size-7 items-center justify-center text-xs font-extrabold tabular-nums"
const markClass =
!hole.played || hole.score === null
? "text-muted-foreground"
: (() => {
const kind = classifyHoleScore(hole.score, hole.par)
if (kind === "eagle") return "rounded-full bg-primary text-primary-foreground"
if (kind === "birdie") return "rounded-full border-2 border-primary bg-primary/10 text-primary"
if (kind === "bogey") return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange"
if (kind === "double") return "rounded-[4px] bg-brand-orange text-brand-orange-foreground"
return "font-bold text-foreground"
})()
return (
<span className="flex w-8 shrink-0 flex-col items-center gap-0.5">
<span aria-hidden="true" className="text-[10px] font-semibold text-muted-foreground">
{hole.hole_number}
</span>
{/* Brutto -- fremhevet, samme "form + farge"-språk som resten av
appen (fylt/farget merke), øverst -- den fremste raden. */}
<span className={cn(shared, markClass)}>{hole.played && hole.score !== null ? hole.score : ""}</span>
{/* Netto/poeng -- rolig, umerket tekst RETT under, samme prinsipp
som referansebildene (aldri fremhevet samme måte som brutto).
Utelates helt i brutto-modus (ingenting nytt å vise der). */}
{secondaryValue !== undefined && (
<span className="text-[11px] font-semibold tabular-nums text-muted-foreground">
{secondaryValue !== null ? secondaryValue : ""}
</span>
)}
</span>
)
}
function HoleStrip({
holes,
startHole,
holesPlanned,
mode,
}: {
holes: ApiLeaderboardHole[]
startHole: number
holesPlanned: number
mode: Mode
}) {
// Samme sirkulære start_hole-rekkefølge som round-detail.tsx/
// round-scorecard.tsx/round-stats.tsx -- viser hullene i FAKTISK
// spillerekkefølge, ikke nødvendigvis rå hullnummer 1-18.
const order = Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1)
const ordered = order.map((n) => holes.find((h) => h.hole_number === n)).filter((h): h is ApiLeaderboardHole => h !== undefined)
const secondaryLabel = mode === "net" ? "Netto" : mode === "points" ? "Poeng" : null
return (
<div className="flex flex-col gap-1">
{secondaryLabel && (
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Slag · {secondaryLabel}
</span>
)}
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="flex gap-1.5">
{ordered.map((h) => (
<HoleMark
key={h.hole_number}
hole={h}
secondaryValue={mode === "net" ? netForHole(h) : mode === "points" ? pointsForHole(h) : undefined}
/>
))}
</div>
</div>
</div>
)
}
// --- Full leaderboard-side -----------------------------------------------
function useLeaderboardData(roundId: string) {
const [round, setRound] = useState<ApiRoundHeader | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Sanntid (samme "noe endret seg, hent på nytt"-mønster som
// round-stats.tsx/round-scorecard.tsx sin refreshKey).
const [refreshKey, setRefreshKey] = useState(0)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
fetch(`/rounds/${roundId}`, { credentials: "include" }),
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
])
if (roundRes.status === 403 || roundRes.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!roundRes.ok || !leaderboardRes.ok) {
if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.")
return
}
const roundData: ApiRoundHeader = await roundRes.json()
const leaderboardData: ApiLeaderboard = await leaderboardRes.json()
if (!cancelled) {
setRound(roundData)
setLeaderboard(leaderboardData)
}
}
void load()
return () => {
cancelled = true
}
}, [roundId, refreshKey])
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
return { round, leaderboard, viewerParticipantId, error }
}
const MODE_MISSING_LABEL: Record<Mode, string> = {
gross: "",
net: "netto-tall",
points: "poengsum",
}
export function RoundLeaderboard({ roundId }: { roundId: string }) {
const { round, leaderboard, viewerParticipantId, error } = useLeaderboardData(roundId)
const [mode, setMode] = useState<Mode>("gross")
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
// etterspurt av bruker) -- kollapset som default (samme "trekkspill
// starter lukket"-konvensjon som round-stats.tsx sine StatCard-seksjoner)
// slik at listen forblir kompakt og skalerer til mange spillere.
const [expandedId, setExpandedId] = useState<string | null>(null)
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 || !leaderboard) {
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>
)
}
const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId)
return (
<div className="min-h-dvh bg-background">
<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" />
Leaderboard
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
{/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) --
IKKE separate kort med mellomrom lenger. Kun ytterkanten er
avrundet/rammet inn; radene skilles med en tynn linje
(divide-y) og flyter sømløst i hverandre. En utvidet rad får en
svak bakgrunnstone som eneste "dette er åpnet"-signal. */}
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry) => {
const value = valueFor(entry, mode)
const isLeader = entry.rank === 1
const expanded = expandedId === entry.participant_id
return (
<li key={entry.participant_id} className={cn(expanded && "bg-muted/30")}>
<button
type="button"
onClick={() => setExpandedId((prev) => (prev === entry.participant_id ? null : entry.participant_id))}
aria-expanded={expanded}
aria-label={`Vis detaljer for ${entry.display_name}`}
className="flex w-full items-center gap-3 p-3 text-left transition-colors hover:bg-accent/30 sm:p-4"
>
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
<ChevronDown
aria-hidden="true"
className={cn("size-5 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{expanded && (
<div className="flex flex-col gap-2 px-3 pb-3 pt-0.5 sm:px-4">
{/* Det som lå i selve raden før 2026-07-26 (Deg/Eier/
leder-ikon/fremdrift) -- fortsatt tilgjengelig, bare
flyttet hit slik at den kollapsede listen forblir
ren "rangering, navn, tall". */}
<div className="flex flex-wrap items-center gap-1.5">
{entry.isSelf && <Badge variant="default">Deg</Badge>}
{entry.is_owner && <Badge variant="outline">Eier</Badge>}
{isLeader && <Trophy aria-hidden="true" className="size-4 text-primary" />}
<span className="text-sm text-muted-foreground">
{leaderboard.completed
? "Ferdig"
: entry.holes_played > 0
? `${entry.holes_played}/${leaderboard.holes_planned} hull spilt`
: "Ikke startet"}
</span>
</div>
{entry.holes_played > 0 ? (
<HoleStrip holes={entry.holes} startHole={round.start_hole} holesPlanned={leaderboard.holes_planned} mode={mode} />
) : (
<p className="py-1 text-sm text-muted-foreground">Ingen hull registrert ennå.</p>
)}
</div>
)}
</li>
)
})}
</ul>
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
<p className="text-sm text-muted-foreground">
Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering.
</p>
)}
</main>
</div>
)
}
// --- Kompakt mini-variant, til rundens egen detaljside --------------------
// Topp 3 (brutto) + "og N til", lenke til hele leaderboardet. Kun relevant
// for runder med mer enn én deltaker.
export function RoundLeaderboardMini({ roundId }: { roundId: string }) {
const { round, leaderboard, viewerParticipantId } = useLeaderboardData(roundId)
if (!round || !leaderboard || round.participants.length < 2) return null
const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId)
const top = ranked.slice(0, 3)
const restCount = ranked.length - top.length
return (
<Link
href={`/my-rounds/${roundId}/leaderboard`}
className="mb-5 flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/30"
>
<span className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
<Trophy aria-hidden="true" className="size-4 text-primary" />
Leaderboard
</span>
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</span>
<span className="flex flex-col gap-1">
{top.map((entry) => (
<span key={entry.participant_id} className="flex items-center gap-2 text-sm">
<span className="w-8 shrink-0 font-bold tabular-nums text-muted-foreground">
{entry.rank !== null ? (entry.tied ? `T-${entry.rank}` : `#${entry.rank}`) : ""}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-foreground">{entry.display_name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{entry.score_to_par !== null ? formatToPar(entry.score_to_par) : ""}
</span>
</span>
))}
</span>
{restCount > 0 && <span className="text-sm text-muted-foreground">og {restCount} til</span>}
</Link>
)
}

Binary file not shown.