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:
parent
997f3c40ed
commit
83c4749516
12 changed files with 2401 additions and 116 deletions
68
031_round_play_formats.sql
Normal file
68
031_round_play_formats.sql
Normal 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;
|
||||||
|
|
@ -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
229
CLAUDE.md
|
|
@ -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` på `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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 på 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 nå.
|
||||||
|
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,17 +815,23 @@ 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"]
|
||||||
|
|
||||||
for hole_number, par, stroke_index in resolved.holes:
|
# ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/
|
||||||
await conn.execute(
|
# scramble) lagrer scoren PÅ SIDEN (round_hole.round_side_id, opprettet
|
||||||
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
|
# samtidig med selve siden, se create_round_side), ikke per deltaker --
|
||||||
participant_id,
|
# ingen individuelle round_hole-rader for disse.
|
||||||
hole_number,
|
if individual_round_hole:
|
||||||
par,
|
for hole_number, par, stroke_index in resolved.holes:
|
||||||
stroke_index,
|
await conn.execute(
|
||||||
)
|
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
|
||||||
|
participant_id,
|
||||||
|
hole_number,
|
||||||
|
par,
|
||||||
|
stroke_index,
|
||||||
|
)
|
||||||
return participant_id
|
return participant_id
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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 på 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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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 på 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue