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:
parent
2ad07d09fe
commit
4b9d93941e
11 changed files with 522 additions and 222 deletions
29
038_stableford_and_pickup.sql
Normal file
29
038_stableford_and_pickup.sql
Normal 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
162
CLAUDE.md
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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 nå.
|
flere spillere enn slagspill -- ingenting fanget dette opp før nå.
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 på samme måte som brutto).
|
som referansebildene (aldri fremhevet på 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) => (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue