Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.

"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".

Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.

Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
This commit is contained in:
Erol Haagenrud 2026-07-29 19:46:17 +02:00
parent 2ad07d09fe
commit 4b9d93941e
11 changed files with 522 additions and 222 deletions

View file

@ -0,0 +1,29 @@
-- =====================================================================
-- TeeCup — Stableford som egen spilleform + "plukket opp"-tilstand,
-- migrasjon 038. FEATURE_BACKLOG.md sitt eget notat fra 2026-07-25:
-- frittstående runder hadde ingen faktisk Stableford-FORMAT (kun rå
-- slagtall), og ingen vei til å registrere at en spiller plukket opp
-- ballen fordi hullet uansett var klart 0 poeng.
--
-- Stableford-POENGENE var allerede beregnet og vist flere steder (rund-
-- detail.tsx/round-scorecard.tsx/round-leaderboard.tsx sin stablefordPoints()
-- /total_points) -- denne migrasjonen dekker de to reelt manglende bitene:
-- (1) 'stableford' som et faktisk selvdeklarert play_format, side om side
-- med 'stroke'/'match' (samme individuelle, ikke-to-sidede kategori som
-- 'stroke' -- IKKE i _TWO_SIDED_FORMATS), (2) round_hole.picked_up.
--
-- "Plukket opp" lagres IKKE som en NULL-score -- den skrives eksplisitt
-- som Net Double Bogey (par + 2 + mottatte slag), samme cap som
-- handicap_engine.py sin max_hole_score_for_handicap() ALLEREDE bruker
-- for enhver høy score (Rule 3.1b). Dette gir automatisk nøyaktig 0
-- Stableford-poeng (samme formel som ellers) OG et korrekt AGS-bidrag
-- for faktisk-HCP (ADR-038) -- ingen ny motorlogikk trengs, kun et nytt
-- felt for VISNING ("Plukket opp" i stedet for et rått slagtall).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round_hole ADD COLUMN picked_up boolean NOT NULL DEFAULT false;
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
ALTER TABLE round ADD CONSTRAINT round_play_format_check
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford'));

162
CLAUDE.md
View file

