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:
parent
5d319f1b68
commit
e2ea0c0fe3
9 changed files with 1371 additions and 241 deletions
|
|
@ -49,7 +49,39 @@
|
|||
"Bash(grep -n \"participants/\\\\${\" /opt/teecup/frontend/components/round-detail.tsx)",
|
||||
"Bash(python3 -)",
|
||||
"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
227
CLAUDE.md
|
|
@ -3205,19 +3205,224 @@ Ferdig og verifisert:
|
|||
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||||
--build teecup_api teecup_frontend`. Begge containere boot-et rent,
|
||||
`/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:
|
||||
0a. **Spillerliste-redesign — venter på V0-zip (2026-07-26):** utslag/HCP/
|
||||
Rediger flyttet inn i selve spillerkortet, vertikal liste, gjeste-navn/
|
||||
kjønn/e-post redigerbart. Backenden er live (migrasjon 029), prompten
|
||||
er skrevet og sendt til bruker (se FEATURE_BACKLOG.md), ikke kjørt i
|
||||
v0.app ennå.
|
||||
0b. **Rundeleaderboard — venter på V0-zip (2026-07-26):** backenden
|
||||
(`GET /rounds/{id}/leaderboard`) er live siden tidligere samme dag.
|
||||
V0-prompten for selve VISNINGEN er skrevet, sendt til bruker, og
|
||||
rettet ("Deg"/"Eier"-merke, se status over) — men ikke kjørt i
|
||||
v0.app ennå. Leaderboardets frontend kommer i en senere runde når den
|
||||
zip-en er klar.
|
||||
0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet
|
||||
(2026-07-26):** håndkodet (V0 tom for credits), rullet ut. Ekte
|
||||
nettleser-interaksjonstest ikke utført av meg (intet slikt verktøy
|
||||
tilgjengelig) — bruker bør selv klikke gjennom flyten (spesielt
|
||||
gjeste-kjønnsendringens reaktive utslagsfilter, "velg vs. rediger"-
|
||||
trykkflatene) før full tillit.
|
||||
0b. **Rundeleaderboard — LIVE, men ikke nettleser-testet (2026-07-26):**
|
||||
håndkodet (ikke V0), backend+frontend rullet ut. Bruker bør selv teste
|
||||
brutto/netto-veksling og visuelt uttrykk før full tillit.
|
||||
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
|
||||
venner/kategorisert deling fase 1 (ADR-036) — begge designet
|
||||
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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? }
|
||||
```
|
||||
|
||||
**Ikke avgjort ennå, avklares når zip-en er klar til integrering:** nøyaktig
|
||||
plassering av "+ Medspiller"-knappen i den nye vertikale listen (nederst
|
||||
som et eget kort, eller en fast knapp over/under listen — din vurdering
|
||||
når du ser V0s forslag), og om Rediger-flyten skal være en inline-
|
||||
utvidelse av kortet eller et eget ark/modal (V0 fikk begge alternativer
|
||||
åpne i prompten over, bevisst).
|
||||
**Oppdatering 2026-07-26: bygget HÅNDKODET i stedet for via V0.** Brukeren
|
||||
gikk tom for V0-credits rett etter at prompten over ble skrevet, og valgte
|
||||
eksplisitt (spurt via AskUserQuestion) at jeg bygger det direkte fremfor å
|
||||
vente på fornyede credits. Implementert etter nøyaktig samme prompt/
|
||||
data-kontrakt som over, i samme Tailwind/shadcn-stil som resten av
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
participant_id: str
|
||||
display_name: str
|
||||
|
|
@ -1261,6 +1272,14 @@ class LeaderboardEntryOut(BaseModel):
|
|||
# gjest uten registrert HCP) -- samme begrensning som strokes_received
|
||||
# i RoundHoleOut/list_holes, samme algoritme (allocate_strokes_by_index).
|
||||
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):
|
||||
|
|
@ -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
|
||||
score_to_par = total_score - total_par if total_score is not None else None
|
||||
|
||||
net_score_to_par = None
|
||||
if total_score is not None and p["course_handicap_snapshot"] is not None:
|
||||
# Beregnes uavhengig av om noen hull faktisk er spilt ennå --
|
||||
# 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(
|
||||
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)}
|
||||
|
||||
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)
|
||||
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
|
||||
# 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,
|
||||
score_to_par=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
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
10
frontend/app/my-rounds/[id]/leaderboard/page.tsx
Normal file
10
frontend/app/my-rounds/[id]/leaderboard/page.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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"
|
||||
|
||||
export type RoundStatus = "active" | "completed"
|
||||
|
|
@ -151,6 +151,13 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
|
||||
|
||||
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
|
||||
href={`/my-rounds/${round.id}`}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ import {
|
|||
Trophy,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
|
@ -50,9 +52,20 @@ type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"
|
|||
|
||||
type Player = {
|
||||
id: string
|
||||
// Visningsnavn -- "Deg" for spilleren SOM SER PÅ (viewer-relativt, se
|
||||
// playerLabel()), IKKE nødvendigvis den faktiske display_name.
|
||||
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
|
||||
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
|
||||
isSelf: boolean
|
||||
countsForHandicap: boolean
|
||||
|
|
@ -100,6 +113,18 @@ function statKey(playerId: string, holeNumber: number) {
|
|||
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 {
|
||||
return g === "m" ? "male" : g === "f" ? "female" : "other"
|
||||
}
|
||||
|
|
@ -120,6 +145,8 @@ type ApiParticipant = {
|
|||
id: string
|
||||
user_id: 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
|
||||
// visningsnavn for en lenket bruker (eier eller medspiller).
|
||||
display_name: string
|
||||
|
|
@ -265,7 +292,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
const [completing, setCompleting] = useState(false)
|
||||
const [deleting, setDeleting] = 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
|
||||
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
|
||||
// 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) => ({
|
||||
id: p.id,
|
||||
name: playerLabel(p, viewerId),
|
||||
rawName: p.display_name,
|
||||
userId: p.user_id,
|
||||
guestEmail: p.guest_email,
|
||||
isOwner: p.is_owner,
|
||||
gender: apiGenderToUi(p.gender),
|
||||
hcp: p.handicap_index_snapshot,
|
||||
courseHandicap: p.course_handicap_snapshot,
|
||||
teeName: p.tee_name_snapshot,
|
||||
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
|
||||
countsForHandicap: p.counts_for_handicap,
|
||||
|
|
@ -411,6 +446,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
hole !== null &&
|
||||
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>) {
|
||||
if (readOnly || !activePlayerId || !currentApiHole) return
|
||||
const merged: HoleStat = { ...currentStat, ...patch }
|
||||
|
|
@ -443,6 +487,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
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) {
|
||||
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||||
method: "POST",
|
||||
|
|
@ -491,21 +545,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
await loadRound()
|
||||
}
|
||||
|
||||
async function updateStatLevel(participantId: string, statLevel: StatLevel) {
|
||||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
|
||||
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
|
||||
// Utslag+HCP+statistikknivå (og navn/kjønn/e-post for en gjest) for en
|
||||
// enkelt deltaker (2026-07-26) -- gjelder KUN denne
|
||||
// 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
|
||||
// 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>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
nøytrale trigger-stil som hverandre) -- disse lå tidligere som
|
||||
store, fremtredende knapper RETT under "Neste hull"-navigasjonen
|
||||
|
|
@ -712,8 +758,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Participant tabs */}
|
||||
<PlayerTabs
|
||||
{/* Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad)
|
||||
-- vertikal, ett kort per spiller. Utslag/HCP/statistikknivå +
|
||||
"Rediger" ligger nå 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}
|
||||
activePlayerId={activePlayer.id}
|
||||
onSelect={setActivePlayerId}
|
||||
|
|
@ -722,51 +773,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
addOpen={showAddGuest}
|
||||
readOnly={readOnly}
|
||||
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
|
||||
som "veldig mye dobbel informasjon") -- CompletedBanner sin
|
||||
"Se full rundestatistikk"-lenke dekker nå akkurat det samme,
|
||||
|
|
@ -774,14 +787,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
mens runden pågår. */}
|
||||
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
||||
|
||||
{showAddGuest && !readOnly && isOwnerViewer && (
|
||||
<AddParticipantForm
|
||||
onAddGuest={addGuest}
|
||||
onAddSearched={addSearchedParticipant}
|
||||
onCancel={() => setShowAddGuest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hole navigation */}
|
||||
{holes.length === 0 ? (
|
||||
<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]}
|
||||
expandLabel="10+"
|
||||
parValue={hole.par}
|
||||
showGolfTerms
|
||||
onChange={(v) => updateStat({ strokes: v, played: true })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
|
@ -982,16 +988,35 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
|
||||
<ChevronLeft aria-hidden="true" className="size-5" />
|
||||
Forrige
|
||||
Forrige hull
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
|
||||
Neste hull
|
||||
{/* Samlebånd-knapp (2026-07-26): går til NESTE SPILLER på
|
||||
samme hull, eller -- er aktiv spiller den siste --
|
||||
videre til neste hull og starter på 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" />
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -1339,7 +1364,14 @@ function DistributionBar({ segments }: { segments: { label: string; count: numbe
|
|||
|
||||
// --- 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,
|
||||
activePlayerId,
|
||||
onSelect,
|
||||
|
|
@ -1348,7 +1380,13 @@ function PlayerTabs({
|
|||
addOpen,
|
||||
readOnly,
|
||||
canManage,
|
||||
editingPlayerId,
|
||||
onToggleEdit,
|
||||
onPatchParticipant,
|
||||
onAddGuest,
|
||||
onAddSearched,
|
||||
}: {
|
||||
roundId: string
|
||||
players: Player[]
|
||||
activePlayerId: string
|
||||
onSelect: (id: string) => void
|
||||
|
|
@ -1356,59 +1394,132 @@ function PlayerTabs({
|
|||
onAdd: () => void
|
||||
addOpen: boolean
|
||||
readOnly: boolean
|
||||
// Kun runde-eieren kan legge til/fjerne medspillere (ADR-036 fase
|
||||
// 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt BYTTE
|
||||
// mellom faner for å registrere score for hele flighten.
|
||||
// Kun runde-eieren kan legge til/fjerne/redigere medspillere (ADR-036
|
||||
// fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt
|
||||
// VELGE kort for å registrere score for hele flighten.
|
||||
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 (
|
||||
<div className="-mx-5 overflow-x-auto px-5">
|
||||
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-3" role="tablist" aria-label="Velg spiller">
|
||||
{players.map((player) => {
|
||||
const active = player.id === activePlayerId
|
||||
const editing = editingPlayerId === player.id
|
||||
const canRemove = !player.isSelf && !readOnly && canManage
|
||||
const canEdit = canManage && !readOnly
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={cn(
|
||||
"flex min-h-12 shrink-0 items-center gap-1 rounded-2xl border pl-4 transition-colors",
|
||||
canRemove ? "pr-1.5" : "pr-4",
|
||||
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
|
||||
"overflow-hidden rounded-2xl border transition-colors",
|
||||
active ? "border-primary" : "border-border",
|
||||
)}
|
||||
>
|
||||
<button type="button" role="tab" aria-selected={active} onClick={() => onSelect(player.id)} className="text-base font-bold">
|
||||
{player.name}
|
||||
<div className={cn("flex items-stretch gap-1", active ? "bg-primary/10" : "bg-card")}>
|
||||
<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>
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(player.id)}
|
||||
aria-label={`Fjern ${player.name}`}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
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"
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && canEdit && (
|
||||
<EditParticipantPanel
|
||||
roundId={roundId}
|
||||
player={player}
|
||||
onPatch={onPatchParticipant}
|
||||
onClose={() => onToggleEdit(player.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!readOnly && canManage && (
|
||||
addOpen ? (
|
||||
<AddParticipantForm onAddGuest={onAddGuest} onAddSearched={onAddSearched} onCancel={onAdd} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
aria-label="Legg til medspiller"
|
||||
aria-expanded={addOpen}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-5" />
|
||||
Medspiller
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1844,9 +1955,19 @@ function EditParticipantPanel({
|
|||
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||
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 [teeName, setTeeName] = useState(player.teeName)
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -1865,7 +1986,10 @@ function EditParticipantPanel({
|
|||
}
|
||||
}, [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))
|
||||
|
||||
async function save() {
|
||||
|
|
@ -1876,6 +2000,15 @@ function EditParticipantPanel({
|
|||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||||
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
|
||||
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) {
|
||||
setSaving(false)
|
||||
onClose()
|
||||
|
|
@ -1891,26 +2024,63 @@ function EditParticipantPanel({
|
|||
}
|
||||
|
||||
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 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>
|
||||
|
||||
<div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5">
|
||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||
|
||||
{isGuest && (
|
||||
<>
|
||||
<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
|
||||
</Label>
|
||||
<select
|
||||
id="edit-participant-tee"
|
||||
id={`edit-tee-${player.id}`}
|
||||
value={teeName}
|
||||
onChange={(e) => setTeeName(e.target.value)}
|
||||
disabled={teeOptions === null}
|
||||
|
|
@ -1924,16 +2094,16 @@ function EditParticipantPanel({
|
|||
))}
|
||||
</select>
|
||||
{teeOptions !== null && availableTees.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Fant ingen andre utslag med rating for dette kjønnet på denne banen.</p>
|
||||
<p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet på denne banen.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-participant-hcp"
|
||||
id={`edit-hcp-${player.id}`}
|
||||
inputMode="decimal"
|
||||
type="number"
|
||||
step="0.1"
|
||||
|
|
@ -1944,10 +2114,27 @@ function EditParticipantPanel({
|
|||
/>
|
||||
</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"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2260,39 +2447,23 @@ function HoleNav({
|
|||
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
|
||||
// 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 ---------------------------------------------------------
|
||||
|
||||
// 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({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -2300,6 +2471,7 @@ function NumberPicker({
|
|||
expandValues,
|
||||
expandLabel,
|
||||
parValue,
|
||||
showGolfTerms,
|
||||
maxValue,
|
||||
onChange,
|
||||
readOnly,
|
||||
|
|
@ -2309,10 +2481,12 @@ function NumberPicker({
|
|||
directValues: number[]
|
||||
expandValues: number[]
|
||||
expandLabel: string
|
||||
// Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst
|
||||
// (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten
|
||||
// å måtte huske det fra hull-overskriften.
|
||||
// Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt.
|
||||
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
|
||||
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som
|
||||
// uansett ville vært selvmotsigende.
|
||||
|
|
@ -2338,14 +2512,14 @@ function NumberPicker({
|
|||
<div className="grid max-w-sm grid-cols-3 gap-2.5">
|
||||
{visibleValues.map((n) => {
|
||||
const selected = value === n
|
||||
const isPar = n === parValue
|
||||
const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={isPar ? `${label} ${n}, par` : `${label} ${n}`}
|
||||
aria-label={term ? `${label} ${n}, ${term}` : `${label} ${n}`}
|
||||
aria-pressed={selected}
|
||||
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",
|
||||
|
|
@ -2356,9 +2530,9 @@ function NumberPicker({
|
|||
)}
|
||||
>
|
||||
<span>{n}</span>
|
||||
{isPar && (
|
||||
{term && (
|
||||
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
|
||||
par
|
||||
{term}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
560
frontend/components/round-leaderboard.tsx
Normal file
560
frontend/components/round-leaderboard.tsx
Normal 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 på 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>
|
||||
)
|
||||
}
|
||||
BIN
screen-20260726-152057-1785072037213.mp4
Normal file
BIN
screen-20260726-152057-1785072037213.mp4
Normal file
Binary file not shown.
Loading…
Reference in a new issue