Ferdig og live. Kort oppsummert:

Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.

Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.

Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
This commit is contained in:
Erol Haagenrud 2026-07-28 13:00:50 +02:00
parent 997f3c40ed
commit 83c4749516
12 changed files with 2401 additions and 116 deletions

View file

@ -0,0 +1,68 @@
-- =====================================================================
-- TeeCup — ekte spillformer for frittstående runder (migrasjon 031,
-- ADR-039)
-- =====================================================================
-- Utvider round.play_format (ADR-038) fra en ren HCP-eksklusjons-etikett
-- til å faktisk styre scoringsmodell + scorekort: match/fourball/
-- foursome/greensome/scramble trenger et "sider"-konsept (Beslutning A),
-- og delt-ball-formatene (foursome/greensome/scramble) trenger én
-- kombinert score PER SIDE per hull i stedet for per spiller
-- (Beslutning C) -- gjenbruker samme nullable-nøkkel-mønster som
-- org-scopet `hole_score` (migrasjon 001) i stedet for en helt ny tabell.
-- =====================================================================
\set ON_ERROR_STOP on
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'));
-- Skins (Beslutning D) -- kun meningsfullt når play_format='skins',
-- håndheves i app-laget (samme "kun meningsfullt for..."-mønster som
-- resten av round -- ikke en tabellbasert CHECK på tvers av kolonner).
ALTER TABLE round ADD COLUMN skins_scoring text CHECK (skins_scoring IN ('net', 'gross'));
ALTER TABLE round ADD COLUMN skins_tie_handling text CHECK (skins_tie_handling IN ('carry', 'split'));
-- ---------------------------------------------------------------------
-- Sider (Beslutning A) -- nøyaktig to per runde for match/fourball/
-- foursome/greensome/scramble, håndhevet i app-laget (samme mønster som
-- ADR-011s to-lags-grense for org-turneringer), ikke en DB-constraint.
-- ---------------------------------------------------------------------
CREATE TABLE round_side (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE,
label text
);
CREATE INDEX ON round_side (round_id);
ALTER TABLE round_participant ADD COLUMN round_side_id uuid REFERENCES round_side(id) ON DELETE SET NULL;
-- Playing Handicap (relativ til motparten via match_play_strokes) --
-- course_handicap_snapshot (eksisterende) er ABSOLUTT og uendret i
-- betydning; playing_handicap er den format-avledede enheten, samme
-- to-kolonne-mønster som org-scopet match_participant.
ALTER TABLE round_participant ADD COLUMN playing_handicap smallint;
-- ---------------------------------------------------------------------
-- round_hole: nøyaktig ÉN av round_participant_id/round_side_id
-- (Beslutning C) -- delt-ball-formater (foursome/greensome/scramble)
-- lagrer én rad PER SIDE per hull, ikke per spiller. Individuell-ball-
-- formater (slagspill/match/fourball/skins) er uendret -- én rad per
-- DELTAKER per hull, som i dag.
-- ---------------------------------------------------------------------
ALTER TABLE round_hole ALTER COLUMN round_participant_id DROP NOT NULL;
ALTER TABLE round_hole ADD COLUMN round_side_id uuid REFERENCES round_side(id) ON DELETE CASCADE;
ALTER TABLE round_hole ADD CONSTRAINT round_hole_owner_xor CHECK (
((round_participant_id IS NOT NULL)::int + (round_side_id IS NOT NULL)::int) = 1
);
ALTER TABLE round_hole DROP CONSTRAINT round_hole_round_participant_id_hole_number_key;
CREATE UNIQUE INDEX round_hole_participant_unique
ON round_hole (round_participant_id, hole_number) WHERE round_participant_id IS NOT NULL;
CREATE UNIQUE INDEX round_hole_side_unique
ON round_hole (round_side_id, hole_number) WHERE round_side_id IS NOT NULL;
GRANT SELECT, INSERT, UPDATE, DELETE ON round_side TO teecup_app;

View file

@ -3482,6 +3482,128 @@ er etablert), ikke behandlet som en feil.
--- ---
## ADR-039: Ekte spillformer for frittstående runder (match/skins/par-lag)
**Kontekst:** reist 2026-07-28, rett etter ADR-038: "Når en singlerunde
settes opp: Er dette en slagspillsrunde, en match mellom to spillere,
skins, eller en par- eller lag-konkurranse. Avhengig av svaret så må hcp
beregnes forskjellig, og også scorekortet vil se annerledes ut." Viktig
presisering: `round.play_format` (ADR-038) styrer i dag KUN et
HCP-eksklusjonsforslag — ikke selve scorings- eller scorekort-modellen.
Dette utvider `play_format` til å faktisk STYRE begge deler.
Fire load-bærende beslutninger avklart eksplisitt med bruker
(AskUserQuestion, to runder) før denne ADR-en ble skrevet.
### Beslutning A — To sider per runde, gjenbruker ADR-011s antakelse
Match/fourball/foursome/greensome/scramble trenger et «hvem spiller
mot hvem»-konsept `round_participant` ikke har i dag (en flat liste
uten gruppering). Løst med nøyaktig TO SIDER per runde (ny
`round_side`-tabell), håndhevet i app-laget — samme mønster som
ADR-011s to-lags-grense for organisasjons-turneringer, ikke en
DB-constraint. En side er 1 spiller (match) eller 2+ (fourball/
foursome/greensome/scramble_2/scramble_4). `round_participant.
round_side_id` (nullable — kun satt for to-sidede formater) knytter
deltakeren til sin side.
### Beslutning B — Alle fire par-/lag-underformater fra start, samme motor som turneringer
Bruker valgte bredt omfang: fourball, foursome, greensome OG begge
scramble-variantene (`scramble_2`/`scramble_4`) — ikke bare fourball
(som alene ville passet uendret inn i dagens skjema).
**Gjenbruker den EKSISTERENDE, testede match-play-motoren fullstendig
uendret** (`handicap_engine.py` sin `Format`-enum,
`AllowanceStrategy`-familien, `match_play_strokes`,
`allocate_over_played_holes`, `compute_match_state`, `HoleResult`) —
ingen ny motorkode for disse fire formatene. Selve API-laget
(`app/routers/rounds.py`) porterer det allerede bevisste,
produksjonskjørte mønsteret fra `app/handicap.py`/`app/routers/
scoring.py` (`compute_and_store_side_handicaps` →
`relative_strokes_for_match``_side_net`/`_compute_hole_results` →
`recompute_and_cache_match_state`), tilpasset `round`/`round_side` i
stedet for `match`/`team_side`. Samme `_SIDE_IS_UNIT`/`FORMAT_UNIT_SIZE`-
tabeller (hvor mange spillere som kreves per side før handicap kan
beregnes) gjenbrukes identisk.
### Beslutning C — Delt-ball-formater: `round_hole` gjenbruker `hole_score`s nullable-nøkkel-mønster
Foursome/greensome/scramble har ÉN kombinert score per SIDE per hull,
ikke én per spiller — akkurat samme situasjon som org-scopet
`hole_score` allerede løser (`match_participant_id` NULLABLE, delt-ball-
rader identifisert av `team_side` alene, to partielle unike indekser).
`round_hole.round_participant_id` gjøres NULLABLE, ny
`round_hole.round_side_id` lagt til, med samme
`(participant XOR side)`-CHECK og to partielle unike indekser som
originalen. For fourball/match (individuell ball) forblir `round_hole`
uendret, én rad per DELTAKER per hull, akkurat som i dag.
**Konsekvens for statistikk (ADR-033 Beslutning B):** de detaljerte
per-hull-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
ingen entydig mening for en DELT ball (hvem sitt slag var det?) — for
delt-ball-formater lagres derfor KUN selve slagtallet på siderraden,
ingen av de andre detalj-feltene (samme "et fast sett felt, ikke fri
logg"-prinsipp, bare at feltsettet er tynnere for denne raden).
### Beslutning D — Skins: konfigurerbar netto/brutto OG rullerer/deles, ny motorfunksjon
Ingen eksisterende motorstøtte for skins i det hele tatt (ikke et
turnering-format). Bruker valgte at DEN SOM SETTER OPP runden skal
kunne velge BEGGE akser uavhengig — ikke ett fastlåst standardvalg:
- `round.skins_scoring`: `'net'` (HCP-slag trekkes fra, WHS-vanlig) |
`'gross'`.
- `round.skins_tie_handling`: `'carry'` (uavgjort hulls "skin" ruller
til neste hull, vinneren der tar hele potten) | `'split'` (uavgjort
hulls skin deles likt mellom de tied spillerne i stedet).
Ny, ren, testbar motorfunksjon `compute_skins(scores_by_hole,
tie_handling)` i `handicap_engine.py` — tar allerede netto-ELLER-brutto-
avgjorte per-hull-scorer (kalleren bestemmer hvilket FØR kall, motoren
vet ikke selv om input er netto eller brutto), returnerer
`{participant_id: antall skins vunnet}` (float, siden 'split' kan gi
brøkdeler). Ingen sider trengs for skins — rent individuelt, som
slagspill.
### Beslutning E — HCP-telling per format
- **Slagspill, fourball, skins** (individuell ball — hver spiller har
en FULL, ekte brutto-score per hull): teller normalt mot faktisk HCP,
samme pipeline som i dag (Score Differential fra AGS). Bekreftet
eksplisitt av bruker for skins — "krever fullt utspilte hull (ingen
konsedering som i match) -- samme grunnlagsproblem som gjorde match
upålitelig gjelder ikke her."
- **Match** (uendret fra ADR-038): fortsatt "spør, anbefal
ekskludering" — Rule 3.3-problemet (konsederte hull) gjelder fortsatt
uendret.
- **Foursome/greensome/scramble** (delt ball — INGEN individuell score
finnes å telle): `counts_for_handicap` forblir usann for disse
deltakerne i disse rundene, alltid, uansett antall spilte hull.
Bekreftet eksplisitt av bruker som riktig løsning — matcher for
øvrig hvordan WHS i praksis også behandler delt-ball-formater (ikke
indeks-byggende på samme måte som individuell/fourball-spill).
### Beslutning F — Scorekort-presentasjon (frontend, ikke bygget i denne runden)
Notert for senere: match/fourball/foursome/greensome/scramble trenger
en løpende matchstatus-visning (gjenbruk av `describe()`-språket fra
`MatchState`, samme som `session-scorecard.tsx` allerede viser for
turnering-matcher) i stedet for rå slagsummer. Skins trenger en
skins-tavle (skins vunnet per spiller + hvilke hull som fortsatt er
"i potten"). Presist UI-design ikke gjort i denne runden — kun
backend/motor/skjema.
### Konsekvens — hva som bygges i DENNE runden vs. senere
Bruker valgte å gå rett til migrasjon + motor (ikke bare dokumentere).
Bygget: skjema (migrasjon 031), `compute_skins`-motorfunksjon + tester,
og backend-API for sider/delt-ball-scoring/format-resultat. Frontend
(nye skjermer for sideoppsett og de nye scorekort-presentasjonene,
Beslutning F) er en egen, senere runde — samme lagdeling som ADR-038
(backend/motor FØR frontend).
---
## Åpne spørsmål (ikke besluttet ennå) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:
@ -3513,6 +3635,16 @@ Disse må avklares før eller under de relevante fasene:
(sesong-sammenlagt på tvers av flere turneringer) — bekreftet som et (sesong-sammenlagt på tvers av flere turneringer) — bekreftet som et
beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
skrevet — ADR-037 er ren struktur-beslutning. skrevet — ADR-037 er ren struktur-beslutning.
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
par-lag-konkurranse, reist 2026-07-28) — **AVKLART OG BACKEND BYGGET
SAMME DAG, se ADR-039.** `round.play_format` styrer nå faktisk både
HCP-beregning (via en portert versjon av turnering-motoren) og et
nytt `format-result`-lese-endepunkt for scorekort-presentasjon.
Fortsatt IKKE utrullet mot ekte systemer, og frontend (sideoppsett/
skins-konfig/matchstatus-visning) fortsatt ikke bygget — se
FEATURE_BACKLOG.md. "Flere flighter i én frittstående runde" (egen
seksjon i FEATURE_BACKLOG.md) forblir et adskilt, ikke-relatert
spørsmål (grupperer RUNDER, ikke deltakere innad i én runde).
--- ---