@ -5304,6 +5304,141 @@ Ferdig og verifisert:
ingen backend-kode rørt). Begge containere boot-et rent, `/health`/ ingen backend-kode rørt). Begge containere boot-et rent, `/health`/
`/dashboard` → 200, `teeoff.no` upåvirket. `/dashboard` → 200, `teeoff.no` upåvirket.
- **Territorium-bar-språket fullført på de to siste "Matchstatus"-boksene,
BYGGET, BROWSERVERIFISERT OG LIVE (2026-07-29), samme dag -- brukeren ba
eksplisitt om "visuell konsistens ferdig helt ut" etter at jeg flagget
disse to som gjenstående:** `round-leaderboard.tsx` sin
`MatchStatusSection` (den innloggede rundens leaderboard-side) og
`watch-round.tsx` sin `MatchStatus` (den offentlige "Følger live"-siden
for en delt/synlig runde, ADR-036 fase 2) hadde begge fortsatt den gamle
flate boksen (kun stor tekst, ingen fargesone). Disse to har INGEN
navn å vise (deltakernavnene vises allerede andre steder på samme side)
-- lagt til KUN de to fargesonene + den sentrerte statuspillen (samme
`leadZoneFraction`+CSS-Grid-mønster som identitetsbanneret dagen før,
egen lokal kopi i hver fil), ikke selve `LeadZone`-identitetsboksen.
Begge filenes lokale `ApiFormatResult`-type manglet `match_lead` helt
(aldri lest her før, selv om backend alltid har eksponert det) -- lagt
til i begge.
**Verifisert i en isolert scratch-nettleserøkt** (fersk `teecup_scratch`-
database + isolert scratch-MinIO + engangs API-container, ekte `next
dev`, Chrome DevTools MCP, 390×844 mobil-viewport): bygget en ekte
OFFENTLIG (`visibility_mode:"public"`) match-runde fra bunnen via API,
bekreftet BEGGE sider (innlogget leaderboard OG anonym `/watch/{id}`)
viser identisk, korrekt fargesatt "1 UP (Side B)"-tilstand med riktig
dominant/lys sone -- OG en egen AS/0-hull-spilt-test (nøytral 50/50,
ingen farge). Begge bekreftet i BÅDE lys og mørk modus. Ingen
konsollfeil (kun en godartet, urelatert PWA-install-loggmelding). Ekte
typesjekket produksjonsbuild kjørt og bekreftet.
**Rullet ut live 2026-07-29**, ingen migrasjon (ren frontend), `docker
compose up -d --build teecup_frontend` (gjenskapte også `teecup_api`
som vanlig bivirkning, ingen backend-kode rørt). Begge containere
boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
**Territorium-bar-språket er dermed konsistent på ALLE fem stedene en
matchstatus vises i appen** (tre fulle identitetsbannere + disse to
navnløse variantene) -- listevisningene (blind draw-reveal, offentlig
turnering-live) beholder bevisst sin egen, ulike kort-per-match-stil
(farget topplinje + statuschip), siden de viser MANGE matcher samtidig
og ikke er en fokusert enkelt-match-visning.
- **Stableford som ekte spilleform + "plukket opp"-tilstand for frittstående
runder, BYGGET, GRUNDIG SCRATCH-/BROWSERVERIFISERT (inkl. TO reelle bugs
funnet OG fikset UNDER browserverifiseringen) OG LIVE (2026-07-29), samme
dag:** brukeren ba om å ta fatt på det siste, klart avgrensede hullet fra
ADR-038-gjennomgangen -- Stableford-POENGENE var allerede beregnet og vist
flere steder (round-detail.tsx/round-scorecard.tsx/round-leaderboard.tsx
sin `stablefordPoints()`/`total_points`, bygget 2026-07-25/26), men to
ting manglet reelt: (1) `'stableford'` fantes ikke som et faktisk
selvdeklarert `play_format` (kun "stroke"/"match" fantes som individuelle
format-etiketter), (2) ingen vei til å registrere at en spiller plukket
opp ballen fordi hullet uansett var klart 0 poeng.
**Design, ny migrasjon `038_stableford_and_pickup.sql`:** `round_hole.
picked_up boolean DEFAULT false`, `'stableford'` lagt til
`round_play_format_check`. "Plukket opp" lagres IKKE som en NULL-score --
serveren skriver eksplisitt Net Double Bogey (par + 2 + mottatte slag,
`handicap_engine.py` sin allerede eksisterende og testede `max_hole_
score_for_handicap()`, Rule 3.1b) som selve `score`-verdien når
`picked_up=true` sendes til `PATCH .../holes/{n}` -- dette gir automatisk
presis 0 Stableford-poeng (samme formel som ellers, ingen spesialkoding)
OG et korrekt AGS-bidrag for faktisk-HCP (ADR-038) helt uendret -- INGEN
ny motorlogikk trengs, kun ett nytt felt for VISNING ("PU"-merke i stedet
for det underliggende tallet, samme "form+farge, aldri farge alene"-språk
som resten av golfscore-visningen). `HoleUpdate` beregner strokes_received
for AKKURAT det aktuelle hullet FØR selve UPDATE-en (omstrukturert fra
tidligere "beregn etterpå"-rekkefølge) for å kunne skrive riktig NDB-verdi
i samme kall. Avvist tydelig (400 `VALIDATION_FAILED`) hvis deltakeren
ikke har noen beregnet course handicap ennå.
**To reelle bugs funnet OG fikset UNDER selve browserverifiseringen, ikke
antatt riktig fra kodegjennomgang alene** -- begge samme underliggende
mønster: eksisterende kode som spesialsjekket `play_format === "stroke"`
(for å bety "individuelt, ikke to-sidet format") måtte utvides til
eksplisitt å ekskludere `"stableford"` også, ellers falt det nye formatet
feilaktig gjennom til den TO-SIDEDE grenen:
1. `new-round.tsx` sin `needsSidesSetup = playFormat !== "stroke" &&
playFormat !== "skins"` -- et ekte skjermbilde viste "Du setter opp
sidene..."-teksten under Stableford-valget, til tross for at formatet
aldri bruker sider. Rettet ved å legge til `&& playFormat !==
"stableford"`.
2. **Alvorligere:** `round-detail.tsx` sin `FormatResultPanel` (`if
(round.play_format === "stroke" || !result) return null`) -- et ekte
skjermbilde av en fersk Stableford-runde viste en fullstendig
meningsløs "Side A"/"Side B"-territorium-bar (samme komponent bygget
tidligere samme dag for ekte to-sidede formater) med "Ingen hull
spilt ennå", siden backend sin `_build_format_result()` returnerer en
harmløs tom `ready:true`-respons for ethvert format utenfor
`_TWO_SIDED_FORMATS`/`"skins"` (inkl. det nye "stableford"), som
frontend-sjekken ikke fanget opp. Rettet samme sted (lagt til
`round.play_format === "stableford"` i den samme betingelsen), pluss
den tilhørende `useEffect` som utløser selve HTTP-kallet (unødvendig
nettverkskall for Stableford, samme fiks som for "stroke").
**Systematisk oppfølging etter de to funnene, IKKE bare disse to
stedene:** grep'et gjennom HELE frontend-kodebasen etter samme
`"stroke"`-spesialsjekk-mønster og fant tre til ekte hull i
`watch-round.tsx` (den offentlige "Følger live"-siden) -- leaderboard-
henting, `StrokeLeaderboard`-visning, og "ingen live-visning
tilgjengelig"-fallback-meldingen ville alle feilaktig behandlet en
offentlig Stableford-runde som enten to-sidet eller "ukjent format".
Alle tre rettet samme runde, FØR de kunne bli oppdaget i produksjon.
Backend-siden av samme klasse spesialsjekk (`app/routers/rounds.py`)
var allerede korrekt fra første forsøk (`_format_setup_status`,
`my_net_score_to_par`-gaten, `RoundCreate`/`RoundUpdate` sine
`Literal`-typer) -- kun frontend hadde det gjentatte hullet.
`EditRoundPanel` sin spilleform-brytar (rediger-runde-panelet) utvidet
fra to til tre valg (Slagspill/Stableford/Matchspill), samme mønster
som `new-round.tsx`.
**Scratch-verifisert grundig i to lag** (isolert `teecup_app_scratch`-
rolle + isolert scratch-MinIO + engangs API-container, alle 38
migrasjoner kjørt friskt, samme mønster som hele prosjektet): et
hånd-utregnet scenario (course handicap 18, slope 113/rating lik par,
1 slag mottatt per hull) bekreftet "plukket opp" på et par-4-hull med
1 mottatt slag ga eksakt score 7 (4+2+1, identisk med håndregning),
leaderboardets `total_points` stemte eksakt (0 for plukket-opp-hullet +
3 for et normalt netto-birdie-hull = 3), reversering (fjern plukket-
opp, sett ekte score, plukk opp igjen) fungerte, avvist tydelig for en
deltaker uten HCP (400), `_format_setup_status` bekreftet IKKE lenger
krasjer (KeyError) for stableford (`setup_complete:true` uten
sider), `RoundUpdate.play_format` PATCH til "stableford" i etterkant
bekreftet, runde fullført uten krasj.
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
samme `localhost`-ikke-`127.0.0.1`-lærdom fra tidligere runder): hele
opprett-runde-flyten klikket gjennom i UI-et (Stableford-valg, riktig
beskrivelsestekst, INGEN sider-notis etter fiksen), "Plukket opp (0
poeng)"-knappen i selve `ScoringWizard` bekreftet å auto-hoppe videre
akkurat som et vanlig slagtall, og "PU"-merket bekreftet konsekvent på
ALLE FIRE stedene det vises: scorekort-gridet på selve Score-fanen,
det post-runde `round-scorecard.tsx`-scorekortet (med korrekt "Poeng
0"/netto 6), `round-leaderboard.tsx` (både i hull-stripen og i
Poeng-modus, total "3" korrekt), og "Så langt i runden"-tabellen. En
separat REGRESJONSSJEKK av et eksisterende `match`-format (to sider,
territorium-bar, "1 UP") bekreftet UENDRET oppførsel -- de to bug-
fiksene rørte kun stableford-grenen. Ingen konsollfeil i noen av
rundene.
**Rullet ut live 2026-07-29**, bruker bekreftet eksplisitt (plan vist
FØR migrasjonen, per CLAUDE.md sin ufravikelige regel): migrasjon 038
kjørt mot ekte `teecup_db` (kolonne + utvidet CHECK-constraint
bekreftet, `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.
Neste steg: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
@ -5319,11 +5454,17 @@ Neste steg:
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
samme dag (se status over). Venner fase 2 (rundevisibilitet) og samme dag (se status over). Venner fase 2 (rundevisibilitet) og
fase 3 (ekte medspillere) er fortsatt ikke bygget. fase 3 (ekte medspillere) er fortsatt ikke bygget.
3. **Frittstående rundeføring + detaljert statistikk — ADR-033 skrevet 3. **Frittstående rundeføring + detaljert statistikk — ADR-033, skrevet
2026-07-22, IKKE bygget.** Brukeren avklarte 2026-07-22 at dette skal 2026-07-22 og siden BYGGET/ITERERT KONTINUERLIG hver dag fram til
bli appens HOVEDFOKUS (turneringsoppsett skal bli ekstremt enkelt 2026-07-29 (se hele status-loggen over -- dette punktet er nå appens
ETTER dette er på plass) — den største enkeltbeslutningen i prosjektet klart mest utviklede område, ikke lenger "ikke bygget").** Beholdt her
siden ADR-001. Fire load-bærende delbeslutninger avklart eksplisitt UENDRET som historisk kontekst for selve grunnbeslutningen fra
2026-07-22 (hvorfor/hvordan ADR-033 ble designet) -- ikke som en
påstand om at arbeidet fortsatt gjenstår. Brukeren avklarte 2026-07-22
at dette skulle bli appens HOVEDFOKUS (turneringsoppsett skulle bli
ekstremt enkelt ETTER dette var på plass) — den største enkeltbeslutningen
i prosjektet siden ADR-001, og det har stemt. Fire load-bærende
delbeslutninger avklart eksplisitt
(AskUserQuestion): nytt parallelt eierskapsmønster keyet på `user_id` (AskUserQuestion): nytt parallelt eierskapsmønster keyet på `user_id`
(gjenbruker det allerede beviste `plain_connection()`-mønsteret fra (gjenbruker det allerede beviste `plain_connection()`-mønsteret fra
personlig profil/HCP-historikk/sekundær e-post — IKKE en skjult personlig profil/HCP-historikk/sekundær e-post — IKKE en skjult
@ -5510,9 +5651,11 @@ Neste steg:
punkt (a), er nå OGSÅ tettet, se status 2026-07-28 over — punktet punkt (a), er nå OGSÅ tettet, se status 2026-07-28 over — punktet
beholdes her kun for historikk): (a) ~~offline-kø (ADR-028) er kun beholdes her kun for historikk): (a) ~~offline-kø (ADR-028) er kun
koblet til turnering-scorekortet~~ — ferdig, browserverifisert og koblet til turnering-scorekortet~~ — ferdig, browserverifisert og
live; (b) ingen Stableford-poengberegning for frittstående runder (kun live; (b) ~~ingen Stableford-poengberegning for frittstående runder
rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand; (kun rå slag/differensial), inkl. en uløst "plukket opp ballen"-
(c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends) tilstand~~ — ferdig, scratch-/browserverifisert og live 2026-07-29,
se status over (`play_format='stableford'` + `round_hole.picked_up`,
migrasjon 038); (c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre 2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
faser); (d) ~~flere flighter i én frittstående runde fortsatt kun faser); (d) ~~flere flighter i én frittstående runde fortsatt kun
@ -5521,8 +5664,7 @@ Neste steg:
koblet til venneforespørsler, ikke til rundehendelser ennå~~ — koblet til venneforespørsler, ikke til rundehendelser ennå~~ —
ferdig, scratch-verifisert og live 2026-07-28, se status over ferdig, scratch-verifisert og live 2026-07-28, se status over
(medspiller lagt til/venn ser synlig runde/tilkoblet runde fullført). (medspiller lagt til/venn ser synlig runde/tilkoblet runde fullført).
Eneste reelt gjenstående punkt i denne listen nå: (b), Stableford for Alle fem punktene (a)-(e) i denne listen er dermed ferdig.
frittstående runder.
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/ 11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
foursome/greensome/scramble) for frittstående runder — backend foursome/greensome/scramble) for frittstående runder — backend
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett, (ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,

View file

@ -2931,17 +2931,19 @@ utslag/innspill + vurdering av sveip vs. scroll) er BEVISST IKKE bygget
selv** — brukeren ba eksplisitt om at dette prompres til V0 for en egen selv** — brukeren ba eksplisitt om at dette prompres til V0 for en egen
vurdering, se egen V0-prompt utarbeidet samme dag (ikke kjørt av vurdering, se egen V0-prompt utarbeidet samme dag (ikke kjørt av
brukeren ennå ved denne loggens skriving). brukeren ennå ved denne loggens skriving).
**Fanget, IKKE bygget (brukerens egen kommentar mens punkt 7 ble **Fanget 2026-07-25, ✅ BYGGET OG LIVE 2026-07-29:** "plukket opp"-
avklart):** "plukket opp"-mulighet for Stableford-format -- i Stableford mulighet for Stableford-format -- i Stableford er det vanlig å plukke
er det vanlig å plukke opp ballen uten å fullføre hullet når det er opp ballen uten å fullføre hullet når det er klart 0 poeng uansett.
klart 0 poeng uansett. Frittstående runder har i dag INGEN Løst nøyaktig som antatt her: `'stableford'` lagt til som eget
Stableford-poengberegning i det hele tatt (kun rå slagtall for HCP- `round.play_format` (migrasjon `038_stableford_and_pickup.sql`), og
differensial) -- dette er et helt eget, udesignet format-spørsmål, ikke "plukket opp" lagres som en cap på (netto) score -- Net Double Bogey,
løst av at "spilt"-avkrysningen ble fjernet. Trenger egen designrunde samme prinsipp som WHS allerede bruker, via den eksisterende
(scoring-format-valg per runde, poengberegning, og en "plukket opp"- `max_hole_score_for_handicap()` i `handicap_engine.py` (ingen ny
tilstand som sannsynligvis bør lagres som en cap på nettoscore, samme motorlogikk). Full detalj i CLAUDE.md-status 2026-07-29, inkl. to
prinsipp som WHS sin Net Double Bogey) den dagen Stableford faktisk reelle frontend-bugs funnet og fikset under browserverifisering
bygges. (`play_format === "stroke"`-spesialsjekker som ikke ekskluderte det
nye "stableford"-formatet, samme mønster funnet og rettet fem steder
på tvers av `new-round.tsx`/`round-detail.tsx`/`watch-round.tsx`).
**Notat fra bruker, IKKE designet/bygget ennå (fanget 2026-07-22):** **Notat fra bruker, IKKE designet/bygget ennå (fanget 2026-07-22):**
brukeren har tenkt å ha med (a) måling av lengde på slag, og (b) å kunne brukeren har tenkt å ha med (a) måling av lengde på slag, og (b) å kunne
@ -3199,31 +3201,41 @@ trolig avklares SAMMEN, ikke som tre uavhengige design-runder, for å
unngå tre parallelle, litt ulike implementasjoner av i bunn og grunn unngå tre parallelle, litt ulike implementasjoner av i bunn og grunn
samme idé. samme idé.
**Åpne spørsmål, ingen besvart ennå:** **Åpne spørsmål under — HISTORISK, alle AVKLART OG BYGGET samme dag via
ADR-039 (se oppdateringsnotatet øverst i denne seksjonen). Beholdt uendret
som referanse for hvordan spørsmålene opprinnelig ble stilt, med fasiten
lagt til rett under hvert punkt — ikke slettet, per CLAUDE.md sin regel
om å ikke fjerne historikk:**
- Skal `round.play_format` utvides med `'skins'`/`'pair_team'` (ny - Skal `round.play_format` utvides med `'skins'`/`'pair_team'` (ny
migrasjon, ny CHECK-verdi), eller er dette en helt egen entitet migrasjon, ny CHECK-verdi), eller er dette en helt egen entitet
parallelt med `round`? parallelt med `round`?
**Avklart:** utvidet til 8 verdier (migrasjon 031), samme `round`-tabell.
- Match/par-lag: hvordan velges/lagres hvem som spiller mot/med hvem — - Match/par-lag: hvordan velges/lagres hvem som spiller mot/med hvem —
ved oppsett (som blind draw for turneringer), eller fritt valgt av ved oppsett (som blind draw for turneringer), eller fritt valgt av
eieren i etterkant? eieren i etterkant?
**Avklart:** ny `round_side`-tabell (maks to per runde), eieren
tildeler deltakere til en side fritt i etterkant via UI (`SidesPanel`).
- Skins: netto eller brutto, og hvordan behandles uavgjorte hull - Skins: netto eller brutto, og hvordan behandles uavgjorte hull
(rullerer premien, eller deles)? (rullerer premien, eller deles)?
**Avklart:** begge akser konfigurerbare av oppsetteren
(`skins_scoring`/`skins_tie_handling`), ny `compute_skins()`-motor.
- Skal scorekortets NYE presentasjoner (matchstatus, skins-tavle, - Skal scorekortets NYE presentasjoner (matchstatus, skins-tavle,
side-score) bygges som varianter av eksisterende komponenter side-score) bygges som varianter av eksisterende komponenter
(`round-scorecard.tsx`/`round-detail.tsx`), eller gjenbruke turnering- (`round-scorecard.tsx`/`round-detail.tsx`), eller gjenbruke turnering-
sidens `session-scorecard.tsx`-språk direkte? sidens `session-scorecard.tsx`-språk direkte?
**Avklart:** egne lokale varianter i `round-detail.tsx`/
`round-scorecard.tsx` (samme visuelle SPRÅK som `session-scorecard.tsx`,
ikke delt kode — prosjektets etablerte "én fil, én kopi"-konvensjon).
- Hvordan påvirker dette allerede byggede ADR-038 (faktisk HCP)? Match/ - Hvordan påvirker dette allerede byggede ADR-038 (faktisk HCP)? Match/
skins/par-lag-runder trenger sannsynligvis EGNE regler for om/hvordan skins/par-lag-runder trenger sannsynligvis EGNE regler for om/hvordan
de teller mot faktisk HCP (samme "matchspill telles vanligvis ikke"- de teller mot faktisk HCP (samme "matchspill telles vanligvis ikke"-
resonnement som allerede finnes for `'match'`, men skins/par-lag er resonnement som allerede finnes for `'match'`, men skins/par-lag er
ikke vurdert i det hele tatt ennå). ikke vurdert i det hele tatt ennå).
**Avklart:** delt-ball-formater (foursome/greensome/scramble) teller
**Ingen kode skrevet** — dette er bevisst kun fanget/dokumentert nå, på ALDRI (ingen individuell score å bygge en differensial fra — viste seg
brukerens eksplisitte instruks. Trenger en egen, dedikert design-/ automatisk av eksisterende `complete_round`-logikk, ingen kodeendring
ADR-runde (samme skala som ADR-033/036/037) før noe bygges, gitt at krevdes). Skins teller NORMALT som slagspill. Match følger allerede
punkt 2-4 hver krever nytt skjema, ny motorlogikk (for skins) eller etablert `exclude_from_handicap`-mønster fra ADR-038.
gjenbruk av eksisterende turnering-motor (for match/par-lag), og en
egen scorekort-presentasjon per format.
--- ---

View file

@ -1,117 +0,0 @@
# Alternativ designinstruks — PROMOTERT til DESIGN_SYSTEM.md (2026-07-29)
**Status: AVSLUTTET/ARKIVERT.** Forsøket på dashbordet ble godkjent samme
dag ("det ser bra ut") — innholdet under er nå slått sammen inn i
`DESIGN_SYSTEM.md` (som fortsatt er DEN autoritative kilden, sammen med
CLAUDE.md sin ufravikelige tilgjengelighetsregel). Denne filen beholdes
kun som historisk referanse for selve forslaget slik det opprinnelig ble
formulert — les `DESIGN_SYSTEM.md` for gjeldende regelverk, ikke denne.
## De nye instruksene (shadcn-basert, klar til bruk i prompter)
### 1. Fargesystem og Design Tokens (Shadcn-basert)
- **Bruk eksisterende CSS-variabler:** systemet bygger allerede på
semantiske variabler (`bg-background`, `text-foreground`, `bg-card`,
`border-border`, `text-muted-foreground`). Aldri hardkod
Tailwind-farger som `slate-500`/`gray-100` — det bryter den
eksisterende mørk/lys-tema-logikken.
- **Merkevarefarger:** Grønn (primær) — hovedhandling, valgt tilstand,
positivt (f.eks. HCP-nedgang). Oransje (sekundær/kontrast) — merkevarens
andre pol, brukes til oppmerksomhet/motsetninger (over par,
motstanderlag, "ventende"-indikator). **Oransje er IKKE en advarsel-
eller feilfarge.**
- **Feil/advarsler:** faktiske feil (sletting/fare) bruker `destructive`
(rød).
### 2. Typografi og lesbarhet (designet for "uten lesebriller")
- Standard brødtekst er `text-base` (16px). Ved denne størrelsen og med
god kontrast (f.eks. `text-foreground`) er `font-normal` helt
akseptabelt og foretrukket, for å unngå visuell støy. Mindre tekst
(`text-sm`) brukes kun til metadata, og bør da ofte gis `font-medium`
for å kompensere for størrelsen.
- `tabular-nums` konsekvent på all numerisk visning (HCP, score, datoer).
- Signerte tall (HCP-trend, score mot par) bruker ekte minustegn (),
aldri bindestrek (-).
### 3. Layout, avstander og "grid"
- **8-punkts rutenett:** alle marger, avstander (`gap`) og padding følger
multipler av 8 (`p-2`, `p-4`, `p-6` osv.).
- **Trykkflater:** absolutt ufravikelig krav om minimum 44×44px for enhver
interaktiv flate.
- **Kort og beholder:** `rounded-2xl`, `border border-border`, `bg-card`,
svak skygge (`shadow-sm`) i lyst tema. Tomtilstander kan bruke
`border-dashed`.
### 4. Interaksjon og navigasjon
- Alle interaktive elementer må ha tydelige tilstander for `:hover` og
`:active` — umiddelbar, taktil bekreftelse på at trykket er registrert.
- Deaktivert tilstand: `opacity-50` og `pointer-events-none`.
- `lucide-react`. Viktige handlinger skal alltid ha en tekstlabel ved
siden av ikonet. Rene ikon-knapper tillates kun for universelt
forståtte symboler (tannhjul, lukkekryss, varselbjelle) og MÅ da ha en
beskrivende `aria-label`.
### 5. Lyst og mørkt tema
- Tema styres automatisk av shadcn-variablene (punkt 1) — ingen egen
logikk trengs per skjerm.
- Dybde i mørkt tema skapes automatisk av at `bg-card` er marginalt
lysere enn `bg-background`, adskilt med `border-border`.
- Kontrastnivå skal møte WCAG AA i begge oppsett (shadcn-variablene
håndterer dette ut av boksen).
### 6. Skjemaer og inndata (mobil-først UX)
- Tekst i `<input>`/`<select>`/`<textarea>` MÅ være minst `text-base`
(16px) — mindre utløser tvungen auto-zoom på iOS.
- Riktig inndatamodus: `inputMode="numeric"`/`"decimal"` der brukeren
skriver tall (HCP, antall slag).
### 7. Safe areas og skjermkanter
- Elementer festet til topp eller bunn skal ha nok ekstra padding (`pt`/
`pb`) til at ingenting havner utenfor trygg sone (Dynamic Island/
hjem-indikator).
### 8. Bevegelse og overganger (motion)
- `transition-all duration-200 ease-in-out` for state-endringer på
knapper og kort. Appen skal føles som en lynrask "native" app.
- Ingen animasjoner/fades lenger enn 300ms.
---
## Anvendelse på dashbordet (2026-07-29)
Denne versjonen er allerede forankret i det eksisterende token-systemet
(punkt 1) og retter selv opp spenningen rundt oransje (punkt 1) — begge
ting forrige runde av dette notatet måtte avvike fra. Konkret gjort denne
runden, oppå forrige rundes 8-punkts/safe-area/`:active`-arbeid:
- **Punkt 2:** `text-sm`-metadata (bane-radens "N runder · Sist …",
"Ingen nye forespørsler") satt til `font-medium`. Body-tekst ved
`text-base` (undertittel under hilsenen) var allerede `font-normal` fra
før — ingen endring nødvendig der.
- **Punkt 8:** gikk tilbake til `transition-all duration-200 ease-in-out`
overalt (forrige runde brukte målrettede `transition-[...]`-lister av
ytelseshensyn — reversert på eksplisitt instruks).
- Alt annet (tokens, ikke-hardkodet farge, 44px-trykkflater, `:active`-
tilstander, safe-area, `tabular-nums`, ekte minustegn, `text-base`
input-felt) stod allerede riktig fra forrige runde og er uendret.
**Reell bug fikset i forrige runde, fortsatt gjeldende:** "Ny turnering"/
"Bli med med kode"-knappene i turnering-tomtilstanden var koblet til
ingenting. Rettet ved å løfte hurtighandling-skjemaets åpne/lukk-tilstand
til dashbord-roten. Bekreftet på nytt i denne runden med en ekte
klikk-gjennom i nettleser (Chrome DevTools, mobilvisning 390×844):
trykk på tomtilstand-knappen åpner nå det samme skjemaet og scroller dit.
**Verifisert i nettleser denne runden (Chrome DevTools, mobil 390×844,
isolert scratch-backend, ryddet opp etterpå):** full registrering →
obligatorisk profilutfylling → dashbord, opprettelse av en turnering via
den nettopp fikserte tomtilstand-knappen, og en visuell sjekk av BÅDE lyst
og mørkt tema (automatisk kontrast/dybde, ingen egen kode nødvendig).

View file

@ -60,6 +60,7 @@ from handicap_engine import (
handicap_index_from_differentials, handicap_index_from_differentials,
low_handicap_index, low_handicap_index,
match_play_strokes, match_play_strokes,
max_hole_score_for_handicap,
round_counts_for_handicap, round_counts_for_handicap,
round_half_up, round_half_up,
score_differential, score_differential,
@ -533,7 +534,7 @@ async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[b
oppfølging): match/skins/fourball/foursome/greensome/scramble krever oppfølging): match/skins/fourball/foursome/greensome/scramble krever
flere spillere enn slagspill -- ingenting fanget dette opp før . flere spillere enn slagspill -- ingenting fanget dette opp før .
Returnerer (komplett, forklarende melding hvis ikke).""" Returnerer (komplett, forklarende melding hvis ikke)."""
if play_format == "stroke": if play_format in ("stroke", "stableford"):
return True, None return True, None
if play_format == "skins": if play_format == "skins":
@ -633,7 +634,7 @@ class RoundCreate(BaseModel):
# trenger sider (opprettes separat via POST .../sides, se ADR-039 # trenger sider (opprettes separat via POST .../sides, se ADR-039
# Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet. # Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
play_format: Literal[ play_format: Literal[
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4" "stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford"
] = "stroke" ] = "stroke"
# Frontend forhåndsutfyller denne til True når play_format='match' # Frontend forhåndsutfyller denne til True når play_format='match'
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse. # (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
@ -733,10 +734,11 @@ class RoundOut(BaseModel):
my_holes_played: int my_holes_played: int
my_total_score: int | None my_total_score: int | None
my_score_to_par: int | None my_score_to_par: int | None
# 2026-07-29 (dashbord-rundeboks-runden): netto til-par, KUN for # 2026-07-29 (dashbord-rundeboks-runden), utvidet 2026-07-29 til også
# slagspill (`play_format == "stroke"`) og kun når HCP faktisk spores # 'stableford' (migrasjon 038): netto til-par for slagspill/Stableford,
# for spilleren (course_handicap_snapshot satt). Samme allokerings- # kun når HCP faktisk spores for spilleren (course_handicap_snapshot
# algoritme som list_holes/update_hole -- ikke en egen utregning. # satt). Samme allokeringsalgoritme som list_holes/update_hole -- ikke
# en egen utregning.
my_net_score_to_par: int | None my_net_score_to_par: int | None
# Viewer-relativ løpende matchstatus for to-sidede formater (match/ # Viewer-relativ løpende matchstatus for to-sidede formater (match/
# fourball/foursome/greensome/scramble), None for slagspill/skins eller # fourball/foursome/greensome/scramble), None for slagspill/skins eller
@ -816,7 +818,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
# kun når HCP faktisk spores for spilleren. Bruker samme allokerings- # kun når HCP faktisk spores for spilleren. Bruker samme allokerings-
# algoritme som list_holes/update_hole -- ingen ny utregningsmåte. # algoritme som list_holes/update_hole -- ingen ny utregningsmåte.
my_net_score_to_par: int | None = None my_net_score_to_par: int | None = None
if round_row["play_format"] == "stroke" and my_holes_played > 0: if round_row["play_format"] in ("stroke", "stableford") and my_holes_played > 0:
target_row = next((r for r in participant_rows if r["id"] == target_id), None) target_row = next((r for r in participant_rows if r["id"] == target_id), None)
if target_row is not None and target_row["course_handicap_snapshot"] is not None: if target_row is not None and target_row["course_handicap_snapshot"] is not None:
hole_rows = await conn.fetch( hole_rows = await conn.fetch(
@ -1609,7 +1611,7 @@ class RoundUpdate(BaseModel):
start_hole: int | None = Field(default=None, ge=1, le=18) start_hole: int | None = Field(default=None, ge=1, le=18)
# Ren metadata (ADR-038 Beslutning D) -- rører aldri AGS/differensial- # Ren metadata (ADR-038 Beslutning D) -- rører aldri AGS/differensial-
# matematikken selv, kan derfor endres uansett fullført-status. # matematikken selv, kan derfor endres uansett fullført-status.
play_format: Literal["stroke", "match"] | None = None play_format: Literal["stroke", "match", "stableford"] | None = None
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i # Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer # etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på # senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
@ -2299,6 +2301,12 @@ class RoundHoleOut(BaseModel):
stroke_index: int stroke_index: int
played: bool played: bool
score: int | None score: int | None
# "Plukket opp" (migrasjon 038, Stableford) -- sann når spilleren
# plukket opp ballen fordi hullet uansett var klart 0 poeng. `score`
# er DA fortsatt satt (til Net Double Bogey, samme cap som enhver
# annen høy score allerede får for HCP-formål), kun for VISNING skal
# dette feltet vises ("Plukket opp") i stedet for det rå tallet.
picked_up: bool
putts: int | None putts: int | None
club_off_tee: str | None club_off_tee: str | None
tee_shot_result: str | None tee_shot_result: str | None
@ -2328,7 +2336,7 @@ async def _build_participant_holes(conn, round_id: str, participant_id: str) ->
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee, SELECT hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count, tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_bucket, anyway_strokes penalty_strokes, first_putt_distance_bucket, anyway_strokes
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
@ -2833,6 +2841,8 @@ class LeaderboardHoleOut(BaseModel):
stroke_index: int stroke_index: int
played: bool played: bool
score: int | None score: int | None
# "Plukket opp" (migrasjon 038) -- se RoundHoleOut for full begrunnelse.
picked_up: bool
# None når deltakeren ikke har noen beregnet course handicap (2026-07-26, # None når deltakeren ikke har noen beregnet course handicap (2026-07-26,
# samme betingelse som net_score_to_par/total_points under). # samme betingelse som net_score_to_par/total_points under).
strokes_received: int | None strokes_received: int | None
@ -2886,7 +2896,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
entries: list[LeaderboardEntryOut] = [] entries: list[LeaderboardEntryOut] = []
for p in participant_rows: for p in participant_rows:
hole_rows = await conn.fetch( hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number", "SELECT hole_number, par, stroke_index, played, score, picked_up FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
p["id"], p["id"],
) )
played_holes = [h for h in hole_rows if h["played"] and h["score"] is not None] played_holes = [h for h in hole_rows if h["played"] and h["score"] is not None]
@ -2939,6 +2949,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
stroke_index=h["stroke_index"], stroke_index=h["stroke_index"],
played=h["played"], played=h["played"],
score=h["score"], score=h["score"],
picked_up=h["picked_up"],
strokes_received=strokes_received_by_hole.get(h["hole_number"]), strokes_received=strokes_received_by_hole.get(h["hole_number"]),
) )
for h in hole_rows for h in hole_rows
@ -3105,6 +3116,14 @@ async def get_flight_group_leaderboard(
class HoleUpdate(BaseModel): class HoleUpdate(BaseModel):
played: bool = True played: bool = True
score: int | None = Field(default=None, ge=1, le=20) score: int | None = Field(default=None, ge=1, le=20)
# "Plukket opp" (migrasjon 038, Stableford) -- spilleren tar opp ballen
# fordi hullet uansett var klart 0 poeng. `score` sendt her IGNORERES
# når dette er sant -- serveren regner selv ut Net Double Bogey (samme
# cap `adjusted_gross_score` allerede bruker for enhver høy score) og
# skriver DEN, slik at eksisterende brutto/netto/HCP-pipeline fungerer
# helt uendret. Krever at deltakeren har en beregnet course handicap
# (samme forutsetning som strokes_received ellers).
picked_up: bool = False
putts: int | None = Field(default=None, ge=0, le=10) putts: int | None = Field(default=None, ge=0, le=10)
club_off_tee: str | None = Field(default=None, max_length=50) club_off_tee: str | None = Field(default=None, max_length=50)
tee_shot_result: Literal["fairway", "left", "right"] | None = None tee_shot_result: Literal["fairway", "left", "right"] | None = None
@ -3140,21 +3159,52 @@ async def update_hole(
if participant_row is None: if participant_row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
# Hentes FØR selve UPDATE-en uansett (strokes_received-visning), og
# for "plukket opp" trengs par+strokes_received for AKKURAT dette
# hullet for å regne ut Net Double Bogey-scoren før vi skriver noe.
strokes_received = None
this_hole_par = None
all_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
participant_id,
)
this_hole_row = next((r for r in all_rows if r["hole_number"] == hole_number), None)
if this_hole_row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
this_hole_par = this_hole_row["par"]
if participant_row["course_handicap_snapshot"] is not None:
allocation = allocate_strokes_by_index(
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_rows]
)
by_hole = {r["hole_number"]: a for r, a in zip(all_rows, allocation)}
strokes_received = by_hole[hole_number]
score = body.score
played = body.played
if body.picked_up:
if strokes_received is None:
raise app_error(
400, "VALIDATION_FAILED",
"Kan ikke registrere «plukket opp» før handicap er beregnet for denne deltakeren.",
)
played = True
score = max_hole_score_for_handicap(this_hole_par, strokes_received)
async with translate_db_errors(): async with translate_db_errors():
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
UPDATE round_hole SET UPDATE round_hole SET
played = $3, score = $4, putts = $5, club_off_tee = $6, played = $3, score = $4, picked_up = $5, putts = $6, club_off_tee = $7,
tee_shot_result = $7, approach_result = $8, chip_count = $9, tee_shot_result = $8, approach_result = $9, chip_count = $10,
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_bucket = $12, bunker_shot_count = $11, penalty_strokes = $12, first_putt_distance_bucket = $13,
anyway_strokes = $13 anyway_strokes = $14
WHERE round_participant_id = $1 AND hole_number = $2 WHERE round_participant_id = $1 AND hole_number = $2
RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee, RETURNING hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count, tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_bucket, anyway_strokes penalty_strokes, first_putt_distance_bucket, anyway_strokes
""", """,
participant_id, hole_number, participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee, played, score, body.picked_up, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count, body.tee_shot_result, body.approach_result, body.chip_count,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket, body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket,
body.anyway_strokes, body.anyway_strokes,
@ -3162,22 +3212,6 @@ async def update_hole(
if row is None: if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
# strokes_received er utledet av HELE rundens stroke-indeks-rekkefølge
# (samme allokeringsalgoritme som list_holes/GET), ikke bare dette ene
# hullet -- må derfor hente alle 18 sin stroke_index for å plassere
# riktig antall mottatte slag på nøyaktig dette hullet.
strokes_received = None
if participant_row["course_handicap_snapshot"] is not None:
all_indexes = await conn.fetch(
"SELECT hole_number, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
participant_id,
)
allocation = allocate_strokes_by_index(
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_indexes]
)
by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)}
strokes_received = by_hole[hole_number]
await broadcast_round_update(round_id) await broadcast_round_update(round_id)
return RoundHoleOut(**dict(row), strokes_received=strokes_received) return RoundHoleOut(**dict(row), strokes_received=strokes_received)

View file

@ -37,6 +37,7 @@ type ApiPersonRound = {
const FORMAT_LABELS: Record<string, string> = { const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill", stroke: "Slagspill",
stableford: "Stableford",
match: "Match", match: "Match",
skins: "Skins", skins: "Skins",
fourball: "Fourball", fourball: "Fourball",

View file

@ -75,7 +75,16 @@ type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
// for frittstående runder, så dette taes brukerens ord for. Sider (for de // for frittstående runder, så dette taes brukerens ord for. Sider (for de
// to-sidede formatene) opprettes IKKE her -- det skjer i selve runden // to-sidede formatene) opprettes IKKE her -- det skjer i selve runden
// (POST .../sides), siden det krever at deltakerne allerede finnes. // (POST .../sides), siden det krever at deltakerne allerede finnes.
type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "greensome" | "scramble_2" | "scramble_4" type PlayFormat =
| "stroke"
| "match"
| "stableford"
| "skins"
| "fourball"
| "foursome"
| "greensome"
| "scramble_2"
| "scramble_4"
type SkinsScoring = "net" | "gross" type SkinsScoring = "net" | "gross"
type SkinsTieHandling = "carry" | "split" type SkinsTieHandling = "carry" | "split"
@ -103,6 +112,7 @@ const VISIBILITY_CATEGORY_OPTIONS = [
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [ const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." }, { value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
{ value: "stableford", label: "Stableford", desc: "Poeng per hull i stedet for slag -- kan plukke opp ved 0 poeng." },
{ value: "match", label: "Match", desc: "To sider, hull mot hull." }, { value: "match", label: "Match", desc: "To sider, hull mot hull." },
{ value: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." }, { value: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." },
{ value: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." }, { value: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." },
@ -1232,7 +1242,7 @@ function ConfirmStep({
// ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble // ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble
// krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må // krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må
// finnes først). Vises som en forklarende note her, ikke et skjema. // finnes først). Vises som en forklarende note her, ikke et skjema.
const needsSidesSetup = playFormat !== "stroke" && playFormat !== "skins" const needsSidesSetup = playFormat !== "stroke" && playFormat !== "stableford" && playFormat !== "skins"
async function handleStart() { async function handleStart() {
if (!selectedTee) return if (!selectedTee) return

View file

@ -85,6 +85,10 @@ type Player = {
type HoleStat = { type HoleStat = {
played: boolean played: boolean
strokes: number | null strokes: number | null
// "Plukket opp" (migrasjon 038, Stableford) -- serveren regner selv ut
// og skriver Net Double Bogey som `strokes` når dette er sant; feltet
// her er KUN til visning ("Plukket opp" i stedet for tallet).
pickedUp: boolean
putts: number | null putts: number | null
club: string club: string
teeShot: TeeShot | null teeShot: TeeShot | null
@ -106,6 +110,7 @@ function emptyStat(): HoleStat {
return { return {
played: false, played: false,
strokes: null, strokes: null,
pickedUp: false,
putts: null, putts: null,
club: "", club: "",
teeShot: null, teeShot: null,
@ -259,6 +264,7 @@ const SIDE_PLAYER_COUNT: Record<string, number> = {
} }
const FORMAT_LABELS: Record<string, string> = { const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill", stroke: "Slagspill",
stableford: "Stableford",
match: "Match", match: "Match",
skins: "Skins", skins: "Skins",
fourball: "Fourball", fourball: "Fourball",
@ -294,6 +300,7 @@ type ApiHole = {
stroke_index: number stroke_index: number
played: boolean played: boolean
score: number | null score: number | null
picked_up: boolean
putts: number | null putts: number | null
club_off_tee: string | null club_off_tee: string | null
tee_shot_result: TeeShot | null tee_shot_result: TeeShot | null
@ -328,6 +335,7 @@ function apiHoleToStat(h: ApiHole): HoleStat {
return { return {
played: h.played, played: h.played,
strokes: h.score, strokes: h.score,
pickedUp: h.picked_up,
putts: h.putts, putts: h.putts,
club: h.club_off_tee ?? "", club: h.club_off_tee ?? "",
teeShot: h.tee_shot_result, teeShot: h.tee_shot_result,
@ -344,6 +352,7 @@ function statToPatchBody(s: HoleStat) {
return { return {
played: s.played, played: s.played,
score: s.strokes, score: s.strokes,
picked_up: s.pickedUp,
putts: s.putts, putts: s.putts,
club_off_tee: s.club.trim() === "" ? null : s.club, club_off_tee: s.club.trim() === "" ? null : s.club,
tee_shot_result: s.teeShot, tee_shot_result: s.teeShot,
@ -479,10 +488,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri // Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri
// lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid // lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid
// (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill trenger // (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill/Stableford
// ingen egen henting. // (migrasjon 038) trenger ingen egen henting -- ingen sider/matchstatus.
useEffect(() => { useEffect(() => {
if (!round || round.play_format === "stroke") { if (!round || round.play_format === "stroke" || round.play_format === "stableford") {
setFormatResult(null) setFormatResult(null)
return return
} }
@ -1423,6 +1432,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
readOnly={readOnly} readOnly={readOnly}
ownBagClubs={ownBagClubs} ownBagClubs={ownBagClubs}
isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)} isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)}
playFormat={round.play_format}
strokesReceived={wizardApiHole?.strokes_received ?? null}
/> />
)} )}
</div> </div>
@ -1477,6 +1488,8 @@ function ScoringWizard({
readOnly, readOnly,
ownBagClubs, ownBagClubs,
isPending, isPending,
playFormat,
strokesReceived,
}: { }: {
players: Player[] players: Player[]
player: Player player: Player
@ -1491,6 +1504,13 @@ function ScoringWizard({
// gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen, // gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen,
// ikke bekreftet av serveren ennå. // ikke bekreftet av serveren ennå.
isPending: boolean isPending: boolean
// "Plukket opp" (migrasjon 038, Stableford) -- kun meningsfullt for
// slagspill/Stableford (match/skins/par-lag har egne, ubyggede
// hull-avgjørelsesmekanismer -- konsedering, ikke dette). Krever en
// kjent strokes_received for AKTUELT hull for at serveren skal kunne
// regne ut Net Double Bogey-scoren.
playFormat: string
strokesReceived: number | null
}) { }) {
const playerIndex = players.findIndex((p) => p.id === player.id) const playerIndex = players.findIndex((p) => p.id === player.id)
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1 const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
@ -1509,7 +1529,8 @@ function ScoringWizard({
const isFirstStep = stepIndex === 0 const isFirstStep = stepIndex === 0
const isLastStep = stepIndex === steps.length - 1 const isLastStep = stepIndex === steps.length - 1
const canProceed = step === "strokes" ? stat.strokes !== null : step === "putts" ? stat.putts !== null : true const canProceed =
step === "strokes" ? stat.strokes !== null || stat.pickedUp : step === "putts" ? stat.putts !== null : true
// Auto-hopp (2026-07-28): så snart steget sitt eget felt får en verdi, // Auto-hopp (2026-07-28): så snart steget sitt eget felt får en verdi,
// hopp videre av seg selv -- ingen "Neste"-trykk nødvendig. `enteredWith // hopp videre av seg selv -- ingen "Neste"-trykk nødvendig. `enteredWith
@ -1598,17 +1619,45 @@ function ScoringWizard({
<main className="flex-1 overflow-y-auto p-5 sm:p-6"> <main className="flex-1 overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex max-w-sm flex-col gap-6"> <div className="mx-auto flex max-w-sm flex-col gap-6">
{step === "strokes" && ( {step === "strokes" && (
<NumberPicker <>
label="Slag" <NumberPicker
value={stat.strokes} label="Slag"
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]} value={stat.strokes}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]} directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandLabel="10+" expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
parValue={hole.par} expandLabel="10+"
showGolfTerms parValue={hole.par}
onChange={(v) => onUpdate({ strokes: v, played: true })} showGolfTerms
readOnly={readOnly} onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })}
/> readOnly={readOnly}
/>
{/* "Plukket opp" (migrasjon 038, Stableford, 2026-07-29) --
kun for slagspill/Stableford, og kun når serveren faktisk
kan regne ut Net Double Bogey (krever kjent strokes_received). */}
{(playFormat === "stroke" || playFormat === "stableford") && strokesReceived !== null && (
<button
type="button"
disabled={readOnly}
aria-pressed={stat.pickedUp}
onClick={() => {
if (stat.pickedUp) {
onUpdate({ pickedUp: false, strokes: null, played: false })
return
}
onUpdate({ pickedUp: true, strokes: null, played: true })
forward()
}}
className={cn(
"flex min-h-11 items-center justify-center gap-2 rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-60",
stat.pickedUp
? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
: "border-dashed border-border bg-card text-muted-foreground hover:bg-accent/50",
)}
>
{stat.pickedUp ? "Plukket opp — trykk for å angre" : "Plukket opp (0 poeng)"}
</button>
)}
</>
)} )}
{step === "putts" && ( {step === "putts" && (
@ -1956,7 +2005,9 @@ function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: num
<tr key={h.hole_number} className="border-b border-border last:border-b-0"> <tr key={h.hole_number} className="border-b border-border last:border-b-0">
<td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td> <td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td> <td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td>
<td className="px-3 py-2 tabular-nums font-semibold text-foreground">{played ? h.score : ""}</td> <td className="px-3 py-2 tabular-nums font-semibold text-foreground">
{played ? (h.picked_up ? "PU" : h.score) : ""}
</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? ""}</td> <td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? ""}</td>
{hasNetto && ( {hasNetto && (
<td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? ""}</td> <td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? ""}</td>
@ -2610,7 +2661,7 @@ function FormatResultPanel({
players: Player[] players: Player[]
result: ApiFormatResult | null result: ApiFormatResult | null
}) { }) {
if (round.play_format === "stroke" || !result) return null if (round.play_format === "stroke" || round.play_format === "stableford" || !result) return null
if (round.play_format === "skins") { if (round.play_format === "skins") {
const won = result.skins_won ?? {} const won = result.skins_won ?? {}
@ -3170,9 +3221,12 @@ function EditRoundPanel({
const [startHole, setStartHole] = useState(String(round.start_hole)) const [startHole, setStartHole] = useState(String(round.start_hole))
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at)) const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at)) const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
// ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri // ADR-038, utvidet med Stableford (migrasjon 038) -- ren metadata, aldri
// AGS/differensial-matematikken selv). // sperret av fullført-status (rører aldri AGS/differensial-matematikken
const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke") // selv).
const currentEditablePlayFormat: "stroke" | "match" | "stableford" =
round.play_format === "match" ? "match" : round.play_format === "stableford" ? "stableford" : "stroke"
const [playFormat, setPlayFormat] = useState<"stroke" | "match" | "stableford">(currentEditablePlayFormat)
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret // ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
// av fullført-status (samme begrunnelse som playFormat over). // av fullført-status (samme begrunnelse som playFormat over).
const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">( const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
@ -3192,7 +3246,7 @@ function EditRoundPanel({
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) || (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
toDatetimeLocalValue(round.started_at) !== startedAt || toDatetimeLocalValue(round.started_at) !== startedAt ||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) || (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
playFormat !== (round.play_format === "match" ? "match" : "stroke") || playFormat !== currentEditablePlayFormat ||
visibilityMode !== round.visibility_mode || visibilityMode !== round.visibility_mode ||
(visibilityMode === "friends" && !sameCategories) (visibilityMode === "friends" && !sameCategories)
@ -3211,7 +3265,7 @@ function EditRoundPanel({
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) { if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
body.completed_at = fromDatetimeLocalValue(completedAt) body.completed_at = fromDatetimeLocalValue(completedAt)
} }
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat if (playFormat !== currentEditablePlayFormat) body.play_format = playFormat
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
const result = await onPatch(body) const result = await onPatch(body)
@ -3250,6 +3304,7 @@ function EditRoundPanel({
{( {(
[ [
{ value: "stroke" as const, label: "Slagspill" }, { value: "stroke" as const, label: "Slagspill" },
{ value: "stableford" as const, label: "Stableford" },
{ value: "match" as const, label: "Matchspill" }, { value: "match" as const, label: "Matchspill" },
] ]
).map((opt) => ( ).map((opt) => (
@ -4182,7 +4237,11 @@ function ScorecardGrid({
dimmed={dimmed} dimmed={dimmed}
/> />
) : ( ) : (
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} /> <ScorecardCell
value={hasScore ? (apiHole!.score as number) : null}
diff={diff}
pickedUp={apiHole?.picked_up}
/>
)} )}
{hasScore ? ( {hasScore ? (
net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span> net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>
@ -4263,8 +4322,26 @@ function ScorecardGrid({
// prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se // prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se
// DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over // DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over
// par, ren tekst på par, fylt ved 2+ slag fra par. // par, ren tekst på par, fylt ved 2+ slag fra par.
function ScorecardCell({ value, diff }: { value: number | null; diff: number | null }) { function ScorecardCell({
value,
diff,
pickedUp,
}: {
value: number | null
diff: number | null
pickedUp?: boolean
}) {
const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums" const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
// "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
// vises som "PU" i stedet for det (allerede kjente) Net Double Bogey-
// slagtallet, som ellers ville sett ut som en vilkårlig høy score.
if (pickedUp) {
return (
<span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange")}>
PU
</span>
)
}
if (value === null) return <span className={cn(shared, "text-muted-foreground")}></span> if (value === null) return <span className={cn(shared, "text-muted-foreground")}></span>
if (diff === null || diff === 0) return <span className={cn(shared, "font-bold text-foreground")}>{value}</span> if (diff === null || diff === 0) return <span className={cn(shared, "font-bold text-foreground")}>{value}</span>
if (diff <= -2) return <span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{value}</span> if (diff <= -2) return <span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{value}</span>

View file

@ -28,6 +28,7 @@ type ApiLeaderboardHole = {
stroke_index: number stroke_index: number
played: boolean played: boolean
score: number | null score: number | null
picked_up: boolean
strokes_received: number | null strokes_received: number | null
} }
@ -259,8 +260,11 @@ function pointsForHole(hole: ApiLeaderboardHole): number | null {
function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondaryValue: number | null | undefined }) { 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 shared = "flex size-7 items-center justify-center text-xs font-extrabold tabular-nums"
const markClass = // "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
!hole.played || hole.score === null // vises som "PU" i stedet for det underliggende Net Double Bogey-tallet.
const markClass = hole.picked_up
? "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange"
: !hole.played || hole.score === null
? "text-muted-foreground" ? "text-muted-foreground"
: (() => { : (() => {
const kind = classifyHoleScore(hole.score, hole.par) const kind = classifyHoleScore(hole.score, hole.par)
@ -277,7 +281,9 @@ function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondar
</span> </span>
{/* Brutto -- fremhevet, samme "form + farge"-språk som resten av {/* Brutto -- fremhevet, samme "form + farge"-språk som resten av
appen (fylt/farget merke), øverst -- den fremste raden. */} appen (fylt/farget merke), øverst -- den fremste raden. */}
<span className={cn(shared, markClass)}>{hole.played && hole.score !== null ? hole.score : ""}</span> <span className={cn(shared, markClass)}>
{hole.picked_up ? "PU" : hole.played && hole.score !== null ? hole.score : ""}
</span>
{/* Netto/poeng -- rolig, umerket tekst RETT under, samme prinsipp {/* Netto/poeng -- rolig, umerket tekst RETT under, samme prinsipp
som referansebildene (aldri fremhevet samme måte som brutto). som referansebildene (aldri fremhevet samme måte som brutto).
Utelates helt i brutto-modus (ingenting nytt å vise der). */} Utelates helt i brutto-modus (ingenting nytt å vise der). */}
@ -375,6 +381,7 @@ function HoleStrip({
type ApiFormatResult = { type ApiFormatResult = {
play_format: string play_format: string
ready: boolean ready: boolean
match_lead: number | null
match_holes_played: number | null match_holes_played: number | null
match_holes_remaining: number | null match_holes_remaining: number | null
match_is_closed: boolean | null match_is_closed: boolean | null
@ -383,6 +390,18 @@ type ApiFormatResult = {
hole_results: string[] | null hole_results: string[] | null
} }
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx) -- den ledende
// sidens sone strekker seg proporsjonalt forbi midtlinjen. Ingen navn her
// (vises allerede andre steder på leaderboard-siden), kun fargesonene +
// den sentrerte statuspillen, for full visuell konsistens på tvers av alle
// steder en matchstatus vises.
function leadZoneFraction(lead: number, totalHoles: number): number {
if (totalHoles <= 0) return 0.5
const fraction = 0.5 + (lead / totalHoles) * 0.5
return Math.min(0.82, Math.max(0.18, fraction))
}
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null { function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null) const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => { useEffect(() => {
@ -421,6 +440,15 @@ function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; s
} }
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
const lead = result.match_lead ?? 0
const holesPlayed = result.match_holes_played ?? 0
const holesRemaining = result.match_holes_remaining ?? 0
const totalHoles = holesPlayed + holesRemaining
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return ( return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5"> <div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
@ -429,11 +457,34 @@ function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; s
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>} {result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>} {result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
</div> </div>
{statusText && <span className="text-3xl font-extrabold tracking-tight text-foreground">{statusText}</span>} <div
<span className="text-sm text-muted-foreground"> className="grid h-20 w-full overflow-hidden rounded-2xl"
{result.match_holes_played} hull spilt style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`} >
</span> <div className={cn("h-full transition-[grid-template-columns] duration-300", zoneAClass)} />
<div className="flex h-full items-center justify-center px-3">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{statusText ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{statusText}
</span>
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`}
</span>
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneBClass)} />
</div>
{result.hole_results && result.hole_results.length > 0 && ( {result.hole_results && result.hole_results.length > 0 && (
<ul className="flex flex-wrap gap-1"> <ul className="flex flex-wrap gap-1">
{result.hole_results.map((r, i) => ( {result.hole_results.map((r, i) => (

View file

@ -69,6 +69,7 @@ type ApiHole = {
stroke_index: number stroke_index: number
played: boolean played: boolean
score: number | null score: number | null
picked_up: boolean
strokes_received: number | null strokes_received: number | null
} }
@ -76,6 +77,7 @@ const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"
const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS] const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS]
const FORMAT_LABELS: Record<string, string> = { const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill", stroke: "Slagspill",
stableford: "Stableford",
match: "Match", match: "Match",
skins: "Skins", skins: "Skins",
fourball: "Fourball", fourball: "Fourball",
@ -131,9 +133,18 @@ function signedToPar(score: number, par: number): string {
// Shape encodes direction (circle = under par, square = over par) so meaning // Shape encodes direction (circle = under par, square = over par) so meaning
// survives without color; fill encodes magnitude (filled = 2+ strokes off). // survives without color; fill encodes magnitude (filled = 2+ strokes off).
function ScoreMark({ gross, par }: { gross: number | null; par: number }) { function ScoreMark({ gross, par, pickedUp }: { gross: number | null; par: number; pickedUp?: boolean }) {
const shared = const shared =
"flex size-6 items-center justify-center text-sm font-extrabold tabular-nums sm:size-7" "flex size-6 items-center justify-center text-sm font-extrabold tabular-nums sm:size-7"
// "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
// vises som "PU" i stedet for det underliggende Net Double Bogey-tallet.
if (pickedUp) {
return (
<span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange")}>
PU
</span>
)
}
if (gross === null) { if (gross === null) {
return <span className={cn(shared, "text-muted-foreground")}>{""}</span> return <span className={cn(shared, "text-muted-foreground")}>{""}</span>
} }
@ -269,7 +280,7 @@ function ScoreBlock({
{holes.map((h) => ( {holes.map((h) => (
<td key={h.hole_number} className={cn(cell, "px-0.5 py-1")}> <td key={h.hole_number} className={cn(cell, "px-0.5 py-1")}>
<span className="flex items-center justify-center"> <span className="flex items-center justify-center">
<ScoreMark gross={h.played ? h.score : null} par={h.par} /> <ScoreMark gross={h.played ? h.score : null} par={h.par} pickedUp={h.picked_up} />
</span> </span>
</td> </td>
))} ))}

View file

@ -54,6 +54,7 @@ type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiL
type ApiFormatResult = { type ApiFormatResult = {
play_format: string play_format: string
ready: boolean ready: boolean
match_lead: number | null
match_holes_played: number | null match_holes_played: number | null
match_holes_remaining: number | null match_holes_remaining: number | null
match_is_closed: boolean | null match_is_closed: boolean | null
@ -63,8 +64,19 @@ type ApiFormatResult = {
skins_won: Record<string, number> | null skins_won: Record<string, number> | null
} }
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx/round-
// leaderboard.tsx) -- ingen navn her (vises andre steder på siden), kun
// fargesonene + den sentrerte statuspillen, for full visuell konsistens.
function leadZoneFraction(lead: number, totalHoles: number): number {
if (totalHoles <= 0) return 0.5
const fraction = 0.5 + (lead / totalHoles) * 0.5
return Math.min(0.82, Math.max(0.18, fraction))
}
const FORMAT_LABELS: Record<string, string> = { const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill", stroke: "Slagspill",
stableford: "Stableford",
match: "Match", match: "Match",
skins: "Skins", skins: "Skins",
fourball: "Fourball", fourball: "Fourball",
@ -160,6 +172,16 @@ function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSid
) )
} }
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
const lead = result.match_lead ?? 0
const holesPlayed = result.match_holes_played ?? 0
const holesRemaining = result.match_holes_remaining ?? 0
const totalHoles = holesPlayed + holesRemaining
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return ( return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5"> <div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@ -167,11 +189,34 @@ function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSid
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>} {result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>} {result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
</div> </div>
{statusText && <span className="text-3xl font-extrabold tracking-tight text-foreground">{statusText}</span>} <div
<span className="text-sm text-muted-foreground"> className="grid h-20 w-full overflow-hidden rounded-2xl"
{result.match_holes_played} hull spilt style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`} >
</span> <div className={cn("h-full transition-[grid-template-columns] duration-300", zoneAClass)} />
<div className="flex h-full items-center justify-center px-3">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{statusText ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{statusText}
</span>
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`}
</span>
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneBClass)} />
</div>
{result.hole_results && result.hole_results.length > 0 && ( {result.hole_results && result.hole_results.length > 0 && (
<ul className="flex flex-wrap gap-1"> <ul className="flex flex-wrap gap-1">
{result.hole_results.map((r, i) => ( {result.hole_results.map((r, i) => (
@ -232,7 +277,7 @@ export function WatchRound({ roundId }: { roundId: string }) {
if (cancelled) return if (cancelled) return
setRound(roundData) setRound(roundData)
if (roundData.play_format === "stroke" || roundData.play_format === "skins") { if (roundData.play_format === "stroke" || roundData.play_format === "stableford" || roundData.play_format === "skins") {
const lbRes = await fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" }) const lbRes = await fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" })
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json()) if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
} }
@ -307,9 +352,14 @@ export function WatchRound({ roundId }: { roundId: string }) {
<SkinsBoard participants={round.participants} skinsWon={formatResult.skins_won} /> <SkinsBoard participants={round.participants} skinsWon={formatResult.skins_won} />
)} )}
{round.play_format === "stroke" && leaderboard && <StrokeLeaderboard leaderboard={leaderboard} />} {(round.play_format === "stroke" || round.play_format === "stableford") && leaderboard && (
<StrokeLeaderboard leaderboard={leaderboard} />
)}
{!TWO_SIDED_FORMATS.has(round.play_format) && round.play_format !== "skins" && round.play_format !== "stroke" && ( {!TWO_SIDED_FORMATS.has(round.play_format) &&
round.play_format !== "skins" &&
round.play_format !== "stroke" &&
round.play_format !== "stableford" && (
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground"> <p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen live-visning tilgjengelig for dette formatet ennå. Ingen live-visning tilgjengelig for dette formatet ennå.
</p> </p>