229
CLAUDE.md
View file

@ -4071,6 +4071,226 @@ Ferdig og verifisert:
ADR-037-arbeid), ingen egen "aging"-bakgrunnsjobb utover at spørringen ADR-037-arbeid), ingen egen "aging"-bakgrunnsjobb utover at spørringen
alltid henter kun de 20 nyeste differensialene. alltid henter kun de 20 nyeste differensialene.
- **Ekte spillformer for frittstående runder (match/skins/fourball/
foursome/greensome/scramble), BACKEND BYGGET OG SCRATCH-VERIFISERT,
IKKE ENNÅ RULLET UT MOT EKTE SYSTEMER (2026-07-28, ADR-039):** reist av
brukeren rett etter ADR-038: "Er dette en slagspillsrunde, en match
mellom to spillere, skins, eller en par- eller lag-konkurranse.
Avhengig av svaret så må hcp beregnes forskjellig, og også scorekortet
vil se annerledes ut." Fire load-bærende avklaringer (AskUserQuestion,
to runder) FØR bygging — bruker valgte det bredeste omfanget i alle
runder: to sider (Beslutning A), ALLE fire par-/lag-underformater fra
start inkl. de to delt-ball-krevende (Beslutning B), skins med BEGGE
akser konfigurerbare av oppsetteren (netto/brutto OG rullerer/deles,
Beslutning D), og gå rett til migrasjon+motor samme økt (ikke bare
dokumentere).
**Kjerneinnsikt som gjorde dette trygt å bygge fort:** hele match-play-
motoren (`handicap_engine.py` sin `Format`/`AllowanceStrategy`-familie/
`match_play_strokes`/`compute_match_state`) og hele "delt-ball vs.
individuell"-mønsteret (`hole_score.match_participant_id` NULLABLE,
delt-ball-rader identifisert av `team_side` alene) fantes ALLEREDE,
bygget og produksjonskjørt for org-scopede turnering-matcher. Denne
runden PORTERER dette mønsteret til frittstående runder (nye
`round_side`/`round_participant.round_side_id`/`round_participant.
playing_handicap`/`round_hole.round_side_id`) i stedet for å finne opp
noe nytt — kun skins (ingen turnering-motstykke) fikk EKTE ny
motorkode (`compute_skins`, 7 nye tester, 50/50 i `test_handicap_
engine.py`). `app/handicap.py` sin `_SIDE_IS_UNIT` omdøpt til
`SIDE_IS_UNIT` (gjort delt for gjenbruk, samme "fjern understrek når
et andre bruksted dukker opp"-mønster som tidligere runder).
**Skjema, migrasjon `031_round_play_formats.sql`:** `round.play_format`
utvidet til 8 verdier, nye `round.skins_scoring`/`skins_tie_handling`,
ny `round_side`-tabell (nøyaktig to per runde, håndhevet i app-laget
som ADR-011s to-lags-grense), `round_participant.round_side_id`/
`playing_handicap` (sistnevnte ALDRI det samme som det eksisterende
`course_handicap_snapshot` — den absolutte WHS-verdien rørt av INGEN
av denne rundens kode), `round_hole.round_participant_id` gjort
NULLABLE + ny `round_hole.round_side_id` + XOR-CHECK + to partielle
unike indekser (samme mønster som org-scopet `hole_score`, migrasjon
001).
**Reelt, bekreftet funn UNDER selve designet (ikke antatt), avklart
eksplisitt med bruker FØR bygging:** foursome/greensome/scramble har
ÉN kombinert score per SIDE per hull -- INGEN individuell score
finnes i det hele tatt å bygge en Score Differential fra. Løst
(Beslutning E, bekreftet av bruker): disse deltakerne får
`counts_for_handicap` ALDRI sann for disse rundene -- og viste seg,
presist verifisert i scratch, å følge HELT AUTOMATISK av den
eksisterende `complete_round`-logikken uten noen kodeendring i det
hele tatt (en delt-ball-deltaker har null individuelle `round_hole`-
rader, så `played_count` blir alltid 0, som `round_counts_for_
handicap` allerede tolker som "teller ikke" for både 9- og
18-hulls-intensjon).
**API (`app/routers/rounds.py`):** `POST/DELETE .../sides` (eier-only,
maks to, avvist for slagspill/skins), `ParticipantCreate`/
`ParticipantUpdate` fikk `round_side_id` (eier-only reassignment,
validerer siden hører til samme runde), `_recompute_side_handicaps`
(porterer `compute_and_store_side_handicaps` — venter på at siden når
forventet spillerantall FØR den skriver noe, samme
"ikke komplett ennå = ikke skriv"-filosofi som originalen),
`_relative_strokes_for_round` (porterer `relative_strokes_for_match`),
nye `GET/PATCH .../sides/{id}/holes/{n}` (delt-ball-scoring, kun
slagtall — ingen av de andre detalj-feltene gir mening for en delt
ball), og et nytt lese-endepunkt `GET .../format-result` som regner
løpende matchstatus (gjenbruker `compute_match_state`/`HoleResult`
uendret) for de to-sidede formatene, eller en skins-tavle
(`compute_skins`) for skins — aldri lagret, alltid avledet ved lesing.
**To reelle bugs funnet OG fikset UNDER scratch-testing, ingen nådde
produksjon:** (1) side-tildelings-recompute kjørte FØR responsen ble
hentet i stedet for ETTER — testen fanget dette presist (forventet
`playing_handicap` i responsen, fikk `null` fra FØR omregningen); (2)
individuell-ball-gren i format-result-spørringen nøkkel-forvekslet
"enhet" (satte `round_side_id` som nøkkel i stedet for
`round_participant_id`, mens `side_net()`-oppslaget forventet
deltaker-id) — ga et tomt `hole_results` til tross for gyldige
registrerte scorer, fanget da `match_holes_played` kom ut som 0 i
stedet for det forventede 3.
**Scratch-verifisert grundig, 96/96 sjekker** (isolert
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
container via ekte HTTP, samme mønster som hele prosjektet): en
hånd-utregnet 3-hulls singles-match (hcp 5 vs. 10, riktig slagmottak
på de fem vanskeligste hullene) ga eksakt `lead=0`/"AS"/riktig
hole-for-hole-mønster; fourball bekreftet INDIVIDUELL (ikke kombinert)
90 %-beregning per spiller, korrekt "ikke komplett ennå" før andre
spiller på siden var tildelt; foursome bekreftet 18 round_hole-rader
opprettet på SIDEN ved side-opprettelse (før noen deltaker lagt til),
korrekt kombinert 50 %-Playing-Handicap først når begge var tildelt,
ekte delt-ball-scoring via det nye sub-endepunktet, OG at
`counts_for_handicap` ble `False` for begge etter fullføring; skins
(netto+carry) ga eksakt `{sk3: 2.0}` for et 2-hulls scenario med et
bevisst konstruert uavgjort-så-carry-så-outright-vinn-mønster, OG
bekreftet skins teller NORMALT mot faktisk HCP etter full fullføring
(ulikt match). `test_isolation.sql` 12/12 uendret (additiv migrasjon).
Alle 50 handicap_engine-tester (43 eksisterende + 7 nye skins) grønne.
**IKKE bygget i denne runden, bevisst utsatt (Beslutning F):**
frontend — ingen skjerm for å opprette sider, tildele deltakere,
konfigurere skins, eller vise løpende matchstatus/skins-tavle. Backend
er fullt funksjonelt og testet, men ubrukelig fra selve appen inntil
frontend bygges i en egen, senere runde (samme lagdelings-mønster som
ADR-038: motor/skjema/API FØR frontend).
**Rullet ut mot ekte systemer 2026-07-28**, bruker bekreftet
eksplisitt: migrasjon 031 kjørt mot ekte `teecup_db` (nye kolonner/
`round_side`-tabell bekreftet, `test_isolation.sql` fortsatt 12/12),
deretter `docker compose up -d --build teecup_api`. Ren boot,
`/health`/`/dashboard` → 200, ny rute bekreftet nåbar (anonymt
`POST .../sides` → 401, ikke en rå 404), `teeoff.no` upåvirket.
- **Oppfølging samme dag: manglende minimums-spiller-håndhevelse fanget
og fikset, BYGGET OG SCRATCH-VERIFISERT, RULLET UT LIVE (2026-07-28):**
brukeren påpekte presist et hull ADR-039 selv ikke fanget opp: "Er det
match-spill, skins eller lagspill så MÅ det jo være flere spillere.
Dette fanges ikke opp." Riktig — ingenting hindret å fullføre en
"match" med kun eieren, eller la flere spillere enn formatet tillater
havne på samme side. Bekreftet med bruker (AskUserQuestion): skins
krever minst 3 spillere (2 gjør skins i praksis identisk med en vanlig
match — 3+ er der en oppsamlet, uavgjort pott faktisk gir mening).
**Bygget, ren Python-logikk, INGEN migrasjon:** ny
`_format_setup_status()` — slagspill alltid klar; skins krever minst
3 deltakere; to-sidede formater (match/fourball/foursome/greensome/
scramble) krever NØYAKTIG to sider, INGEN uassignerte deltakere, og
hver side nøyaktig riktig spillerantall for formatet
(`_SIDE_PLAYER_COUNT`, allerede definert). Ny `setup_complete`/
`setup_message``RoundOut` (alltid synlig, uansett format/status).
`complete_round` avviser nå (409 `SETUP_INCOMPLETE`) hvis oppsettet
ikke er komplett — FØR noe regnes ut. Ny `_check_side_capacity()`
avviser (409 `SIDE_FULL`) proaktivt ved SELVE tildelingen (både
`POST .../participants` med `round_side_id` og `PATCH .../
participants/{id}`), i stedet for å først oppdage overtallet ved
fullføring — med eksplisitt unntak for en no-op-reassignment til
samme side (en deltaker teller ikke seg selv ut av plassen sin egen
side har).
**Scratch-verifisert grundig, 122/122 sjekker** (samme isolerte
scratch-oppsett som resten av runden — full regresjon av alle
tidligere 96 sjekker PLUSS 26 nye): 3. spiller avvist på en full
match-/foursome-side (409 SIDE_FULL), `setup_complete` korrekt False
ved kun 1 av 2 sider / uassignerte deltakere / for få skins-spillere,
fullføring korrekt avvist (409 SETUP_INCOMPLETE) i alle disse
tilstandene, og korrekt True (+ vellykket fullføring) først når
oppsettet faktisk er komplett for formatet. `test_isolation.sql`
uendret (ingen skjemaendring).
**Rullet ut live 2026-07-28**, ingen migrasjon, kun
`docker compose up -d --build teecup_api`. Ren boot, `/health`/
`/dashboard` → 200, `teeoff.no` upåvirket.
- **Frontend for ADR-039 (sider/skins/delt-ball-scoring) BYGGET, BROWSER-
VERIFISERT OG LIVE (2026-07-28), samme dag:** ingen backend-endring i
denne runden (alt allerede live) — ren frontend-jobb, testet reelt i
nettleser mot en isolert scratch-backend (Chrome DevTools), ikke bare
typesjekk.
**`new-round.tsx`:** spilleform-velgeren utvidet fra to (Slagspill/
Match) til alle åtte format (Skins/Fourball/Foursome/Greensome/
Scramble 2/4 lagt til), med en ny skins-konfigurasjonsseksjon (netto/
brutto, rullerer/deles) som kun vises for `play_format="skins"` og et
forklarende "du setter opp sidene inni runden etterpå"-notat for de
to-sidede formatene (ADR-039 Beslutning A -- sider kan ikke opprettes
før deltakerne finnes).
**`round-detail.tsx` (hoveddelen):** ny `SidesPanel` (manage-fanen) --
opprett/slett de to sidene, tildel/fjern deltakere (kompakte
"→ Side"-hurtigknapper, deaktivert når siden er full), viser
`playing_handicap` per side. Ny `FormatResultPanel` -- henter
`GET .../format-result`, viser løpende matchstatus (oversetter
motorens bokstavelige "(A)"/"(B)" til faktiske side-navn via
`round.sides[0]/[1]`, samme sorteringsrekkefølge som backend) eller en
skins-tavle (sortert synkende). `setup_complete`/`setup_message`
gater nå "Fullfør runde"-knappen klientside også (server er fortsatt
autoritativ). For delt-ball-formatene (foursome/greensome/scramble):
ny `SideScorecardGrid` (rader = sider, ikke spillere) + ny, forenklet
`SideScoreWizard` (kun slagtall, ingen putt/detalj-steg) mot de
eksisterende `GET/PATCH .../sides/{id}/holes/{n}`-endepunktene.
**To reelle stale-state-bugs funnet UNDER selve browserverifiseringen
(ikke i kodegjennomgang), begge fikset før utrulling:**
1. `SidesPanel` sin `assign()` oppdaterte kun deltaker-listen lokalt
(via `onPatchParticipant`) -- `setup_complete`/`setup_message`
(server-beregnet) ble stående utdatert etter en vellykket
side-tildeling ("ikke tildelt en side" fortsatte å vises til tross
for at begge var tildelt). Fikset: `assign()` kaller nå
`onSidesChanged()` (full runde-refetch) etter en vellykket PATCH.
2. `FormatResultPanel` sin refetch var kun koblet til hull-registrering
og WebSocket-signaler, ikke til side-/deltaker-tildeling --
matchstatus ble stående på "venter..." selv etter at oppsettet var
komplett og "Fullfør runde" allerede var aktivert. Fikset ved å
bumpe `formatResultRefreshTick` ved HVER vellykket `loadRound()`
(enklere og mer robust enn å spore hvert enkelt kallsted som kan
påvirke handicap-beregningen).
**Verifisert grundig i en isolert scratch-nettleserøkt** (fersk
`teecup_scratch`-database, isolert scratch-MinIO, engangs API-
container, ekte `next dev` mot scratch-backend, Chrome DevTools MCP --
ekte innlogging via magic-link, ekte profil-fullføring): tre komplette
runder bygget og spilt gjennom UI-et alene, ende-til-ende:
- **Match:** opprettet med eksklusjons-avkrysning forhåndshuket,
opprettet to sider, tildelte eier+gjest, bekreftet `playing_handicap`
(60/20) vist riktig, scoret hull 1 (4 mot 6) via `ScoringWizard`
(ubrørt komponent), bekreftet `FormatResultPanel` viste "1 UP
(Erol)" + riktig fargede hull-merker -- kryssjekket med
`aria-label`-attributtet direkte via `evaluate_script` for å bekrefte
semantisk korrekt side-navn bak den rå A/B-bokstaven.
- **Skins:** konfigurasjons-UI-et (netto/brutto, rullerer/deles) bekreftet
visuelt, opprettet med kun 1 spiller (satte-message "krever minst 3"),
la til to gjester til (meldingen forsvant idet den tredje ble lagt til,
"Fullfør runde" aktivert), scoret hull 1 for alle tre (via direkte
API-kall for hastighet, samme kontrakt som UI-et bruker), bekreftet
skins-tavlen -- **hånd-regnet og kryssjekket eksakt**: netto 1/4/3 for
de tre spillerne (course handicap 60/12/6, alle mottar 1 slag på
hull 1 unntatt eieren som mottar 4) ga korrekt "1 skin" til laveste
netto.
- **Foursome:** bekreftet "Opprett begge sidene..."-meldingen i Score-
fanen FØR sidene fantes (ingen krasj), opprettet to sider, la til tre
gjester, scoret hull 1 via `SideScorecardGrid`/`SideScoreWizard`
(5 mot 5 -- observerte LIVE at gridet oppdaterte seg bak selve
veiviseren), fant OG fikset de to stale-state-bugene over midt i
denne runden (glemte først å tildele spillerne til sider -- avdekket
nettopp fordi UI-et da IKKE viste feil tilstand, men en ekte utdatert
en), bekreftet til slutt `playing_handicap` kombinert riktig per side
(38/38 og 17/17) og at `FormatResultPanel` viste "1 UP (Rødt lag)"
-- kryssjekket for hånd at Rødt lag (høyere kombinert CH) mottar
slag på det vanskeligste hullet og derfor vinner nettoduellen 5 mot 5.
Ekte typesjekket + full produksjonsbuild kjørt på nytt ETTER
bug-fiksene (ikke bare før), alle 24 ruter listet.
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: 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. **ADR-039 er dermed
fullstendig ferdig, backend og frontend, live.**
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
@ -4286,3 +4506,12 @@ Neste steg:
flighter i én frittstående runde fortsatt kun drøftet (se flighter i én frittstående runde fortsatt kun drøftet (se
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
til rundehendelser ennå. til rundehendelser ennå.
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
foursome/greensome/scramble) for frittstående runder — backend
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,
skins-konfig i `/my-rounds/new`, matchstatus-/skins-tavle-visning,
delt-ball-scorekort/-veiviser, `setup_complete`-gating) er nå BEGGE
bygget, browserverifisert og live (se status 2026-07-28). Mulig
fremtidig finpuss (ikke bedt om ennå): redigere et sidenavn i
etterkant (kun opprett/slett finnes i dag), en tydeligere
skins-poeng-forklaring i UI-et.

View file

@ -2916,6 +2916,108 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden.
--- ---
## Frittstående runder: ekte spillformer (slagspill/match/skins/par-lag) — ✅ BACKEND BYGGET OG SCRATCH-VERIFISERT (ADR-039), IKKE ENNÅ rullet ut mot ekte systemer, frontend fortsatt ikke bygget
**Oppdatering 2026-07-28 (ADR-039):** design + backend er ferdig samme
dag som punktet ble reist. Se CLAUDE.md-status 2026-07-28 for full
detalj (migrasjon `031`, nye endepunkter, 96/96 scratch-sjekker). Kort:
alle fire load-bærende spørsmål (sider/gruppering, omfang av par-/lag-
underformater, skins-regler, HCP-tellestatus for delt-ball) avklart med
bruker, hele match-play-motoren fra org-turneringer PORTERT uendret
(ingen ny regnelogikk for match/fourball/foursome/greensome/scramble),
kun skins fikk ekte ny motorkode. **Gjenstår:** frontend (ingen skjerm
for sideoppsett/skins-konfig/matchstatus-visning ennå) og selve
utrullingen mot ekte `teecup_db`/`teecup_api` — begge egne, separate
neste steg.
Opprinnelig reist av brukeren rett etter at ADR-038 (faktisk HCP) ble
rullet ut:
"Når en singlerunde settes opp: Er dette en slagspillsrunde, en match
mellom to spillere, skins, eller en par- eller lag-konkurranse. Avhengig
av svaret så må hcp beregnes forskjellig, og også scorekortet vil se
annerledes ut."
**Viktig presisering av hva som FINNES i dag, for å unngå forveksling:**
`round.play_format` (`'stroke'`/`'match'`, ADR-038) er i dag KUN en
selvdeklarert ETIKETT som styrer én ting — et forslag om å ekskludere
runden fra faktisk-HCP-grunnlaget. Den endrer INGENTING ved selve
scoringsmodellen eller scorekortet — en "matchspill"-runde i dag
registreres og vises identisk med en slagspill-runde (rå slag per hull
per spiller). Dette nye punktet ber om noe vesentlig større: at
spilleform faktisk STYRER både HCP-beregningen og scorekort-
presentasjonen, for fire distinkte typer.
**De fire spillformene, og hva som mangler for hver:**
1. **Slagspill** — dagens modell, uendret. Allerede fullt bygget
(Score Differential/AGS, ADR-033 Beslutning G).
2. **Match (to spillere)** — trenger match-play-slagfordeling
(`match_play_strokes` i `handicap_engine.py`, allerede bygget/testet
for turnering-matcher) i stedet for `allocate_strokes_by_index`, og et
scorekort som viser løpende matchstatus (hull for hull vunnet/tapt/
delt + "X UP"/"AS", samme presentasjonsspråk som
`session-scorecard.tsx` allerede har for turnering-matcher) i stedet
for rå slagsummer. **Reelt skjemahull:** `round_participant` har i dag
INGEN "hvem spiller mot hvem"-kobling — en runde med 3+ deltakere har
ingen måte å si at akkurat to av dem utgjør matchen.
3. **Skins** — hull-for-hull-konkurranse der laveste (netto eller
brutto) score på hullet vinner en "skin", uavgjort hull ruller
premien videre til neste hull. **Ingen eksisterende motorstøtte i det
hele tatt** — verken en beregningsfunksjon eller noen skjema-plass for
"skins vunnet"/gjeldende premieverdi. Må designes fra bunnen
(inkl. avklaring: netto eller brutto skins, rullerer uavgjort-verdien
videre eller deles, minst 3 spillere).
4. **Par- eller lag-konkurranse** — fourball/foursome/greensome/scramble-
type spill blant rundens deltakere. Motoren for AKKURAT dette
(`Format`-enum, `AllowanceStrategy`-familien, `unit_playing_handicap`)
er allerede bygget og testet — men KUN brukt av den org-scopede
turnering-modellen (`match`/`match_participant` under `session`).
**Reelt skjemahull, samme klasse som punkt 2:** frittstående
`round_participant`-rader er i dag rene individer uten noe
par-/lag-konsept — ingen kobling for "disse to er makkere denne
runden."
**Sentral arkitektur-spenning, verdt å legge merke til FØR design
starter:** punkt 2 og 4 er strukturelt nesten IDENTISKE med det
`app/routers/matches.py`/`scoring.py` allerede gjør for org-scopede
turneringer (samme `Format`-enum, samme match-play-motor) — bare uten en
organisasjon rundt. Dette overlapper direkte med det ennå ubesluttede
spørsmålet i ADR-037 ("individuelle turneringer") og det tidligere
presiserte "flere flighter i én frittstående runde"-spørsmålet (se egen
seksjon over) — tre beslektede, men foreløpig separat behandlede
problemstillinger som alle til slutt lander på "hvordan grupperer/
parer vi deltakere, og hvilken motor regner poeng fra rå slag." Bør
trolig avklares SAMMEN, ikke som tre uavhengige design-runder, for å
unngå tre parallelle, litt ulike implementasjoner av i bunn og grunn
samme idé.
**Åpne spørsmål, ingen besvart ennå:**
- Skal `round.play_format` utvides med `'skins'`/`'pair_team'` (ny
migrasjon, ny CHECK-verdi), eller er dette en helt egen entitet
parallelt med `round`?
- Match/par-lag: hvordan velges/lagres hvem som spiller mot/med hvem —
ved oppsett (som blind draw for turneringer), eller fritt valgt av
eieren i etterkant?
- Skins: netto eller brutto, og hvordan behandles uavgjorte hull
(rullerer premien, eller deles)?
- Skal scorekortets NYE presentasjoner (matchstatus, skins-tavle,
side-score) bygges som varianter av eksisterende komponenter
(`round-scorecard.tsx`/`round-detail.tsx`), eller gjenbruke turnering-
sidens `session-scorecard.tsx`-språk direkte?
- Hvordan påvirker dette allerede byggede ADR-038 (faktisk HCP)? Match/
skins/par-lag-runder trenger sannsynligvis EGNE regler for om/hvordan
de teller mot faktisk HCP (samme "matchspill telles vanligvis ikke"-
resonnement som allerede finnes for `'match'`, men skins/par-lag er
ikke vurdert i det hele tatt ennå).
**Ingen kode skrevet** — dette er bevisst kun fanget/dokumentert nå, på
brukerens eksplisitte instruks. Trenger en egen, dedikert design-/
ADR-runde (samme skala som ADR-033/036/037) før noe bygges, gitt at
punkt 2-4 hver krever nytt skjema, ny motorlogikk (for skins) eller
gjenbruk av eksisterende turnering-motor (for match/par-lag), og en
egen scorekort-presentasjon per format.
---
## Én person, flere e-postadresser — DEL 1 (det enkle tilfellet) ✅ BYGGET OG LIVE 2026-07-21, DEL 2 (kontosammenslåing) fortsatt 📋 NOTERT ## Én person, flere e-postadresser — DEL 1 (det enkle tilfellet) ✅ BYGGET OG LIVE 2026-07-21, DEL 2 (kontosammenslåing) fortsatt 📋 NOTERT
Reist av brukeren rett etter ADR-032 (verifisert e-postbytte). Et beslektet, Reist av brukeren rett etter ADR-032 (verifisert e-postbytte). Et beslektet,

View file

@ -31,7 +31,7 @@ from handicap_engine import (
# Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret, # Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret,
# greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er # greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er
# ikke med her: der er enheten spilleren, og beregnes individuelt. # ikke med her: der er enheten spilleren, og beregnes individuelt.
_SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"} SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"}
FORMAT_UNIT_SIZE: dict[str, int] = { FORMAT_UNIT_SIZE: dict[str, int] = {
"singles": 1, "singles": 1,
@ -134,7 +134,7 @@ async def compute_and_store_side_handicaps(
team_side, team_side,
) )
if format_ in _SIDE_IS_UNIT: if format_ in SIDE_IS_UNIT:
expected = FORMAT_UNIT_SIZE[format_] expected = FORMAT_UNIT_SIZE[format_]
total_on_side = await conn.fetchval( total_on_side = await conn.fetchval(
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side", "SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
@ -162,7 +162,7 @@ async def compute_and_store_side_handicaps(
for p in participants for p in participants
] ]
if format_ in _SIDE_IS_UNIT: if format_ in SIDE_IS_UNIT:
playing = config.strategy.playing_handicap(course_handicaps) playing = config.strategy.playing_handicap(course_handicaps)
for p, ch in zip(participants, course_handicaps): for p, ch in zip(participants, course_handicaps):
await conn.execute( await conn.execute(
@ -196,7 +196,7 @@ async def relative_strokes_for_match(
ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som
"kan ikke avgjøre noen hull ennå". "kan ikke avgjøre noen hull ennå".
""" """
if format_ in _SIDE_IS_UNIT: if format_ in SIDE_IS_UNIT:
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap

View file

@ -43,18 +43,28 @@ from .. import teeoff_client
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket
from ..db import plain_connection from ..db import plain_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
from ..realtime import broadcast_round_update, live_sockets_for_round from ..realtime import broadcast_round_update, live_sockets_for_round
from handicap_engine import ( from handicap_engine import (
HoleResult,
adjusted_gross_score, adjusted_gross_score,
allocate_over_played_holes,
allocate_strokes_by_index, allocate_strokes_by_index,
apply_index_caps, apply_index_caps,
compute_match_state,
compute_skins,
course_handicap, course_handicap,
course_handicap_raw,
handicap_index_from_differentials, handicap_index_from_differentials,
low_handicap_index, low_handicap_index,
match_play_strokes,
round_counts_for_handicap, round_counts_for_handicap,
round_half_up,
score_differential, score_differential,
) )
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
router = APIRouter(tags=["rounds"]) router = APIRouter(tags=["rounds"])
@ -399,6 +409,148 @@ async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
return resolved return resolved
async def _resolve_round_course(round_row) -> _ResolvedCourse:
if round_row["course_source"] == "teeoff":
return await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
return await _resolve_personal_course(round_row["personal_course_id"])
# ---------------------------------------------------------------------------
# Sider og match-play-håndtering (ADR-039) -- porterer det allerede
# bevisste, produksjonskjørte mønsteret fra `app/handicap.py` sin
# `compute_and_store_side_handicaps`/`relative_strokes_for_match`
# (org-scopede turnering-matcher) til frittstående runder. SAMME motor
# (handicap_engine.py), samme allowance-familie -- ingen ny regnelogikk
# for match/fourball/foursome/greensome/scramble, kun andre tabeller.
# Ingen allowance_override-støtte i v1 (kun DEFAULT_MATCHPLAY_ALLOWANCES).
# ---------------------------------------------------------------------------
async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> None:
"""Beregn og lagre round_participant.playing_handicap for begge sider.
Rører ALDRI course_handicap_snapshot (den absolutte, individuelle WHS-
verdien -- brukt uendret av slagspill-pipelinen) -- playing_handicap er
en egen, ny kolonne kun for den format-relative match-play-enheten.
"""
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
config = parse_allowance_config(engine_format, None)
expected = _SIDE_PLAYER_COUNT[play_format]
sides = await conn.fetch("SELECT id::text AS id FROM round_side WHERE round_id = $1", round_id)
for side in sides:
participants = await conn.fetch(
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_side_id = $1",
side["id"],
)
if len(participants) != expected or any(p["course_handicap_snapshot"] is None for p in participants):
continue # ikke komplett ennå, eller mangler HCP-sporing for noen
if not config.use_handicap:
for p in participants:
await conn.execute("UPDATE round_participant SET playing_handicap = 0 WHERE id = $1", p["id"])
continue
course_handicaps = [float(p["course_handicap_snapshot"]) for p in participants]
if engine_format in SIDE_IS_UNIT:
playing = config.strategy.playing_handicap(course_handicaps)
for p in participants:
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
else:
for p, ch in zip(participants, course_handicaps):
playing = config.strategy.playing_handicap([ch])
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
async def _relative_strokes_for_round(conn, round_id: str, play_format: str) -> dict[str, int]:
"""Relative slag per scoringsenhet (porterer relative_strokes_for_match).
Nøkkel = round_participant_id for match/fourball (fourball: begge sidene
samlet i ÉN liste før match_play_strokes, jf. R&A-regelen om at laveste
av alle fire spiller av scratch). Nøkkel = round_side_id for foursome/
greensome/scramble. Returnerer {} hvis playing_handicap ikke er klart
for én eller flere enheter ennå.
"""
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
config = parse_allowance_config(engine_format, None)
if engine_format in SIDE_IS_UNIT:
rows = await conn.fetch(
"""
SELECT DISTINCT ON (round_side_id) round_side_id::text AS unit, playing_handicap
FROM round_participant WHERE round_id = $1 AND round_side_id IS NOT NULL
ORDER BY round_side_id
""",
round_id,
)
else:
rows = await conn.fetch(
"SELECT id::text AS unit, playing_handicap FROM round_participant "
"WHERE round_id = $1 AND round_side_id IS NOT NULL",
round_id,
)
if not rows or any(r["playing_handicap"] is None for r in rows):
return {}
absolutes = [r["playing_handicap"] for r in rows]
relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes
return {r["unit"]: v for r, v in zip(rows, relative)}
async def _check_side_capacity(
conn, side_id: str, play_format: str, exclude_participant_id: str | None = None
) -> None:
"""Avvis tydelig FØR en deltaker tildeles en side som allerede har
forventet antall spillere for formatet (2026-07-28 oppfølging) -- i
stedet for å oppdage overtallet først ved fullføring. `exclude_
participant_id` unngår at en deltaker som allerede ER siden (f.eks.
en no-op-reassignment til samme side) teller seg selv ut av plass."""
expected = _SIDE_PLAYER_COUNT[play_format]
current_count = await conn.fetchval(
"SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
side_id, exclude_participant_id or "00000000-0000-0000-0000-000000000000",
)
if current_count >= expected:
raise app_error(
409, "SIDE_FULL",
f"Denne siden har allerede {expected} spiller(e) -- maks for {play_format}.",
)
async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[bool, str | None]:
"""Er rundens deltaker-/side-oppsett komplett for FORMATET (2026-07-28
oppfølging): match/skins/fourball/foursome/greensome/scramble krever
flere spillere enn slagspill -- ingenting fanget dette opp før .
Returnerer (komplett, forklarende melding hvis ikke)."""
if play_format == "stroke":
return True, None
if play_format == "skins":
total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id)
if total < _SKINS_MIN_PARTICIPANTS:
return False, f"Skins krever minst {_SKINS_MIN_PARTICIPANTS} spillere -- runden har {total}."
return True, None
# To-sidede formater (match/fourball/foursome/greensome/scramble_*).
expected = _SIDE_PLAYER_COUNT[play_format]
sides = await conn.fetch("SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id)
if len(sides) != 2:
return False, "Denne spilleformen krever nøyaktig to sider -- opprett begge før runden fullføres."
unassigned = await conn.fetchval(
"SELECT count(*) FROM round_participant WHERE round_id = $1 AND round_side_id IS NULL", round_id
)
if unassigned > 0:
return False, f"{unassigned} deltaker(e) er ikke tildelt noen side ennå."
for side in sides:
count = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_side_id = $1", side["id"])
if count != expected:
label = side["label"] or "En side"
return False, f"{label} har {count} spiller(e), men trenger nøyaktig {expected}."
return True, None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Runder # Runder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -408,6 +560,29 @@ async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
# av som default. GIR krever putts og vises derfor ikke i strokes_only. # av som default. GIR krever putts og vises derfor ikke i strokes_only.
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"] StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
# ADR-039: formater som bruker et to-siders-konsept (Beslutning A). "match"
# er rundens eget navn for singles match play -- mappes til motorens eget
# Format-enum-navn ("singles") kun når handicap_engine.py/app.handicap
# kalles, ikke noe sted ellers.
_TWO_SIDED_FORMATS = {"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"}
_ROUND_FORMAT_TO_ENGINE_FORMAT = {
"match": "singles",
"fourball": "fourball",
"foursome": "foursome",
"greensome": "greensome",
"scramble_2": "scramble_2",
"scramble_4": "scramble_4",
}
# Antall SPILLERE forventet per side -- IKKE det samme som app.handicap sin
# FORMAT_UNIT_SIZE (som er HCP-BEREGNINGS-enheten: fourball beregnes
# individuelt der, men har likevel 2 spillere per side for selve
# matchresultat-sammenligningen).
_SIDE_PLAYER_COUNT = {"match": 1, "fourball": 2, "foursome": 2, "greensome": 2, "scramble_2": 2, "scramble_4": 4}
# Bekreftet av bruker (2026-07-28): 2 spillere gjør skins i praksis
# identisk med en vanlig match -- 3+ er der en oppsamlet, uavgjort pott
# faktisk gir mening som eget spillformat.
_SKINS_MIN_PARTICIPANTS = 3
class RoundCreate(BaseModel): class RoundCreate(BaseModel):
course_source: Literal["teeoff", "custom"] course_source: Literal["teeoff", "custom"]
@ -425,12 +600,21 @@ class RoundCreate(BaseModel):
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne # Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
# tidsbruk (completed_at - started_at) når runden fullføres. # tidsbruk (completed_at - started_at) når runden fullføres.
started_at: datetime | None = None started_at: datetime | None = None
# Selvdeklarert spilleform (ADR-038 Beslutning D) -- ingen egen # Selvdeklarert spilleform (ADR-038 Beslutning D, utvidet ADR-039) --
# match-motor for frittstående runder, så dette taes brukerens ord for. # ingen egen "oppdag selv"-motor for frittstående runder, så dette taes
play_format: Literal["stroke", "match"] = "stroke" # brukerens ord for. match/fourball/foursome/greensome/scramble_*
# trenger sider (opprettes separat via POST .../sides, se ADR-039
# Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
play_format: Literal[
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"
] = "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.
exclude_owner_from_handicap: bool = False exclude_owner_from_handicap: bool = False
# KUN meningsfulle (og påkrevd) når play_format='skins' -- ADR-039
# Beslutning D. Brukeren som setter opp runden velger begge uavhengig.
skins_scoring: Literal["net", "gross"] | None = None
skins_tie_handling: Literal["carry", "split"] | None = None
class RoundParticipantOut(BaseModel): class RoundParticipantOut(BaseModel):
@ -461,6 +645,18 @@ class RoundParticipantOut(BaseModel):
# counts_for_handicap. Kontrolleres av DENNE deltakeren selv (eller # counts_for_handicap. Kontrolleres av DENNE deltakeren selv (eller
# runde-eieren), se update_participant. # runde-eieren), se update_participant.
exclude_from_handicap: bool exclude_from_handicap: bool
# ADR-039 Beslutning A -- hvilken side deltakeren er på (match/fourball/
# foursome/greensome/scramble_*), null for slagspill/skins (ingen sider).
round_side_id: str | None
# ADR-039 Beslutning B -- den format-avledede, RELATIVE match-play-
# enheten (fra AllowanceStrategy), IKKE samme som course_handicap_
# snapshot (som forblir den absolutte, individuelle WHS-verdien).
playing_handicap: int | None
class RoundSideOut(BaseModel):
id: str
label: str | None
class RoundOut(BaseModel): class RoundOut(BaseModel):
@ -473,9 +669,18 @@ class RoundOut(BaseModel):
start_hole: int start_hole: int
holes_planned: int holes_planned: int
play_format: str play_format: str
skins_scoring: str | None
skins_tie_handling: str | None
started_at: str | None started_at: str | None
completed_at: str | None completed_at: str | None
participants: list[RoundParticipantOut] participants: list[RoundParticipantOut]
# ADR-039 Beslutning A -- tom liste for formater uten sider (slagspill/skins).
sides: list[RoundSideOut]
# 2026-07-28 oppfølging -- match/skins/fourball/foursome/greensome/
# scramble krever flere spillere enn slagspill, håndhevet i
# complete_round. setup_message er None når setup_complete er sann.
setup_complete: bool
setup_message: str | None
# Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036 # Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036
# fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra # fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra
# round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme # round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme
@ -490,7 +695,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
round_row = await conn.fetchrow( round_row = await conn.fetchrow(
""" """
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot, SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, play_format, started_at, completed_at played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
started_at, completed_at
FROM round WHERE id = $1 FROM round WHERE id = $1
""", """,
round_id, round_id,
@ -503,13 +709,17 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap, rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level, rp.score_differential::float AS score_differential, rp.stat_level,
rp.exclude_from_handicap rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
rp.playing_handicap
FROM round_participant rp FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
""", """,
round_id, round_id,
) )
side_rows = await conn.fetch(
"SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
)
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket # Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
# medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN # medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
# fremdrift her, ikke alltid eierens. Faller tilbake til eierens rad # fremdrift her, ikke alltid eierens. Faller tilbake til eierens rad
@ -532,6 +742,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
my_score_to_par = ( my_score_to_par = (
viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None
) )
setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
return RoundOut( return RoundOut(
id=round_row["id"], id=round_row["id"],
course_source=round_row["course_source"], course_source=round_row["course_source"],
@ -542,9 +753,14 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
start_hole=round_row["start_hole"], start_hole=round_row["start_hole"],
holes_planned=round_row["holes_planned"], holes_planned=round_row["holes_planned"],
play_format=round_row["play_format"], play_format=round_row["play_format"],
skins_scoring=round_row["skins_scoring"],
skins_tie_handling=round_row["skins_tie_handling"],
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None, started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None, completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
sides=[RoundSideOut(**dict(r)) for r in side_rows],
setup_complete=setup_complete,
setup_message=setup_message,
my_holes_played=my_holes_played, my_holes_played=my_holes_played,
my_total_score=my_total_score, my_total_score=my_total_score,
my_score_to_par=my_score_to_par, my_score_to_par=my_score_to_par,
@ -565,6 +781,8 @@ async def _create_participant(
stat_level: StatLevel = "strokes_only", stat_level: StatLevel = "strokes_only",
guest_email: str | None = None, guest_email: str | None = None,
exclude_from_handicap: bool = False, exclude_from_handicap: bool = False,
round_side_id: str | None = None,
individual_round_hole: bool = True,
) -> str: ) -> str:
rating = resolved.rating_for(tee_name, gender) rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None course_rating = slope_rating = tee_par = None
@ -579,8 +797,8 @@ async def _create_participant(
INSERT INTO round_participant INSERT INTO round_participant
(round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot, (round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot, course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
stat_level, exclude_from_handicap) stat_level, exclude_from_handicap, round_side_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
round_id, round_id,
@ -597,9 +815,15 @@ async def _create_participant(
course_handicap_snapshot, course_handicap_snapshot,
stat_level, stat_level,
exclude_from_handicap, exclude_from_handicap,
round_side_id,
) )
participant_id = participant_row["id"] participant_id = participant_row["id"]
# ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/
# scramble) lagrer scoren PÅ SIDEN (round_hole.round_side_id, opprettet
# samtidig med selve siden, se create_round_side), ikke per deltaker --
# ingen individuelle round_hole-rader for disse.
if individual_round_hole:
for hole_number, par, stroke_index in resolved.holes: for hole_number, par, stroke_index in resolved.holes:
await conn.execute( await conn.execute(
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)", "INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
@ -625,6 +849,17 @@ async def create_round(
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.") raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
resolved = await _resolve_personal_course(body.personal_course_id) resolved = await _resolve_personal_course(body.personal_course_id)
# ADR-039 Beslutning D -- skins-konfigurasjon er påkrevd for skins, og
# gir ikke mening for noe annet format.
if body.play_format == "skins":
if body.skins_scoring is None or body.skins_tie_handling is None:
raise app_error(
400, "VALIDATION_FAILED",
"skins_scoring og skins_tie_handling er påkrevd når play_format='skins'.",
)
elif body.skins_scoring is not None or body.skins_tie_handling is not None:
raise app_error(400, "VALIDATION_FAILED", "skins_scoring/skins_tie_handling gir kun mening for play_format='skins'.")
async with plain_connection() as conn: async with plain_connection() as conn:
owner_row = await conn.fetchrow( owner_row = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1", "SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
@ -645,8 +880,9 @@ async def create_round(
INSERT INTO round INSERT INTO round
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id, (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name, play_format) start_hole, holes_planned, started_at, name, play_format,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) skins_scoring, skins_tie_handling)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
user.user_id, user.user_id,
@ -662,6 +898,8 @@ async def create_round(
body.started_at, body.started_at,
body.name.strip() if body.name and body.name.strip() else None, body.name.strip() if body.name and body.name.strip() else None,
body.play_format, body.play_format,
body.skins_scoring,
body.skins_tie_handling,
) )
round_id = round_row["id"] round_id = round_row["id"]
@ -671,6 +909,7 @@ async def create_round(
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"], gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level, stat_level=body.stat_level,
exclude_from_handicap=body.exclude_owner_from_handicap, exclude_from_handicap=body.exclude_owner_from_handicap,
individual_round_hole=body.play_format not in SIDE_IS_UNIT,
) )
return await _load_round_out(conn, round_id, user.user_id) return await _load_round_out(conn, round_id, user.user_id)
@ -1289,6 +1528,10 @@ class ParticipantCreate(BaseModel):
# gjest har ingen computed_handicap_index å beskytte). Frontend # gjest har ingen computed_handicap_index å beskytte). Frontend
# forhåndsutfyller True når rundens play_format='match'. # forhåndsutfyller True når rundens play_format='match'.
exclude_from_handicap: bool = False exclude_from_handicap: bool = False
# ADR-039 Beslutning A -- valgfritt, kun gyldig når rundens play_format
# bruker sider (match/fourball/foursome/greensome/scramble_*). Kan også
# settes/endres senere via PATCH .../participants/{id}.
round_side_id: str | None = None
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201) @router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
@ -1305,7 +1548,8 @@ async def add_participant(
async with plain_connection() as conn: async with plain_connection() as conn:
round_row = await conn.fetchrow( round_row = await conn.fetchrow(
"SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, " "SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, "
"teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1", "teeoff_course_id, personal_course_id, tee_name_snapshot, play_format, completed_at "
"FROM round WHERE id = $1",
round_id, round_id,
) )
if round_row is None: if round_row is None:
@ -1313,10 +1557,17 @@ async def add_participant(
if round_row["owner_user_id"] != user.user_id: if round_row["owner_user_id"] != user.user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.") raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
if round_row["course_source"] == "teeoff": if body.round_side_id is not None:
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"])) if round_row["play_format"] not in _TWO_SIDED_FORMATS:
else: raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
resolved = await _resolve_personal_course(round_row["personal_course_id"]) side_exists = await conn.fetchval(
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", body.round_side_id, round_id
)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
await _check_side_capacity(conn, body.round_side_id, round_row["play_format"])
resolved = await _resolve_round_course(round_row)
if body.user_id is not None: if body.user_id is not None:
if body.user_id == round_row["owner_user_id"]: if body.user_id == round_row["owner_user_id"]:
@ -1359,7 +1610,11 @@ async def add_participant(
stat_level=body.stat_level, stat_level=body.stat_level,
guest_email=body.guest_email, guest_email=body.guest_email,
exclude_from_handicap=body.exclude_from_handicap, exclude_from_handicap=body.exclude_from_handicap,
round_side_id=body.round_side_id,
individual_round_hole=round_row["play_format"] not in SIDE_IS_UNIT,
) )
if body.round_side_id is not None:
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email, SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
@ -1368,7 +1623,8 @@ async def add_participant(
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap, rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level, rp.score_differential::float AS score_differential, rp.stat_level,
rp.exclude_from_handicap rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
rp.playing_handicap
FROM round_participant rp FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 WHERE rp.id = $1
@ -1379,6 +1635,81 @@ async def add_participant(
return RoundParticipantOut(**dict(row)) return RoundParticipantOut(**dict(row))
# ---------------------------------------------------------------------------
# Sider (ADR-039 Beslutning A) -- nøyaktig to per runde, kun for formater
# som faktisk bruker sider. Håndhevet i app-laget (samme mønster som
# ADR-011s to-lags-grense for org-turneringer), ikke en DB-constraint.
# ---------------------------------------------------------------------------
class RoundSideCreate(BaseModel):
label: str | None = Field(default=None, max_length=100)
@router.post("/rounds/{round_id}/sides", response_model=RoundSideOut, status_code=201)
async def create_round_side(
round_id: str,
body: RoundSideCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundSideOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT play_format, course_source, teeoff_facility_slug, teeoff_course_id, "
"personal_course_id, completed_at FROM round WHERE id = $1",
round_id,
)
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
if round_row["completed_at"] is not None:
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
existing_count = await conn.fetchval("SELECT count(*) FROM round_side WHERE round_id = $1", round_id)
if existing_count >= 2:
raise app_error(409, "LIMIT_REACHED", "En runde kan maks ha to sider.")
async with conn.transaction():
side_row = await conn.fetchrow(
"INSERT INTO round_side (round_id, label) VALUES ($1, $2) RETURNING id::text AS id, label",
round_id, body.label,
)
# Delt-ball-formater (ADR-039 Beslutning C): siden får sine
# round_hole-rader med en gang -- rating trengs ikke her, kun
# par/stroke-index-snapshot, uavhengig av om siden har fått
# noen deltakere ennå.
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]]
if engine_format in SIDE_IS_UNIT:
resolved = await _resolve_round_course(round_row)
for hole_number, par, stroke_index in resolved.holes:
await conn.execute(
"INSERT INTO round_hole (round_side_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
side_row["id"], hole_number, par, stroke_index,
)
await broadcast_round_update(round_id)
return RoundSideOut(**dict(side_row))
@router.delete("/rounds/{round_id}/sides/{side_id}", status_code=204)
async def delete_round_side(
round_id: str,
side_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT completed_at FROM round WHERE id = $1", round_id)
if round_row["completed_at"] is not None:
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
side_exists = await conn.fetchval(
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id
)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
# Kaskade fjerner sidens round_hole-rader; deltakere som var på
# denne siden faller tilbake til round_side_id = NULL (ON DELETE
# SET NULL) -- de forblir i runden, bare uten side-tilhørighet.
await conn.execute("DELETE FROM round_side WHERE id = $1", side_id)
await broadcast_round_update(round_id)
@router.get("/rounds/{round_id}/tee-options", response_model=list[TeeOption]) @router.get("/rounds/{round_id}/tee-options", response_model=list[TeeOption])
async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_current_user)) -> list[TeeOption]: async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_current_user)) -> list[TeeOption]:
"""Utslagene tilgjengelig på RUNDENS bane -- brukt av 'rediger spiller'- """Utslagene tilgjengelig på RUNDENS bane -- brukt av 'rediger spiller'-
@ -1421,11 +1752,17 @@ class ParticipantUpdate(BaseModel):
# ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket # ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
# medspiller som redigerer SIN EGEN rad) får sende her. # medspiller som redigerer SIN EGEN rad) får sende her.
exclude_from_handicap: bool | None = None exclude_from_handicap: bool | None = None
# ADR-039 Beslutning A -- (re)tildel/fjern en deltakers side. Sendes som
# `null` for å fjerne fra en side. Eier-only (se _OWNER_ONLY_
# PARTICIPANT_FIELDS under).
round_side_id: str | None = None
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder -- # Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
# eier-only, se update_participant sin autorisasjonssjekk. # eier-only, se update_participant sin autorisasjonssjekk.
_OWNER_ONLY_PARTICIPANT_FIELDS = {"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email"} _OWNER_ONLY_PARTICIPANT_FIELDS = {
"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email", "round_side_id",
}
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut) @router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
@ -1450,7 +1787,7 @@ async def update_participant(
rp.counts_for_handicap, rp.counts_for_handicap,
r.owner_user_id::text AS round_owner_user_id, r.owner_user_id::text AS round_owner_user_id,
r.completed_at, r.course_source, r.teeoff_facility_slug, r.completed_at, r.course_source, r.teeoff_facility_slug,
r.teeoff_course_id, r.personal_course_id r.teeoff_course_id, r.personal_course_id, r.play_format
FROM round_participant rp FROM round_participant rp
JOIN round r ON r.id = rp.round_id JOIN round r ON r.id = rp.round_id
WHERE rp.id = $1 AND rp.round_id = $2 WHERE rp.id = $1 AND rp.round_id = $2
@ -1499,6 +1836,19 @@ async def update_participant(
if "exclude_from_handicap" in updates: if "exclude_from_handicap" in updates:
values.append(updates["exclude_from_handicap"]) values.append(updates["exclude_from_handicap"])
set_clauses.append(f"exclude_from_handicap = ${len(values)}") set_clauses.append(f"exclude_from_handicap = ${len(values)}")
if "round_side_id" in updates:
new_side_id = updates["round_side_id"]
if new_side_id is not None:
if current["play_format"] not in _TWO_SIDED_FORMATS:
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
side_exists = await conn.fetchval(
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
await _check_side_capacity(conn, new_side_id, current["play_format"], exclude_participant_id=participant_id)
values.append(new_side_id)
set_clauses.append(f"round_side_id = ${len(values)}")
if rating_changed: if rating_changed:
if current["course_source"] == "teeoff": if current["course_source"] == "teeoff":
@ -1546,6 +1896,13 @@ async def update_participant(
f"UPDATE round_participant SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING id", f"UPDATE round_participant SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING id",
*values, *values,
) )
# Side-tildeling endret (ADR-039) -- regn playing_handicap for begge
# sider på nytt FØR raden under leses, slik at responsen reflekterer
# den ferske verdien (billig no-op hvis fortsatt ikke komplett).
if "round_side_id" in updates and current["play_format"] in _TWO_SIDED_FORMATS:
await _recompute_side_handicaps(conn, round_id, current["play_format"])
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email, SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
@ -1554,7 +1911,8 @@ async def update_participant(
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap, rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level, rp.score_differential::float AS score_differential, rp.stat_level,
rp.exclude_from_handicap rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
rp.playing_handicap
FROM round_participant rp FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 WHERE rp.id = $1
@ -1671,6 +2029,278 @@ async def list_holes(
] ]
# ---------------------------------------------------------------------------
# Delt-ball-scoring (ADR-039 Beslutning C) -- foursome/greensome/scramble
# lagrer ÉN score PER SIDE per hull, ikke per spiller. Ingen av de andre
# detalj-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
# entydig mening for en delt ball -- kun selve slagtallet.
# ---------------------------------------------------------------------------
class RoundSideHoleOut(BaseModel):
hole_number: int
par: int
stroke_index: int
played: bool
score: int | None
@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
async def list_side_holes(
round_id: str,
side_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[RoundSideHoleOut]:
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole "
"WHERE round_side_id = $1 ORDER BY hole_number",
side_id,
)
return [RoundSideHoleOut(**dict(r)) for r in rows]
class SideHoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)
@router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut)
async def update_side_hole(
round_id: str,
side_id: str,
hole_number: int,
body: SideHoleUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundSideHoleOut:
async with plain_connection() as conn:
# Samme begrunnelse som update_hole -- en medspiller som fører
# score for flighten skal også kunne føre for en delt-ball-side.
await _get_accessible_round_or_404(conn, round_id, user.user_id)
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
row = await conn.fetchrow(
"""
UPDATE round_hole SET played = $3, score = $4
WHERE round_side_id = $1 AND hole_number = $2
RETURNING hole_number, par, stroke_index, played, score
""",
side_id, hole_number, body.played, body.score,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
await broadcast_round_update(round_id)
return RoundSideHoleOut(**dict(row))
# ---------------------------------------------------------------------------
# Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/
# greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret),
# samme "cache aldri det som kan regnes ut fra rå data"-filosofi som resten
# av appen. Slagspill trenger ingen egen visning her -- eksisterende
# scorekort/leaderboard dekker det allerede.
# ---------------------------------------------------------------------------
class RoundFormatResultOut(BaseModel):
play_format: str
ready: bool # False = handicap ikke klart for én eller begge sider ennå
# Match/fourball/foursome/greensome/scramble:
match_lead: int | None = None
match_holes_played: int | None = None
match_holes_remaining: int | None = None
match_is_closed: bool | None = None
match_is_dormie: bool | None = None
match_status_text: str | None = None
hole_results: list[str] | None = None # "a"/"b"/"halved" per avgjort hull, i spillerekkefølge
# Skins:
skins_won: dict[str, float] | None = None
def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]:
"""Samme sirkulære spillerekkefølge som frontend allerede bruker
(round-detail.tsx) -- starter start_hole, ruller rundt 1-18."""
return [((start_hole - 1 + i) % 18) + 1 for i in range(holes_planned)]
@router.get("/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
async def get_round_format_result(
round_id: str,
user: CurrentUser = Depends(get_current_user),
) -> RoundFormatResultOut:
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned "
"FROM round WHERE id = $1",
round_id,
)
play_format = round_row["play_format"]
if play_format in _TWO_SIDED_FORMATS:
relative = await _relative_strokes_for_round(conn, round_id, play_format)
if not relative:
return RoundFormatResultOut(play_format=play_format, ready=False)
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
if engine_format in SIDE_IS_UNIT:
# Delt ball -- én rad per SIDE per hull.
rows = await conn.fetch(
"""
SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score
FROM round_hole rh
JOIN round_side rs ON rs.id = rh.round_side_id
WHERE rs.round_id = $1
""",
round_id,
)
else:
# Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv
# (samme som _relative_strokes_for_round sin nøkkel for
# disse formatene), IKKE siden -- hvilken side hver deltaker
# tilhører slås opp separat via unit_to_side under.
rows = await conn.fetch(
"""
SELECT rp.id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score
FROM round_hole rh
JOIN round_participant rp ON rp.id = rh.round_participant_id
WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL
""",
round_id,
)
by_unit_hole: dict[tuple[str, int], list] = {}
stroke_index_by_hole: dict[int, int] = {}
for r in rows:
if r["played"] and r["score"] is not None:
by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"])
stroke_index_by_hole[r["hole_number"]] = r["stroke_index"]
all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None
side_ids = sorted(relative.keys()) if engine_format in SIDE_IS_UNIT else None
if side_ids is None:
# Individuelle enheter er round_participant_id-er -- hent
# hvilken side hver enhet faktisk tilhører for å vite "a"/"b".
unit_side = await conn.fetch(
"SELECT id::text AS id, round_side_id::text AS side_id FROM round_participant WHERE round_id = $1",
round_id,
)
unit_to_side = {r["id"]: r["side_id"] for r in unit_side}
side_ids = sorted({s for s in unit_to_side.values() if s is not None})
if len(side_ids) != 2 or all_18_si is None:
return RoundFormatResultOut(play_format=play_format, ready=False)
strokes_per_hole = {
unit: dict(zip(range(1, 19), allocate_over_played_holes(v, all_18_si, list(range(1, 19)))))
for unit, v in relative.items()
}
side_a_id, side_b_id = side_ids
expected_players = _SIDE_PLAYER_COUNT[play_format]
def side_net(hole_number: int, side_id: str) -> int | None:
if engine_format in SIDE_IS_UNIT:
scores = by_unit_hole.get((side_id, hole_number), [])
if len(scores) != 1:
return None
per_hole = strokes_per_hole.get(side_id)
if per_hole is None:
return None
return scores[0] - per_hole[hole_number]
nets = []
for unit_id, unit_side_id in unit_to_side.items():
if unit_side_id != side_id:
continue
scores = by_unit_hole.get((unit_id, hole_number), [])
if len(scores) != 1:
return None
per_hole = strokes_per_hole.get(unit_id)
if per_hole is None:
return None
nets.append(scores[0] - per_hole[hole_number])
if len(nets) != expected_players:
return None
return min(nets)
hole_results: list[HoleResult] = []
for hole_number in play_order:
net_a = side_net(hole_number, side_a_id)
net_b = side_net(hole_number, side_b_id)
if net_a is None or net_b is None:
break
if net_a < net_b:
hole_results.append(HoleResult.SIDE_A)
elif net_b < net_a:
hole_results.append(HoleResult.SIDE_B)
else:
hole_results.append(HoleResult.HALVED)
state = compute_match_state(hole_results, total_holes=round_row["holes_planned"])
return RoundFormatResultOut(
play_format=play_format,
ready=True,
match_lead=state.lead,
match_holes_played=state.holes_played,
match_holes_remaining=state.holes_remaining,
match_is_closed=state.is_closed,
match_is_dormie=state.is_dormie,
match_status_text=state.describe(),
hole_results=[_RESULT_LABEL[r] for r in hole_results],
)
if play_format == "skins":
participant_rows = await conn.fetch(
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_id = $1",
round_id,
)
hole_rows = await conn.fetch(
"""
SELECT rp.id::text AS participant_id, rh.hole_number, rh.stroke_index, rh.played, rh.score
FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id
WHERE rp.round_id = $1
ORDER BY rh.hole_number
""",
round_id,
)
strokes_by_participant: dict[str, dict[int, int]] = {}
if round_row["skins_scoring"] == "net":
by_participant_holes: dict[str, list] = {}
for r in hole_rows:
by_participant_holes.setdefault(r["participant_id"], []).append(r)
ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows}
for pid, holes in by_participant_holes.items():
ch = ch_by_participant.get(pid)
if ch is None:
continue
allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes])
strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes, allocation)}
scores_by_hole_number: dict[int, list[tuple[str, int]]] = {}
for r in hole_rows:
if not r["played"] or r["score"] is None:
continue
if round_row["skins_scoring"] == "net":
per_hole = strokes_by_participant.get(r["participant_id"])
if per_hole is None:
continue
value = r["score"] - per_hole[r["hole_number"]]
else:
value = r["score"]
scores_by_hole_number.setdefault(r["hole_number"], []).append((r["participant_id"], value))
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order]
skins_won = compute_skins(scores_by_hole, tie_handling=round_row["skins_tie_handling"])
return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won)
return RoundFormatResultOut(play_format=play_format, ready=True)
class LeaderboardHoleOut(BaseModel): class LeaderboardHoleOut(BaseModel):
hole_number: int hole_number: int
par: int par: int
@ -1890,7 +2520,15 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
# Samme begrunnelse som update_hole -- en medspiller som fører # Samme begrunnelse som update_hole -- en medspiller som fører
# score for flighten skal også kunne markere runden ferdig. # score for flighten skal også kunne markere runden ferdig.
await _get_accessible_round_or_404(conn, round_id, user.user_id) await _get_accessible_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id) round_row = await conn.fetchrow("SELECT holes_planned, play_format FROM round WHERE id = $1", round_id)
# 2026-07-28 oppfølging: match/skins/fourball/foursome/greensome/
# scramble krever flere spillere enn slagspill -- ingenting fanget
# dette opp før nå. Avvis tydelig FØR noe regnes ut, i stedet for å
# stille fullføre en runde som er strukturelt umulig for formatet.
setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
if not setup_complete:
raise app_error(409, "SETUP_INCOMPLETE", setup_message)
participants = await conn.fetch( participants = await conn.fetch(
""" """

View file

@ -71,9 +71,24 @@ type CourseMeta =
type Gender = "m" | "f" type Gender = "m" | "f"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full" type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
// ADR-038 -- selvdeklarert spilleform. Ingen egen match-motor for // ADR-038/ADR-039 -- selvdeklarert spilleform. Ingen egen match-/skins-motor
// frittstående runder, så dette taes brukerens ord for. // for frittstående runder, så dette taes brukerens ord for. Sider (for de
type PlayFormat = "stroke" | "match" // to-sidede formatene) opprettes IKKE her -- det skjer i selve runden
// (POST .../sides), siden det krever at deltakerne allerede finnes.
type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "greensome" | "scramble_2" | "scramble_4"
type SkinsScoring = "net" | "gross"
type SkinsTieHandling = "carry" | "split"
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
{ 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: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." },
{ value: "foursome", label: "Foursome", desc: "To mot to, én delt ball, annenhver slår." },
{ value: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." },
{ value: "scramble_2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." },
{ value: "scramble_4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." },
]
// --- API-typer --------------------------------------------------------------- // --- API-typer ---------------------------------------------------------------
@ -203,36 +218,35 @@ export function NewRound() {
statLevel: StatLevel statLevel: StatLevel
playFormat: PlayFormat playFormat: PlayFormat
excludeFromHandicap: boolean excludeFromHandicap: boolean
skinsScoring: SkinsScoring | null
skinsTieHandling: SkinsTieHandling | null
}): Promise<{ id: string }> { }): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane") if (!courseMeta) throw new Error("Mangler valgt bane")
const shared = {
tee_name: payload.teeName,
name: payload.name || null,
played_at: payload.date,
started_at: payload.startedAt,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
play_format: payload.playFormat,
exclude_owner_from_handicap: payload.excludeFromHandicap,
skins_scoring: payload.skinsScoring,
skins_tie_handling: payload.skinsTieHandling,
}
const body = const body =
courseMeta.source === "teeoff" courseMeta.source === "teeoff"
? { ? {
course_source: "teeoff", course_source: "teeoff",
teeoff_facility_slug: courseMeta.facilitySlug, teeoff_facility_slug: courseMeta.facilitySlug,
teeoff_course_id: courseMeta.teeoffCourseId, teeoff_course_id: courseMeta.teeoffCourseId,
tee_name: payload.teeName, ...shared,
name: payload.name || null,
played_at: payload.date,
started_at: payload.startedAt,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
play_format: payload.playFormat,
exclude_owner_from_handicap: payload.excludeFromHandicap,
} }
: { : {
course_source: "custom", course_source: "custom",
personal_course_id: courseMeta.personalCourseId, personal_course_id: courseMeta.personalCourseId,
tee_name: payload.teeName, ...shared,
name: payload.name || null,
played_at: payload.date,
started_at: payload.startedAt,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
play_format: payload.playFormat,
exclude_owner_from_handicap: payload.excludeFromHandicap,
} }
const res = await fetch("/rounds", { const res = await fetch("/rounds", {
method: "POST", method: "POST",
@ -1121,6 +1135,8 @@ function ConfirmStep({
statLevel: StatLevel statLevel: StatLevel
playFormat: PlayFormat playFormat: PlayFormat
excludeFromHandicap: boolean excludeFromHandicap: boolean
skinsScoring: SkinsScoring | null
skinsTieHandling: SkinsTieHandling | null
}) => Promise<{ id: string }> }) => Promise<{ id: string }>
onCreated: (roundId: string) => void onCreated: (roundId: string) => void
}) { }) {
@ -1134,17 +1150,27 @@ function ConfirmStep({
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only") const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke") const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke")
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false) const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
// ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
function choosePlayFormat(next: PlayFormat) { function choosePlayFormat(next: PlayFormat) {
setPlayFormat(next) setPlayFormat(next)
// ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når // ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når match
// matchspill velges. Brukeren kan uansett overstyre under. // velges. De andre formatene har egne HCP-regler (ADR-039 Beslutning E)
// som ikke bruker dette feltet -- nullstill det da for å unngå et
// misvisende forhåndsvalg som likevel ikke sendes videre meningsfullt.
setExcludeFromHandicap(next === "match") setExcludeFromHandicap(next === "match")
} }
const selectedTee = compatibleTees.find((t) => t.id === teeId) const selectedTee = compatibleTees.find((t) => t.id === teeId)
// ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble
// krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må
// finnes først). Vises som en forklarende note her, ikke et skjema.
const needsSidesSetup = playFormat !== "stroke" && playFormat !== "skins"
async function handleStart() { async function handleStart() {
if (!selectedTee) return if (!selectedTee) return
@ -1165,6 +1191,8 @@ function ConfirmStep({
statLevel, statLevel,
playFormat, playFormat,
excludeFromHandicap, excludeFromHandicap,
skinsScoring: playFormat === "skins" ? skinsScoring : null,
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
}) })
onCreated(created.id) onCreated(created.id)
} catch { } catch {
@ -1311,32 +1339,33 @@ function ConfirmStep({
</div> </div>
</fieldset> </fieldset>
{/* Spilleform (ADR-038) -- selvdeklarert, ingen egen match-motor */} {/* Spilleform (ADR-038/ADR-039) -- selvdeklarert, ingen egen match-/
skins-motor. match/fourball/foursome/greensome/scramble krever
sider -- opprettes i selve runden etter at deltakerne er lagt til. */}
<fieldset className="flex flex-col gap-3"> <fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Spilleform</legend> <legend className="mb-1 text-base font-semibold text-foreground">Spilleform</legend>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{( {PLAY_FORMAT_OPTIONS.map((opt) => (
[
{ value: "stroke" as const, label: "Slagspill" },
{ value: "match" as const, label: "Matchspill" },
]
).map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
onClick={() => choosePlayFormat(opt.value)} onClick={() => choosePlayFormat(opt.value)}
aria-pressed={playFormat === opt.value} aria-pressed={playFormat === opt.value}
className={cn( className={cn(
"flex min-h-[44px] items-center justify-center rounded-2xl border px-4 py-3 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "flex min-h-[44px] flex-col items-center justify-center gap-0.5 rounded-2xl border px-3 py-3 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
playFormat === opt.value playFormat === opt.value
? "border-primary bg-primary/10 text-foreground" ? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground", : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)} )}
> >
{opt.label} <span className="text-base font-bold">{opt.label}</span>
</button> </button>
))} ))}
</div> </div>
<p className="text-sm text-muted-foreground">
{PLAY_FORMAT_OPTIONS.find((o) => o.value === playFormat)?.desc}
</p>
{playFormat === "match" && ( {playFormat === "match" && (
<label className="flex min-h-[44px] items-start gap-3 rounded-2xl border border-border bg-card px-5 py-3"> <label className="flex min-h-[44px] items-start gap-3 rounded-2xl border border-border bg-card px-5 py-3">
<input <input
@ -1350,12 +1379,79 @@ function ConfirmStep({
Ekskluder denne runden fra mitt faktiske HCP Ekskluder denne runden fra mitt faktiske HCP
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Anbefalt for matchspill -- konsederte hull gir ofte et upålitelig grunnlag for Anbefalt for match -- konsederte hull gir ofte et upålitelig grunnlag for
HCP-beregning. Du kan endre dette senere. HCP-beregning. Du kan endre dette senere.
</span> </span>
</span> </span>
</label> </label>
)} )}
{playFormat === "skins" && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card px-5 py-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Poengberegning</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "net" as const, label: "Netto (m/HCP)" },
{ value: "gross" as const, label: "Brutto" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSkinsScoring(opt.value)}
aria-pressed={skinsScoring === opt.value}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
skinsScoring === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Ved uavgjort hull</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "carry" as const, label: "Rullerer over" },
{ value: "split" as const, label: "Deles likt" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSkinsTieHandling(opt.value)}
aria-pressed={skinsTieHandling === opt.value}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
skinsTieHandling === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<p className="text-sm text-muted-foreground">
Skins krever minst 3 spillere i runden -- legg til medspillere etter at runden er opprettet.
</p>
</div>
)}
{needsSidesSetup && (
<p className="text-sm text-muted-foreground">
Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er
lagt til.
</p>
)}
</fieldset> </fieldset>
{/* Hole count */} {/* Hole count */}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,7 @@ from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal, ROUND_FLOOR from decimal import Decimal, ROUND_FLOOR
from enum import Enum from enum import Enum
from typing import Protocol, Sequence from typing import Literal, Protocol, Sequence
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -585,3 +585,50 @@ class Player:
def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy) -> int: def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy) -> int:
"""Playing Handicap for én enhet (spiller eller lag) gitt en strategi.""" """Playing Handicap for én enhet (spiller eller lag) gitt en strategi."""
return strategy.playing_handicap([p.course_handicap_raw() for p in players]) return strategy.playing_handicap([p.course_handicap_raw() for p in players])
# ---------------------------------------------------------------------------
# Skins (ADR-039 Beslutning D) -- ikke et WHS/R&A-regulert format, ingen
# tilsvarende motorstøtte fantes fra før (ulikt match play over).
# ---------------------------------------------------------------------------
def compute_skins(
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
tie_handling: Literal["carry", "split"] = "carry",
) -> dict[str, float]:
"""Skins vunnet per deltaker, gitt scorer hull for hull.
`scores_by_hole[i]` er (deltaker_id, score)-par for hull i+1 -- KUN for
deltakere som faktisk har registrert en score det hullet. Scoren skal
allerede være avgjort NETTO eller BRUTTO av kalleren FØR denne funksjonen
kalles (`round.skins_scoring`) -- motoren selv vet ikke, og bryr seg ikke
om, hvilket.
- "carry": et uavgjort hull sitt "skinn" (og alt tidligere oppsamlet)
ruller videre til neste hull -- førstemann til en ENTYDIG laveste
score der tar HELE potten.
- "split": et uavgjort hull sitt skinn (og alt oppsamlet) deles LIKT
mellom de tied spillerne i stedet for å rulle videre -- kan gi
brøkdeler, derav float i returtypen.
Et hull uten noen registrerte scorer hopper stille over (verken potten
eller noen vinnere endres) -- ikke en feiltilstand.
"""
winnings: dict[str, float] = {}
pot = 0.0
for hole_scores in scores_by_hole:
if not hole_scores:
continue
pot += 1.0
lowest = min(score for _, score in hole_scores)
winners = [pid for pid, score in hole_scores if score == lowest]
if len(winners) == 1:
winnings[winners[0]] = winnings.get(winners[0], 0.0) + pot
pot = 0.0
elif tie_handling == "split":
share = pot / len(winners)
for pid in winners:
winnings[pid] = winnings.get(pid, 0.0) + share
pot = 0.0
# "carry": potten står urørt og tas med videre til neste hull.
return winnings

View file

@ -32,6 +32,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,
compute_skins,
max_hole_score_for_handicap, max_hole_score_for_handicap,
net_par, net_par,
round_counts_for_handicap, round_counts_for_handicap,
@ -502,6 +503,62 @@ def test_course_handicap_9_halves_index_rule_6_1b():
assert course_handicap_9(idx, slope9, rating9, par9) == round_half_up(expected) assert course_handicap_9(idx, slope9, rating9, par9) == round_half_up(expected)
# ---------------------------------------------------------------------------
# Skins (ADR-039 Beslutning D)
# ---------------------------------------------------------------------------
def test_skins_outright_win_every_hole():
scores = [[("a", 4), ("b", 5), ("c", 6)], [("a", 3), ("b", 3), ("c", 5)]]
result = compute_skins(scores, tie_handling="carry")
# Hull 1: a vinner (laveste 4) -> 1 skin. Hull 2: a/b uavgjort (3) -> carry.
assert result == {"a": 1.0}, result
def test_skins_carry_over_then_win():
scores = [
[("a", 4), ("b", 4), ("c", 5)], # uavgjort a/b -> carry (pott=1)
[("a", 5), ("b", 5), ("c", 4)], # c vinner alene -> tar pott (1+1=2)
]
result = compute_skins(scores, tie_handling="carry")
assert result == {"c": 2.0}, result
def test_skins_three_way_tie_carries_whole_pot():
scores = [
[("a", 4), ("b", 4), ("c", 4)], # alle like -> carry (pott=1)
[("a", 5), ("b", 5), ("c", 3)], # c vinner alene -> tar hele pott (2)
]
result = compute_skins(scores, tie_handling="carry")
assert result == {"c": 2.0}, result
def test_skins_split_tie_instead_of_carry():
scores = [[("a", 4), ("b", 4), ("c", 5)]]
result = compute_skins(scores, tie_handling="split")
assert result == {"a": 0.5, "b": 0.5}, result
def test_skins_split_after_prior_carry_splits_whole_pot():
scores = [
[("a", 5), ("b", 4), ("c", 6)], # b vinner alene -> tar pott (1)
[("a", 4), ("b", 4), ("c", 5)], # a/b uavgjort -> splittes (pott=1 denne runden)
]
result = compute_skins(scores, tie_handling="split")
assert result == {"b": 1.0 + 0.5, "a": 0.5}, result
def test_skins_empty_hole_skipped_not_an_error():
scores = [[], [("a", 4), ("b", 5)]]
result = compute_skins(scores, tie_handling="carry")
assert result == {"a": 1.0}, result
def test_skins_no_winner_if_never_decided():
scores = [[("a", 4), ("b", 4)], [("a", 5), ("b", 5)]]
result = compute_skins(scores, tie_handling="carry")
assert result == {}, result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Enkel selvsjekk uten pytest # Enkel selvsjekk uten pytest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------