Update Todos

Diagnostisere HCP-bug mot ekte data (gjort)

Fiks 1: filtrer valgte spillere bort fra listen (blind draw)

Fiks 3: tallvelger for slagregistrering (scorekort)

Fiks 4: hardkode full_18-scope i handicap-utregning (bekreftet bug)

Punkt 2: migrasjon 014 + kode (courses.py, handicap.py, matches.py, tournaments.py, frontend)

Scratch-verifisere punkt 2 (fletting, import, validering, remap)

Skrive ADR-029 + oppdatere FEATURE_BACKLOG.md

Presentere full plan og be om bekreftelse (ekte migrasjon + deploy)
Alle fire punkter er nå bygget og grundig scratch-verifisert. Oppsummering før jeg ber om bekreftelse på å rulle ut mot ekte systemer:

1 — Spillerliste: Fikset. Valgte spillere filtreres nå bort i stedet for å bare vises gråtonet.

3 — Tallvelger: Fikset. Ny StrokePicker (1–9, med utvidbar "10+"→10–19).

4 — HCP-bug: Bekreftet ekte kodebug (ikke bare synlighet). Jeg leste ekte produksjonsdata for testøkten din: front_9 + foursome, og alle fire deltakerne hadde NULL i handicap-feltene. Årsak: handicap-utregningen lette etter en tee-rating scopet til øktens hole_config, men slike rader lages i praksis kun med full_18-omfang — så den fant aldri noe for en front_9-økt. Din beskrevne formel (kombinert hcp/2, laveste side til 0 mottatte slag, resten fordelt fra stroke index 1) var allerede riktig implementert — bugen var at den aldri kjørte. Fikset og verifisert: identisk bruttoscore på begge sider ga nå et ikke-delt resultat, som beviser at hcp faktisk brukes.

2 — Tee/kjønn: Du hadde helt rett i presiseringen din — dette var større enn jeg først trodde. Kjønn er nå flyttet fra selve utslaget til ratingen (ny migrasjon 014_tee_gender_to_rating.sql), tee-valget i blind draw er blitt helt automatisk (ingen kjønnsvelger lenger), og manglende kjønn/rating avvises tydelig i stedet for å gjettes. Fikk egen ADR-029. Verifisert i fem separate runder, inkludert mot ekte teeoff-data (Borregaard) og en gjenskaping av akkurat Tjøme-mønsteret.

Bifunn, ikke fikset: en liten, urelatert 500-krasj (stroke-registrering på en bane uten registrerte hull) — vil du at jeg tar den også, eller lar den ligge som egen sak?

Utrullingsplan — dette er første gang i denne runden noe rører ekte data:

Migrasjon 014_tee_gender_to_rating.sql mot ekte teecup_db — slår sammen Tjømes 8 tee-rader til 4 (bevarer alle ratinger, reparerer alle referanser; mekanisk identisk med det jeg nettopp verifiserte i scratch)
This commit is contained in:
Erol Haagenrud 2026-07-20 08:53:53 +02:00
parent 7d911f67d5
commit 52e0cfaaf9
14 changed files with 701 additions and 152 deletions

View file

@ -320,7 +320,12 @@
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)", "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)",
"Bash(curl -s https://teecup.teeoff.no/manifest.webmanifest)", "Bash(curl -s https://teecup.teeoff.no/manifest.webmanifest)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/sw.js)", "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/sw.js)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/icons/icon-512.png)" "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/icons/icon-512.png)",
"Bash(disown)",
"Bash(python3 -c \"import httpx\")",
"Bash(curl -s http://localhost:18800/health)",
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hcp_fix.py)",
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_tee_gender.py)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -0,0 +1,84 @@
-- =====================================================================
-- TeeCup — kjønn hører til RATINGEN, ikke selve utslaget (migrasjon 014)
-- =====================================================================
-- Reist av brukeren 2026-07-19: en golfbane har IKKE fysisk kjønnsdelte
-- utslag -- begge kjønn kan som regel spille fra ethvert utslag. Det
-- eneste som faktisk varierer per kjønn er om klubben har VALGT å slope
-- (rate) et gitt utslag for det respektive kjønnet (noen klubber sloper
-- bevisst ikke det lengste utslaget for damer). `tee.gender` (migrasjon
-- 001) konflaterte feilaktig utslaget og ratingen -- hvert fysisk utslag
-- ble lagret som TO separate `tee`-rader (én per kjønn), bekreftet mot
-- ekte importert Tjøme-data (fire utslag, hver som et rent m/f-par).
--
-- Denne migrasjonen flytter `gender` fra `tee` til `tee_rating` (der det
-- faktisk hører hjemme -- én fysisk tee kan ha 0, 1 eller 2
-- kjønnsspesifikke ratinger), og slår sammen eksisterende kjønns-par-rader
-- til én fysisk tee-rad. Se ARCHITECTURE_DECISIONS.md ADR-029.
--
-- Kjørt trygt mot ekte data FORDI (bekreftet FØR skriving, read-only):
-- alle eksisterende tee-rader er rene navnepar (m+f, ingen 'x'), ingen
-- gruppe har mer enn to rader.
-- =====================================================================
\set ON_ERROR_STOP on
BEGIN;
ALTER TABLE tee_rating ADD COLUMN gender text;
-- Fyll inn gender på HVER eksisterende tee_rating-rad fra sin (ennå
-- urørte) eier-tee, FØR noe slås sammen eller slettes.
UPDATE tee_rating tr
SET gender = t.gender
FROM tee t
WHERE t.id = tr.tee_id;
-- Selve golfregelen: en WHS-rating er alltid for ett bestemt kjønn -- 'x'
-- (som var gyldig på PLAYER-nivå) gir ikke mening for en rating. Krasjer
-- høyt her (NOT NULL + CHECK) hvis noen ukjent/uventet rad skulle mangle
-- et gyldig kjønn -- fanger det FØR det stille blir en umulig rating,
-- ikke etterpå.
ALTER TABLE tee_rating ALTER COLUMN gender SET NOT NULL;
ALTER TABLE tee_rating ADD CONSTRAINT tee_rating_gender_check CHECK (gender IN ('m', 'f'));
-- Den GAMLE unikheten (tee_id, scope) må vekk FØR sammenslåingen under --
-- å flytte en "duplikat"-tee sin rating over til beholder-tee-en kolliderer
-- ellers midlertidig med beholderens EGEN eksisterende (tee_id, scope)-rad
-- (samme scope, men snart ulikt kjønn -- det er jo poenget). Den NYE,
-- kjønnsbevisste unikheten legges til ETTER sammenslåingen, når den faktisk
-- kan håndheves riktig.
ALTER TABLE tee_rating DROP CONSTRAINT tee_rating_tee_id_scope_key;
-- Slå sammen "duplikat"-utslag (samme bane+navn, hittil ulikt kjønn på
-- selve tee-raden) til ÉN fysisk tee-rad. Velger MIN(id) per
-- (course_id, name) som "beholderen".
CREATE TEMP TABLE tee_merge_map AS
SELECT t.id AS old_id,
(SELECT min(t2.id::text)::uuid FROM tee t2
WHERE t2.course_id = t.course_id AND t2.name = t.name) AS keeper_id
FROM tee t;
UPDATE tee_rating tr
SET tee_id = m.keeper_id
FROM tee_merge_map m
WHERE tr.tee_id = m.old_id AND m.old_id <> m.keeper_id;
UPDATE match_participant mp
SET tee_id = m.keeper_id
FROM tee_merge_map m
WHERE mp.tee_id = m.old_id AND m.old_id <> m.keeper_id;
DELETE FROM tee t
USING tee_merge_map m
WHERE t.id = m.old_id AND m.old_id <> m.keeper_id;
DROP TABLE tee_merge_map;
-- Unikhet er nå per (tee_id, scope, gender) -- ett utslag kan ha BÅDE en
-- herre- og en dame-rating for samme scope.
ALTER TABLE tee_rating ADD CONSTRAINT tee_rating_tee_id_scope_gender_key UNIQUE (tee_id, scope, gender);
ALTER TABLE tee DROP CONSTRAINT tee_gender_check;
ALTER TABLE tee DROP COLUMN gender;
COMMIT;

View file

@ -1312,6 +1312,99 @@ for oppfølging, se FEATURE_BACKLOG.md sin PWA-tabell og CLAUDE.md sin
--- ---
## ADR-029: Kjønn hører til tee-RATINGEN, ikke selve utslaget
Reist av brukeren 2026-07-19, som del av en runde med fire rapporterte
UX-/korrekthetshull fra faktisk testing av blind draw og scorekort. Startet
som en antatt frontend-fiks ("tee-valget bør følge spillerens kjønn
automatisk"), men brukeren presiserte at premisset mitt var feil: en
golfbane har IKKE fysisk kjønnsdelte utslag -- begge kjønn kan som regel
spille fra ethvert utslag. Det eneste som faktisk varierer per kjønn er om
klubben har VALGT å slope (rate) et gitt utslag for det respektive kjønnet
(noen klubber sloper bevisst ikke det lengste utslaget for damer).
**Bekreftet problem, ikke antatt:** `tee.gender` (migrasjon 001) la kjønn på
selve utslaget, ikke ratingen. Sjekket mot ekte, importert produksjonsdata
(Tjøme Golfklubb, ADR-019): alle fire utslagene lå som RENE navnepar --
"32"/m + "32"/f, "44"/m + "44"/f, osv. -- to separate `tee`-rader for
akkurat samme fysiske utslag. Blind draw-skjermen viste dette som "velg
Dame- eller Herre-tee", som om det var to ulike steder å slå fra.
**Beslutning A -- `gender` flyttes fra `tee` til `tee_rating`.** Ett fysisk
utslag (`tee`, identifisert kun ved navn) kan ha 0, 1 eller 2
kjønnsspesifikke ratinger. `tee_rating` sin unikhet endres fra
`(tee_id, scope)` til `(tee_id, scope, gender)`. `gender``tee_rating`
er `NOT NULL CHECK IN ('m','f')` -- 'x' (gyldig på PLAYER-nivå) gir ikke
mening for en WHS-rating, som alltid er for ett bestemt kjønn.
**Beslutning B -- tee-valget blir helt automatisk, ingen manuell
kjønnsvelger (bekreftet med bruker, valgte det anbefalte alternativet).**
Organisator/kaptein velger KUN fysisk utslag i blind draw
(`session-blind-draw.tsx`, ingen "H"/"D"-suffiks lenger). Riktig
kjønnsspesifikk rating løses AUTOMATISK server-side fra spillerens
registrerte `player.gender` -- `app/handicap.py` sin
`compute_and_store_side_handicaps` joiner nå `tee_rating`
`(tee_id, scope='full_18', gender = player.gender)` i tillegg til den
tidligere ADR-028-fiksen (alltid full_18, uavhengig av hole_config).
**Beslutning C -- manglende rating/kjønn feiler tydelig FØR innsetting,
ingen stille fallback (bekreftet med bruker, valgte det anbefalte
alternativet).** `matches.py` sin `add_participant` validerer, når
`config.use_handicap` er sann: (1) spilleren har et registrert kjønn --
avvist med `VALIDATION_FAILED` hvis ikke ("sett kjønn på spilleren
først"), (2) valgt utslag har faktisk en `tee_rating` for NØYAKTIG det
kjønnet -- avvist med `VALIDATION_FAILED` hvis ikke ("dette utslaget har
ingen dame/herre-rating -- velg et annet utslag"). Samme "fail loudly"-
prinsipp som ADR-019 sin importvalidering og den eksisterende
handicap_index-sjekken (2026-07-18) rett ved siden av. `_remap_course`
(bane-bytte midt i en økt) fikk samme presisering: matcher fortsatt på
tee-navn, men sjekker nå at den NYE tee-en har en rating for hver berørte
spillers kjønn, ikke bare at navnet finnes.
**Konsekvens for import/opprettelse:** ADR-019 sin teeoff-import
(`import_official_course`) lager nå ÉN `tee`-rad per fysisk teeoff-utslag
(tidligere: to, én per kjønn), med inntil to `tee_rating`-rader under.
Manuell tee-opprettelse (`POST .../courses/{id}/tees`) redesignet
tilsvarende: `TeeCreate.ratings` er nå en liste (1-2 elementer, distinkte
kjønn) i stedet for ett flatt kjønn+rating-sett -- ingen reell
bruker-/produksjonsdata brukte dette endepunktet fra før (kun Tjøme, som
er offisielt importert), så ingen bakoverkompatibilitet var nødvendig.
**Migrasjon `014_tee_gender_to_rating.sql`:** flytter `gender` til
`tee_rating`, slår deretter sammen eksisterende kjønns-par-tee-rader til
én fysisk tee-rad per (bane, navn) -- velger laveste id som "beholder",
flytter alle `tee_rating`- og `match_participant.tee_id`-referanser dit,
sletter duplikatene. Bekreftet TRYGT å kjøre mot ekte data FØR skriving
(read-only sjekk): alle 8 eksisterende tee-rader (Tjøme) er rene m/f-par,
ingen `gender='x'`-rader, ingen gruppe med mer enn to rader.
**Scratch-verifisert grundig, flere runder:** (1) selve
fletting-migrasjonen kjørt mot syntetisk data som gjenskaper Tjøme-
mønsteret nøyaktig, inkl. én `match_participant`-rad som pekte til
DUPLIKATEN (ikke beholderen) -- bekreftet korrekt reparert til beholderens
id etterpå, og et utslag med KUN én rating (ingen duplikat) forblir
urørt. (2) Manuell tee-opprettelse: to ratinger på ett utslag, kun én
rating (simulerer "klubben har ikke slopet dette kjønnet"), duplikat
kjønn i samme innsending avvist. (3) `GET tees` viser riktig sammenslått
struktur (2 fysiske utslag, ikke 3 rader). (4) `add_participant`: kvinne
+ utslag med dame-rating lykkes; mann + utslag som KUN har dame-rating
avvist tydelig; spiller uten registrert kjønn avvist tydelig; en full
kjønnsblandet singel-match scoret korrekt end-to-end. (5) Offisiell
import kjørt mot EKTE `teeoff_api` (Borregaard Golfklubb) -- bekreftet
4 fysiske utslag importert med begge kjønnsratinger hver, ikke 8 doble
rader. (6) `_remap_course`: bane-bytte til en bane UTEN matchende
kjønnsrating avvist tydelig, bane-bytte til en bane MED matchende
rating lykket.
**Status: ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-19, IKKE ENNÅ RULLET UT**
mot ekte `teecup_db` -- migrasjon 014 endrer ekte produksjonsdata
(Tjøme-utslagene slås faktisk sammen), venter derfor på eksplisitt
brukerbekreftelse før den kjøres mot den ekte databasen, i tråd med
CLAUDE.md sin regel om å alltid vise planen og vente på bekreftelse før
noe som migrerer ekte data kjøres.
---
## Å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:

159
CLAUDE.md
View file

@ -1747,6 +1747,150 @@ Ferdig og verifisert:
Verifisert: `/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ Verifisert: `/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/
`icons/icon-512.png` alle 200 over ekte https, `teeoff.no` upåvirket. `icons/icon-512.png` alle 200 over ekte https, `teeoff.no` upåvirket.
- **Fire nye hull rapportert fra faktisk testing av blind draw + scorekort
(foursome), DIAGNOSTISERT OG NOTERT, IKKE fikset (2026-07-19):** rett
etter PWA-runden. Full detalj i FEATURE_BACKLOG.md sin nye seksjon
"Rapporterte hull, blind draw + scorekort (2026-07-19)". Kort:
1. **Bekreftet root cause:** valgt spiller forsvinner ikke fra
nedtrekkslisten i `AddSlotForm` (`session-blind-draw.tsx`) — den
merkes kun `disabled``<option>`-nivå, som HTML fortsatt viser
(bare gråtonet). Skal FILTRERES bort, ikke deaktiveres.
2. **Bekreftet, større enn antatt:** tee-valg (Dame/Herre) bør følges av
spillerens registrerte kjønn automatisk. Krever en backend-utvidelse
først — `RosterEntry`/`list_roster` (`tournaments.py`) mangler
`player.gender` helt i responsen, selv om feltet finnes i skjemaet og
alt eksponeres via `GET /orgs/{id}/players`. Åpne spørsmål om låst vs.
forhåndsutfylt valg, og fallback ved ukjent kjønn/manglende
matchende tee, før bygging.
3. Ren frontend-UX: tallvelger (19 + utvidbar "10 eller flere") i stedet
for pluss/minus-steppere for slagregistrering. Ingen backend-endring.
4. **HCP "ikke hensyntatt" i foursome-test — IKKE bekreftet som bug.** Ett
definitivt, bekreftet hull uavhengig av alt annet: det beregnede
`course_handicap`/`playing_handicap` eksponeres ALDRI noe sted i
API-et eller UI-et (sjekket `matches.py`, `scoring.py`, begge
frontend-skjermene) — usynlig selv når beregningen er korrekt. I
TILLEGG en kjent, tidligere dokumentert begrensning som kan ha slått
inn: foursome/greensome/scramble sin side-handicap beregnes kun når
BEGGE deltakere har en matchende `tee_rating`, og `tee_rating`-rader
lages i dag ALLTID kun med `full_18`-omfang — en `front_9`/`back_9`-
testøkt ville derfor ALDRI fått handicap beregnet i det hele tatt.
**Trenger avklaring:** var testøktens `hole_config` `full_18`, og
hadde begge sider alle sine deltakere+tee lagt til før scoring?
**Ingen av de fire fikset i denne runden** — kun dokumentert på brukerens
eksplisitte instruks.
- **Tre av fire hull FIKSET OG SCRATCH-VERIFISERT (2026-07-19), samme dag:**
bruker ba eksplisitt om punkt 1 og 3, og presiserte punkt 2 (se under)
samt bekreftet den nøyaktige handicap-formelen for punkt 4.
1. **Spillerliste-fiks:** `AddSlotForm` (`session-blind-draw.tsx`)
filtrerer nå allerede-valgte roster-rader helt bort i stedet for å
bare `disabled`-merke `<option>`-en (som HTML uansett viser gråtonet).
2. **Tee-valg — OMDEFINERT etter brukerens presisering, IKKE bygget
ennå:** min opprinnelige antakelse ("lås tee til spillerens kjønn")
var feil i premisset — en golfbane har ikke fysisk kjønnsdelte
utslag, kun eventuelt kjønnsdelt RATING av samme utslag (noen klubber
sloper bevisst ikke ett utslag for ett kjønn). Bekreftet mot ekte
Tjøme-data: hvert fysisk utslag ligger i dag som TO `tee`-rader med
samme navn (én per kjønn) — nøyaktig konflateringen brukeren pekte
på. Riktig fiks er å flytte `gender` fra `tee` til `tee_rating`
(skjemaendring + datamigrering + import-/handicap-/remap-kode).
Betydelig større enn antatt — se FEATURE_BACKLOG.md for full
analyse og de tre åpne designspørsmålene som trengs FØR bygging.
3. **Tallvelger:** ny `StrokePicker`-komponent
(`session-scorecard.tsx`) — 1-9 direkte, "10+"-knapp åpner 10-19,
med en "tilbake"-lenke. Erstatter ±-stepperen og all dens døde kode
helt.
4. **HCP-bug, BEKREFTET og FIKSET:** brukeren beskrev selv riktig
formel (kombinert hcp/2 justert for prosent, laveste side til 0
mottatte slag ved matchplay-hcp, resten fordelt fra stroke index 1) —
lest direkte mot `handicap_engine.py` og bekreftet at koden allerede
implementerer NØYAKTIG dette. Bugen lå ikke i formelen, men i at den
ALDRI kjørte for front_9/back_9-økter: `compute_and_store_side_
handicaps` (`app/handicap.py`) joinet `tee_rating` på øktens
`hole_config` som rating-scope, men slike rader lages i praksis kun
med `scope='full_18'` (matcher ADR-008 sin allerede etablerte
design — full_18-ratingen skal alltid brukes, front/back-9-
fordelingen skjer senere ved selve slagtildelingen). Bekreftet
direkte mot EKTE `teecup_db` (read-only): brukerens rapporterte
testøkt (`front_9`+`foursome`) hadde `course_handicap`/
`playing_handicap = NULL` på alle fire deltakere. Påvirket ALLE
formater på front_9/back_9-økter, ikke bare foursome. Fikset: scope
hardkodet til `full_18`, `hole_config`-parameteren fjernet helt fra
funksjonen og alle tre kallstedene (var død etter fiksen).
**Scratch-verifisert presist:** samme scenario gjenskapt (foursome+
front_9, hcp 10/20 mot 5/15) — course/playing handicap kom ut nøyaktig
som beregnet for hånd (10/21 vs. 5/16, kombinert 16/10), og et hull
med IDENTISK bruttoscore (5-5) på begge sider ga et IKKE-delt
resultat ("a" vant) — direkte bevis på at hcp nå faktisk brukes.
**Ny, urelatert bug funnet under samme scratch-test, IKKE fikset:**
stroke-modus-innsending på en bane uten registrerte hull krasjer rått
(500 `IndexError` i `allocate_over_played_holes`) i stedet for en ren
`VALIDATION_FAILED` — samme klasse feil som en tidligere fikset
manglende-handicap-krasj. Notert i FEATURE_BACKLOG.md, ikke bygget.
**Scratch-infrastruktur:** isolert `teecup_scratch`-database +
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
container (`python:3.12-slim`, `app/` og `handicap_engine.py` montert
read-only), alt ryddet opp etter verifisering. Typesjekket
produksjonsbuild kjørt for frontend-fiksene (1+3), alle 16 ruter listet.
**Ikke rullet ut ennå** — venter på utrullingsbekreftelse. Punkt 2
(tee/kjønn-modellen) venter på designavklaring med bruker før noe bygges.
- **Punkt 2 (tee/kjønn) BYGGET OG SCRATCH-VERIFISERT (2026-07-19, ADR-029),
samme dag, rett etter designavklaringen:** bruker bekreftet begge
anbefalte alternativer (helautomatisk tee-valg, "feil høyt" ved
manglende kjønn/rating). Ny migrasjon `014_tee_gender_to_rating.sql`:
flytter `gender` fra `tee` til `tee_rating` (unikhet
`(tee_id, scope)``(tee_id, scope, gender)`), slår sammen
eksisterende kjønns-par-tee-rader til én fysisk tee-rad per (bane,
navn) — velger laveste id som "beholder", flytter `tee_rating`- og
`match_participant.tee_id`-referanser dit, sletter duplikatene.
**Reell bug funnet OG fikset UNDER selve migrasjonsskrivingen** (ikke i
produksjon): første versjon prøvde å droppe den GAMLE
`(tee_id, scope)`-unikheten ETTER sammenslåingen i stedet for FØR —
kolliderte da midlertidig med beholder-tee-ens egen eksisterende rad
for samme scope. Rettet ved å bytte rekkefølge (drop gammel unikhet FØR
sammenslåing, legg til ny kjønnsbevisst unikhet ETTER).
**Kodeendringer:** `app/handicap.py` sin `compute_and_store_side_
handicaps` joiner nå også på `player.gender` (i tillegg til forrige
rundes full_18-fiks). `app/routers/matches.py` sin `add_participant`
validerer FØR innsetting: spiller har registrert kjønn, OG valgt utslag
har en matchende rating — begge avvist med klar `VALIDATION_FAILED`.
`app/routers/tournaments.py` sin `_remap_course` (bane-bytte) matcher nå
på tee-navn OG bekrefter matchende kjønnsrating for hver berørte
spiller. `app/routers/courses.py`: `TeeCreate` redesignet fra ett flatt
kjønn+rating-sett til en `ratings`-liste (1-2 elementer, distinkte
kjønn); ADR-019 sin `import_official_course` lager nå ÉN tee-rad per
fysisk teeoff-utslag (før: to, én per kjønn) med inntil to
`tee_rating`-rader under. `session-blind-draw.tsx` forenklet — ingen
"H"/"D"-suffiks, ingen kjønnslogikk i det hele tatt lenger (serveren
løser det).
**Verifisert grundig, flere separate scratch-runder:**
1. Selve fletting-migrasjonen kjørt mot SYNTETISK data som gjenskaper
Tjøme-mønsteret nøyaktig (to par + én enslig utslag, pluss en
`match_participant`-rad som bevisst pekte til DUPLIKATEN, ikke
beholderen) — bekreftet: to rader ble til én, `tee_rating.gender`
riktig fylt inn for begge, `match_participant.tee_id` korrekt
reparert til beholderens id, det enslige utslaget urørt.
2. Full API-runde (18 automatiserte sjekker via et Python/urllib-
testskript — httpx var ikke tilgjengelig i vertsmiljøet, løst med
stdlib `http.cookiejar`/`urllib` i stedet): manuell tee-opprettelse
(to ratinger, kun én rating, duplikat kjønn avvist), `GET tees`
viser riktig sammenslått struktur, kvinne+dame-rating lykkes,
mann+kun-dame-rating avvist tydelig, spiller uten kjønn avvist
tydelig, full kjønnsblandet singel-match scoret korrekt.
3. Offisiell import kjørt mot EKTE `teeoff_api` (Borregaard Golfklubb,
samme mønster som ADR-019 sin opprinnelige verifisering) — bekreftet
4 fysiske utslag importert med begge kjønnsratinger hver, ikke 8
doble rader.
4. `_remap_course`: bane-bytte til en bane UTEN matchende kjønnsrating
avvist tydelig, bane-bytte til en bane MED matchende rating lykket.
Ekte typesjekket produksjonsbuild av frontend kjørt på nytt og
bekreftet etter blind draw-forenklingen.
**Ikke rullet ut ennå** — migrasjon 014 endrer EKTE produksjonsdata
(slår sammen Tjømes 8 tee-rader til 4), venter derfor på eksplisitt
brukerbekreftelse før den kjøres mot ekte `teecup_db`, sammen med de
tre andre fiksene (1/3/4) i samme utrulling.
Neste steg: Neste steg:
1. **Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens 1. **Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
forespørsel:** offline-scoreregistrering er ALDRI browser-testet i forespørsel:** offline-scoreregistrering er ALDRI browser-testet i
@ -1757,16 +1901,23 @@ Neste steg:
2. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå. 2. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå.
Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er
helt utilgjengelig i dag. helt utilgjengelig i dag.
3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for 3. **Alle fire nye hull (spillerliste, tee/kjønn-modell ADR-029,
tallvelger, hcp-scope-bug) er FIKSET OG SCRATCH-VERIFISERT** (se over)
— venter på utrullingsbekreftelse. Punkt 2 KREVER en ekte migrasjon
(`014_tee_gender_to_rating.sql`, endrer Tjømes tee-data) i tillegg til
kode-redeploy av begge containere. En liten, urelatert 500-krasj
(stroke-innsending på bane uten hull) ble også funnet under
verifiseringen, ikke fikset — egen, liten sak.
4. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
(bevisst utsatt i ADR-025). (bevisst utsatt i ADR-025).
4. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/ 5. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/
Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå. Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå.
5. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute, 6. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute,
ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring, `teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`. lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`.
6. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt), 7. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
FORVENT en full re-eksport hver gang — diff mot live-treet i et FORVENT en full re-eksport hver gang — diff mot live-treet i et
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR

View file

@ -806,6 +806,101 @@ manuelt tildelt makt»-designet).
--- ---
## Rapporterte hull, blind draw + scorekort (2026-07-19)
Fire punkter rapportert av brukeren fra faktisk testing av
`/tournaments/.../sessions/...` (blind draw-skjermen) og scorekort-skjermen,
foursome-format. Root cause funnet ved kodegjennomgang for alle fire (det
fjerde, hcp, ble bekreftet mot EKTE produksjonsdata — ikke gjettet). Alle
fire er nå ✅ FIKSET OG SCRATCH-VERIFISERT samme dag — punkt 2 ble omdefinert
etter en presisering fra brukeren (se under), fikk en egen ADR (ADR-029) og
en reell skjemamigrasjon (`014_tee_gender_to_rating.sql`).
1. **✅ FIKSET 2026-07-19: valgt spiller forsvant ikke fra listen over
velgbare spillere.** Root cause: `AddSlotForm` i
`components/session-blind-draw.tsx` merket allerede brukte roster-rader
som `disabled` på selve `<option>`-elementet — et `disabled`
HTML-`<option>` blir stående synlig (kun gråtonet), forsvinner ikke.
Fikset: `roster`-listen FILTRERES nå ned til kun ledige spillere før
den rendres, i stedet for å deaktivere valget.
2. **✅ FIKSET OG SCRATCH-VERIFISERT 2026-07-19 (ADR-029), IKKE ENNÅ RULLET
UT mot ekte data.** Brukeren presiserte at min opprinnelige forståelse
var feil: en golfbane har IKKE fysisk kjønnsdelte utslag — begge kjønn
kan som regel spille fra ethvert utslag. Det eneste som faktisk
varierer per kjønn er om klubben har VALGT å slope (rate) et gitt
utslag for det respektive kjønnet (typisk: noen klubber sloper bevisst
ikke det lengste utslaget for damer). Bekreftet mot ekte produksjonsdata
(Tjøme Golfklubb, importert via ADR-019): hvert fysisk utslag ("32",
"44", "50", "55") lå som TO separate `tee`-rader med SAMME navn, én per
kjønn — selve konflateringen brukeren påpekte. **Fikset:** `gender`
flyttet fra `tee` til `tee_rating` (migrasjon
`014_tee_gender_to_rating.sql`, se ADR-029 for full detalj), tee-valg i
blind draw er nå HELT automatisk (organisator velger kun fysisk
utslag, riktig kjønnsrating løses fra spillerens `player.gender`
server-side — bruker bekreftet dette fremfor et forhåndsutfylt-men-
overstyrbart alternativ), manglende kjønn/rating avvises tydelig FØR
innsetting (bruker bekreftet "feil høyt" fremfor stille fallback).
ADR-019s teeoff-import, manuell tee-opprettelse og bane-bytte-remap
(`_remap_course`) alle oppdatert til samme modell.
3. **✅ FIKSET 2026-07-19: tallvelger (19 + utvidbar "10 eller flere" →
1019) i stedet for pluss/minus-steppere.** Ny `StrokePicker`-komponent
i `components/session-scorecard.tsx`, erstatter den gamle ±-stepperen
helt (`stepStroke`/`Minus`/`Plus` fjernet som død kode). Ren
frontend-endring.
4. **✅ FIKSET 2026-07-19: HCP ble faktisk ALDRI beregnet for
front_9/back_9-økter — bekreftet ekte kodebug, ikke bare en
synlighetsmangel.** Diagnostisert presist ved å lese EKTE
produksjonsdata (read-only, `teecup_db`) for brukerens rapporterte
testøkt: `hole_config='front_9'`, `format='foursome'`, og ALLE fire
`match_participant`-radene hadde `course_handicap`/`playing_handicap =
NULL`. Root cause: `app/handicap.py` sin
`compute_and_store_side_handicaps` joinet `tee_rating` på ØKTENS
`hole_config` som ratingens `scope` — men `tee_rating`-rader lages i
praksis KUN med `scope='full_18'` (ADR-019 Beslutning D + tee-
endepunktet), og ADR-008 sin allerede etablerte design tilsier nettopp
dette: course handicap skal ALLTID regnes fra full_18-ratingen, uansett
øktens hole_config — front/back-9-fordelingen skjer SENERE, ved selve
slagtildelingen (`allocate_over_played_holes`), ikke ved rating-
oppslaget. Joinen matchet dermed aldri noen rad for en front_9/back_9-
økt, og handicap ble stille aldri beregnet — påvirket ALLE formater på
front_9/back_9-økter, ikke bare foursome. **Fikset:** scope hardkodet
til `'full_18'`, `hole_config`-parameteren fjernet helt fra
`compute_and_store_side_handicaps`/`_recompute_session_matches` (var
død etter fiksen) og de tre kallstedene i `matches.py`/`tournaments.py`.
**Bekreftet at selve UTREGNINGEN matcher brukerens egen beskrivelse
nøyaktig** (kombinert course handicap / 2 via `CombinedPercentage(0.5)`,
laveste side satt til 0 mottatte slag via `match_play_strokes`, resten
fordelt fra stroke index 1 via `allocate_over_played_holes`) — bugen lå
i at beregningen ALDRI kjørte for front_9/back_9, ikke i selve formelen.
**Scratch-verifisert presist:** gjenskapte nøyaktig samme scenario
(foursome + front_9, hcp 10/20 vs. 5/15) — course/playing handicap
beregnet korrekt (10/21 vs. 5/16, kombinert 16/10), og et hull med
IDENTISK bruttoscore (5-5) på begge sider ga et ikke-delt resultat
("a" vant, ikke "halved") — direkte bevis på at handicap nå faktisk
brukes. Ingen migrasjon (ren Python-logikk-fiks).
**Ekte, urelatert bug funnet UNDER samme scratch-test, IKKE fikset
ennå:** et stroke-modus scoreinnsending på en bane UTEN registrerte
hull (`hole`-tabellen tom) krasjer med en rå 500
(`IndexError: list index out of range` i `handicap_engine.py` sin
`allocate_over_played_holes`, kalt fra `scoring.py` sin
`_compute_hole_results`) i stedet for en tydelig `VALIDATION_FAILED`.
Samme klasse feil som den allerede kjente/fikset
manglende-handicap-indeks-krasjen fra blind draw-runden (2026-07-18) —
bør fikses likt (eksplisitt sjekk FØR beregning, ikke en try/except
rundt symptomet). Ikke fikset i denne runden, kun oppdaget og notert.
**Designspørsmålene for punkt 2 er avklart** (bruker valgte det anbefalte
alternativet på begge, se ADR-029 Beslutning B/C): helautomatisk tee-valg
(ingen manuell kjønnsvelger), og "feil høyt" ved manglende kjønn/rating
(ingen stille fallback). Migrasjon 014 er skrevet og scratch-verifisert,
men IKKE kjørt mot ekte `teecup_db` ennå — venter på eksplisitt
brukerbekreftelse (endrer ekte Tjøme-data: slår sammen 8 tee-rader til 4).
---
## Rapporterte UI-/UX-hull (2026-07-19) — notert, IKKE fikset ennå ## Rapporterte UI-/UX-hull (2026-07-19) — notert, IKKE fikset ennå
Fire punkter rapportert av brukeren fra faktisk bruk av `teecup.teeoff.no`. Fire punkter rapportert av brukeren fra faktisk bruk av `teecup.teeoff.no`.

BIN
SCGA Club Digest.pdf Normal file

Binary file not shown.

View file

@ -82,7 +82,6 @@ async def compute_and_store_side_handicaps(
match_id: str, match_id: str,
team_side: str, team_side: str,
format_: str, format_: str,
hole_config: str,
config: AllowanceConfig, config: AllowanceConfig,
) -> None: ) -> None:
"""Beregn og lagre course_handicap/playing_handicap for én side i en match. """Beregn og lagre course_handicap/playing_handicap for én side i en match.
@ -95,6 +94,26 @@ async def compute_and_store_side_handicaps(
nøyaktig FORMAT_UNIT_SIZE[format] deltakere OG alle har en matchende nøyaktig FORMAT_UNIT_SIZE[format] deltakere OG alle har en matchende
tee_rating -- ellers returnerer den uten å skrive noe (se tee_rating -- ellers returnerer den uten å skrive noe (se
modul-docstring). modul-docstring).
Bruker ALLTID tee-ens full_18-rating (ADR-008), uavhengig av øktens
hole_config -- front/back-9-fordelingen skjer senere, ved selve
slagtildelingen (`allocate_over_played_holes` i scoring.py), ikke her.
FIKSET 2026-07-19: joinet tidligere mot øktens hole_config som
rating-scope direkte, som gjorde at en front_9/back_9-økt ALDRI fikk
beregnet handicap i praksis -- ingen front_9/back_9-scopet tee_rating
har noensinne blitt opprettet (kun full_18, se ADR-019 Beslutning D og
tee-endepunktet), joinen fant alltid null rader. Bekreftet mot ekte
produksjonsdata (en front_9 foursome-økt med NULL course_handicap/
playing_handicap alle fire deltakere).
ADR-029 (2026-07-19): ratingen er også kjønnsspesifikk (`tee_rating.
gender`) -- ett fysisk utslag (`tee`) kan ha én rating per kjønn, aldri
én felles. Joines derfor også mot spillerens `player.gender`. Krever at
`matches.py` sin `add_participant` har validert FØR innsetting at
spilleren har et registrert kjønn OG at valgt utslag faktisk har en
matchende rating -- denne funksjonen forventer at det alltid stemmer,
den validerer ikke selv (samme mønster som for manglende
handicap_index_snapshot).
""" """
participants = await conn.fetch( participants = await conn.fetch(
""" """
@ -105,12 +124,14 @@ async def compute_and_store_side_handicaps(
tee_rating.par AS par tee_rating.par AS par
FROM match_participant mp FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN tee_rating ON tee_rating.tee_id = mp.tee_id AND tee_rating.scope = $3::rating_scope JOIN player p ON p.id = tr.player_id
JOIN tee_rating ON tee_rating.tee_id = mp.tee_id
AND tee_rating.scope = 'full_18'
AND tee_rating.gender = p.gender
WHERE mp.match_id = $1 AND mp.team_side = $2::team_side WHERE mp.match_id = $1 AND mp.team_side = $2::team_side
""", """,
match_id, match_id,
team_side, team_side,
hole_config,
) )
if format_ in _SIDE_IS_UNIT: if format_ in _SIDE_IS_UNIT:

View file

@ -75,27 +75,33 @@ async def create_course(
# omfang denne runden -- egen, senere sak når scorekort-skjermen bygges. # omfang denne runden -- egen, senere sak når scorekort-skjermen bygges.
class TeeCreate(BaseModel): class TeeRatingInput(BaseModel):
name: str = Field(min_length=1, max_length=50) gender: str = Field(pattern="^[mf]$")
gender: str | None = Field(default=None, pattern="^[mfx]$")
course_rating: float = Field(gt=0) course_rating: float = Field(gt=0)
slope_rating: int = Field(ge=55, le=155) slope_rating: int = Field(ge=55, le=155)
par: int = Field(ge=27, le=90) par: int = Field(ge=27, le=90)
class Tee(BaseModel): class TeeCreate(BaseModel):
id: str name: str = Field(min_length=1, max_length=50)
name: str # ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- ett fysisk
gender: str | None # utslag kan ha én rating per kjønn (aldri to av samme), organisator
# oppgir hvilke som faktisk finnes for banen (ofte begge, noen ganger
# bare én -- klubben har bevisst ikke slopet den andre).
ratings: list[TeeRatingInput] = Field(min_length=1, max_length=2)
class TeeRatingOut(BaseModel):
gender: str
course_rating: float course_rating: float
slope_rating: int slope_rating: int
par: int par: int
_TEE_COLUMNS = """ class Tee(BaseModel):
t.id::text, t.name, t.gender, tr.course_rating::float AS course_rating, id: str
tr.slope_rating, tr.par name: str
""" ratings: list[TeeRatingOut]
@router.get("/orgs/{organization_id}/courses/{course_id}/tees", response_model=list[Tee]) @router.get("/orgs/{organization_id}/courses/{course_id}/tees", response_model=list[Tee])
@ -105,16 +111,29 @@ async def list_tees(
) -> list[Tee]: ) -> list[Tee]:
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
rows = await conn.fetch( rows = await conn.fetch(
f""" """
SELECT {_TEE_COLUMNS} SELECT t.id::text AS id, t.name,
tr.gender, tr.course_rating::float AS course_rating,
tr.slope_rating, tr.par
FROM tee t FROM tee t
JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18' JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
WHERE t.course_id = $1 WHERE t.course_id = $1
ORDER BY t.name ORDER BY t.name, tr.gender
""", """,
course_id, course_id,
) )
return [Tee(**dict(r)) for r in rows] tees: dict[str, Tee] = {}
for r in rows:
tee = tees.setdefault(r["id"], Tee(id=r["id"], name=r["name"], ratings=[]))
tee.ratings.append(
TeeRatingOut(
gender=r["gender"],
course_rating=r["course_rating"],
slope_rating=r["slope_rating"],
par=r["par"],
)
)
return list(tees.values())
@router.post("/orgs/{organization_id}/courses/{course_id}/tees", response_model=Tee, status_code=201) @router.post("/orgs/{organization_id}/courses/{course_id}/tees", response_model=Tee, status_code=201)
@ -123,6 +142,12 @@ async def create_tee(
body: TeeCreate, body: TeeCreate,
organization_id: str = Depends(get_authorized_org), organization_id: str = Depends(get_authorized_org),
) -> Tee: ) -> Tee:
genders = [r.gender for r in body.ratings]
if len(genders) != len(set(genders)):
raise app_error(
400, "VALIDATION_FAILED", "Kan ikke oppgi to ratinger for samme kjønn på ett utslag."
)
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id) source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id)
if source is None: if source is None:
@ -135,36 +160,32 @@ async def create_tee(
) )
tee_row = await conn.fetchrow( tee_row = await conn.fetchrow(
""" "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id::text",
INSERT INTO tee (organization_id, course_id, name, gender)
VALUES ($1, $2, $3, $4)
RETURNING id::text
""",
organization_id, organization_id,
course_id, course_id,
body.name, body.name,
body.gender,
) )
rating_row = await conn.fetchrow( ratings_out: list[TeeRatingOut] = []
""" for r in body.ratings:
INSERT INTO tee_rating (organization_id, tee_id, scope, course_rating, slope_rating, par) await conn.execute(
VALUES ($1, $2, 'full_18', $3, $4, $5) """
RETURNING course_rating::float AS course_rating, slope_rating, par INSERT INTO tee_rating
""", (organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
organization_id, VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
tee_row["id"], """,
body.course_rating, organization_id,
body.slope_rating, tee_row["id"],
body.par, r.gender,
) r.course_rating,
return Tee( r.slope_rating,
id=tee_row["id"], r.par,
name=body.name, )
gender=body.gender, ratings_out.append(
course_rating=rating_row["course_rating"], TeeRatingOut(
slope_rating=rating_row["slope_rating"], gender=r.gender, course_rating=r.course_rating, slope_rating=r.slope_rating, par=r.par
par=rating_row["par"], )
) )
return Tee(id=tee_row["id"], name=body.name, ratings=ratings_out)
# --- Hull (ADR-008-relatert: stroke_index kreves for slagfordeling i -- # --- Hull (ADR-008-relatert: stroke_index kreves for slagfordeling i --
@ -373,15 +394,21 @@ async def import_official_course(
"Banen mangler par eller HCP-index på ett eller flere hull i teeoff.", "Banen mangler par eller HCP-index på ett eller flere hull i teeoff.",
) )
# (navn, kjønn, course_rating, slope_rating) -- ADR-019 Beslutning D: kun # ADR-019 Beslutning D: kun full_18-rating importeres, teeoff har ingen
# full_18-rating importeres, teeoff har ingen egen front9/back9-rating. # egen front9/back9-rating. ADR-029: kjønn hører til RATINGEN, ikke selve
tee_inputs: list[tuple[str, str, float, int]] = [] # utslaget -- ett teeoff-utslag blir HER importert som ÉN fysisk
# `tee`-rad med inntil to kjønnsspesifikke ratinger (tidligere ble dette
# feilaktig importert som to separate tee-rader, én per kjønn).
tee_inputs: list[tuple[str, list[tuple[str, float, int]]]] = []
for t in tees: for t in tees:
name = t.get("name") or "Tee" name = t.get("name") or "Tee"
ratings: list[tuple[str, float, int]] = []
if t.get("cr_men") is not None and t.get("slope_men") is not None: if t.get("cr_men") is not None and t.get("slope_men") is not None:
tee_inputs.append((name, "m", float(t["cr_men"]), int(t["slope_men"]))) ratings.append(("m", float(t["cr_men"]), int(t["slope_men"])))
if t.get("cr_women") is not None and t.get("slope_women") is not None: if t.get("cr_women") is not None and t.get("slope_women") is not None:
tee_inputs.append((name, "f", float(t["cr_women"]), int(t["slope_women"]))) ratings.append(("f", float(t["cr_women"]), int(t["slope_women"])))
if ratings:
tee_inputs.append((name, ratings))
if not tee_inputs: if not tee_inputs:
raise app_error( raise app_error(
400, 400,
@ -422,29 +449,26 @@ async def import_official_course(
h["hcp_index"], h["hcp_index"],
) )
for name, gender, course_rating, slope_rating in tee_inputs: for name, ratings in tee_inputs:
tee_row = await conn.fetchrow( tee_row = await conn.fetchrow(
""" "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
INSERT INTO tee (organization_id, course_id, name, gender)
VALUES ($1, $2, $3, $4)
RETURNING id
""",
organization_id, organization_id,
course_id, course_id,
name, name,
gender,
)
await conn.execute(
"""
INSERT INTO tee_rating
(organization_id, tee_id, scope, course_rating, slope_rating, par)
VALUES ($1, $2, 'full_18', $3, $4, $5)
""",
organization_id,
tee_row["id"],
course_rating,
slope_rating,
par_total,
) )
for gender, course_rating, slope_rating in ratings:
await conn.execute(
"""
INSERT INTO tee_rating
(organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
""",
organization_id,
tee_row["id"],
gender,
course_rating,
slope_rating,
par_total,
)
return Course(**dict(course_row)) return Course(**dict(course_row))

View file

@ -273,7 +273,7 @@ async def add_participant(
session = await conn.fetchrow( session = await conn.fetchrow(
""" """
SELECT course_id::text AS course_id, format, hole_config::text AS hole_config, SELECT course_id::text AS course_id, format,
allowance_override::text AS allowance_override allowance_override::text AS allowance_override
FROM session WHERE id = $1 FROM session WHERE id = $1
""", """,
@ -307,16 +307,51 @@ async def add_participant(
) )
config = parse_allowance_config(session["format"], allowance_override) config = parse_allowance_config(session["format"], allowance_override)
if config.use_handicap: if config.use_handicap:
handicap_index_snapshot = await conn.fetchval( roster_row = await conn.fetchrow(
"SELECT handicap_index_snapshot FROM team_roster WHERE id = $1", body.team_roster_id """
SELECT tr.handicap_index_snapshot, p.gender
FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.id = $1
""",
body.team_roster_id,
) )
if handicap_index_snapshot is None: if roster_row["handicap_index_snapshot"] is None:
raise app_error( raise app_error(
400, 400,
"VALIDATION_FAILED", "VALIDATION_FAILED",
"Denne spilleren mangler handicap-indeks -- sett en verdi på spilleren " "Denne spilleren mangler handicap-indeks -- sett en verdi på spilleren "
"før hen kan legges til i en handicap-basert økt.", "før hen kan legges til i en handicap-basert økt.",
) )
# ADR-029: kjønn hører til RATINGEN (tee_rating), ikke selve
# utslaget -- et utslag kan mangle rating for ett kjønn (klubben
# har bevisst ikke slopet det for det kjønnet). Sjekket her, FØR
# innsetting, av samme "fail loudly"-grunn som sjekken over.
if roster_row["gender"] is None:
raise app_error(
400,
"VALIDATION_FAILED",
"Denne spilleren mangler registrert kjønn -- sett kjønn på spilleren "
"før hen kan legges til i en handicap-basert økt (trengs for å finne "
"riktig tee-rating).",
)
has_rating = await conn.fetchval(
"""
SELECT EXISTS(
SELECT 1 FROM tee_rating
WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2
)
""",
body.tee_id,
roster_row["gender"],
)
if not has_rating:
kjonn_tekst = "dame" if roster_row["gender"] == "f" else "herre"
raise app_error(
400,
"VALIDATION_FAILED",
f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet utslag.",
)
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
@ -346,7 +381,7 @@ async def add_participant(
# fourball: alltid; foursome/greensome/scramble: kun når siden er # fourball: alltid; foursome/greensome/scramble: kun når siden er
# komplett) -- se app/handicap.py. `config` allerede parset over. # komplett) -- se app/handicap.py. `config` allerede parset over.
await compute_and_store_side_handicaps( await compute_and_store_side_handicaps(
conn, match_id, body.team_side, session["format"], session["hole_config"], config conn, match_id, body.team_side, session["format"], config
) )
return MatchParticipantOut(**dict(row)) return MatchParticipantOut(**dict(row))

View file

@ -688,10 +688,13 @@ async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
hører til DEN GAMLE banen, og flyttes til en tilsvarende tee den hører til DEN GAMLE banen, og flyttes til en tilsvarende tee den
nye for at handicap-beregningen skal bli riktig videre. nye for at handicap-beregningen skal bli riktig videre.
Matcher tee-navn + kjønn (case-insensitive). Finnes ingen tilsvarende Matcher tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke
tee den nye banen for én eller flere spillere, avvises HELE selve utslaget (ADR-029), remap-en sjekker i tillegg at den nye
bane-byttet tydelig FØR noe skrives -- heller stoppe enn å gjette feil tee-en faktisk har en full_18-rating for hver berørte spillers kjønn.
(samme "fail loudly" prinsipp som ADR-019 sin importvalidering). Finnes ingen tilsvarende, gyldig tee den nye banen for én eller
flere spillere, avvises HELE bane-byttet tydelig FØR noe skrives --
heller stoppe enn å gjette feil (samme "fail loudly" prinsipp som
ADR-019 sin importvalidering).
""" """
course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id) course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id)
if course_exists is None: if course_exists is None:
@ -699,10 +702,12 @@ async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
participants = await conn.fetch( participants = await conn.fetch(
""" """
SELECT mp.id::text AS id, t.name AS tee_name, t.gender AS tee_gender SELECT mp.id::text AS id, t.name AS tee_name, p.gender AS player_gender
FROM match_participant mp FROM match_participant mp
JOIN match m ON m.id = mp.match_id JOIN match m ON m.id = mp.match_id
JOIN tee t ON t.id = mp.tee_id JOIN tee t ON t.id = mp.tee_id
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
WHERE m.session_id = $1 WHERE m.session_id = $1
""", """,
session_id, session_id,
@ -713,12 +718,14 @@ async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
for p in participants: for p in participants:
new_tee_id = await conn.fetchval( new_tee_id = await conn.fetchval(
""" """
SELECT id::text FROM tee SELECT t.id::text FROM tee t
WHERE course_id = $1 AND lower(name) = lower($2) AND gender IS NOT DISTINCT FROM $3 JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
AND tr.gender IS NOT DISTINCT FROM $3
WHERE t.course_id = $1 AND lower(t.name) = lower($2)
""", """,
new_course_id, new_course_id,
p["tee_name"], p["tee_name"],
p["tee_gender"], p["player_gender"],
) )
if new_tee_id is None: if new_tee_id is None:
missing.add(p["tee_name"]) missing.add(p["tee_name"])
@ -741,7 +748,7 @@ async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
async def _recompute_session_matches( async def _recompute_session_matches(
conn, session_id: str, format_: str, hole_config: str, allowance_override: dict | None conn, session_id: str, format_: str, allowance_override: dict | None
) -> None: ) -> None:
"""Regner om handicap + matchstatus for HVER match i økten -- brukt etter """Regner om handicap + matchstatus for HVER match i økten -- brukt etter
et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede
@ -752,7 +759,7 @@ async def _recompute_session_matches(
matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id) matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id)
for m in matches: for m in matches:
for side in ("a", "b"): for side in ("a", "b"):
await compute_and_store_side_handicaps(conn, m["id"], side, format_, hole_config, config) await compute_and_store_side_handicaps(conn, m["id"], side, format_, config)
await recompute_and_cache_match_state(conn, m["id"]) await recompute_and_cache_match_state(conn, m["id"])
@ -768,7 +775,7 @@ async def update_session(
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow( session = await conn.fetchrow(
"SELECT format, hole_config::text AS hole_config, course_id::text AS course_id FROM session WHERE id = $1", "SELECT format, course_id::text AS course_id FROM session WHERE id = $1",
session_id, session_id,
) )
if session is None: if session is None:
@ -804,7 +811,7 @@ async def update_session(
) )
current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None
await _recompute_session_matches( await _recompute_session_matches(
conn, session_id, session["format"], session["hole_config"], current_override conn, session_id, session["format"], current_override
) )
row = await conn.fetchrow( row = await conn.fetchrow(

View file

@ -23,13 +23,12 @@ type ApiRosterEntry = {
is_captain: boolean is_captain: boolean
} }
// ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- riktig
// kjønnsspesifikk rating løses automatisk server-side ut fra spilleren, så
// frontend trenger kun å vise/velge blant de fysiske utslagene.
type ApiTee = { type ApiTee = {
id: string id: string
name: string name: string
gender: "m" | "f" | "x" | null
course_rating: number
slope_rating: number
par: number
} }
type ApiSession = { type ApiSession = {
@ -652,13 +651,14 @@ function AddSlotForm({
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring" className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">Velg spiller</option> <option value="">Velg spiller</option>
{roster.map((p) => ( {roster
<option key={p.id} value={p.id} disabled={disabledPlayerIds.has(p.id)}> .filter((p) => !disabledPlayerIds.has(p.id))
{p.display_name} .map((p) => (
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""} <option key={p.id} value={p.id}>
{disabledPlayerIds.has(p.id) ? " — valgt" : ""} {p.display_name}
</option> {p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
))} </option>
))}
</select> </select>
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
@ -671,7 +671,7 @@ function AddSlotForm({
<option value="">Velg tee</option> <option value="">Velg tee</option>
{tees.map((t) => ( {tees.map((t) => (
<option key={t.id} value={t.id}> <option key={t.id} value={t.id}>
{teeLabel(t)} {t.name}
</option> </option>
))} ))}
</select> </select>
@ -812,12 +812,6 @@ function MetaBadge({ children }: { children: React.ReactNode }) {
) )
} }
function teeLabel(tee: ApiTee) {
const genderMap: Record<"m" | "f" | "x", string> = { m: "H", f: "D", x: "N" }
const g = tee.gender ? ` ${genderMap[tee.gender]}` : ""
return `${tee.name}${g}`
}
function formatHcp(value: number | null) { function formatHcp(value: number | null) {
if (value === null) return "" if (value === null) return ""
return value.toFixed(1).replace(".", ",") return value.toFixed(1).replace(".", ",")

View file

@ -9,8 +9,6 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronsUpDown, ChevronsUpDown,
Minus,
Plus,
RefreshCw, RefreshCw,
Trophy, Trophy,
WifiOff, WifiOff,
@ -444,13 +442,6 @@ export function SessionScorecard({
return entry ? entry.gross_strokes : null return entry ? entry.gross_strokes : null
} }
function stepStroke(unit: Unit, delta: number) {
const current = strokeValue(currentHole!, unit)
const base = current === null ? par : current
const next = Math.max(1, Math.min(20, base + delta))
void submitStroke(currentHole!, unit, next)
}
const currentResultEntry = pendingResults.has(currentHole) const currentResultEntry = pendingResults.has(currentHole)
? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null } ? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null }
: (scorecard.hole_result_entries ?? []).find((e) => e.hole_number === currentHole) : (scorecard.hole_result_entries ?? []).find((e) => e.hole_number === currentHole)
@ -721,41 +712,12 @@ export function SessionScorecard({
<span className="text-xs font-medium text-muted-foreground">(delt ball)</span> <span className="text-xs font-medium text-muted-foreground">(delt ball)</span>
)} )}
</div> </div>
<div className="flex items-center justify-between gap-3"> <StrokePicker
<button value={value}
type="button" par={par}
onClick={() => stepStroke(unit, -1)} isPending={isPending}
aria-label={`Ett slag mindre for ${unit.label}`} onSelect={(n) => void submitStroke(currentHole, unit, n)}
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-secondary text-secondary-foreground transition-colors hover:bg-accent active:scale-95" />
>
<Minus aria-hidden="true" className="size-6" />
</button>
<div className="flex flex-1 flex-col items-center">
<span
className={cn(
"text-5xl font-extrabold leading-none tabular-nums",
value === null ? "text-muted-foreground/40" : "text-foreground",
)}
>
{value === null ? "" : value}
</span>
<span className="mt-1 text-xs font-medium text-muted-foreground">
{value === null
? "Ikke registrert"
: isPending
? "Lagret lokalt · venter på synk"
: scoreToParLabel(value, par)}
</span>
</div>
<button
type="button"
onClick={() => stepStroke(unit, 1)}
aria-label={`Ett slag mer for ${unit.label}`}
className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground transition-colors hover:bg-primary/90 active:scale-95"
>
<Plus aria-hidden="true" className="size-6" />
</button>
</div>
</div> </div>
) )
})} })}
@ -850,6 +812,84 @@ function TeamTag({ name, color, align }: { name: string; color: string; align: "
) )
} }
// Tallvelger for slagregistrering: 1-9 direkte, pluss et "10 eller flere"-
// valg som åpner en ny rad med 10-19. Erstatter en tidligere pluss/minus-
// stepper -- raskere å treffe riktig tall banebruk (uten å måtte klikke seg
// opp/ned ett og ett slag).
function StrokePicker({
value,
par,
isPending,
onSelect,
}: {
value: number | null
par: number
isPending: boolean
onSelect: (n: number) => void
}) {
const [showHigh, setShowHigh] = useState(value !== null && value >= 10)
useEffect(() => {
setShowHigh(value !== null && value >= 10)
}, [value])
const numbers = showHigh
? Array.from({ length: 10 }, (_, i) => i + 10)
: Array.from({ length: 9 }, (_, i) => i + 1)
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-5 gap-2">
{numbers.map((n) => (
<button
key={n}
type="button"
onClick={() => onSelect(n)}
aria-pressed={value === n}
className={cn(
"flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95",
value === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
))}
{!showHigh && (
<button
type="button"
onClick={() => setShowHigh(true)}
className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-card text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
>
10+
</button>
)}
</div>
<div className="flex items-center justify-between gap-2">
{showHigh ? (
<button
type="button"
onClick={() => setShowHigh(false)}
className="text-xs font-semibold text-primary"
>
Tilbake til 19
</button>
) : (
<span />
)}
<span className="text-xs font-medium text-muted-foreground">
{value === null
? "Ikke registrert"
: isPending
? "Lagret lokalt · venter på synk"
: scoreToParLabel(value, par)}
</span>
</div>
</div>
)
}
function HoleSummaryTable({ function HoleSummaryTable({
teams, teams,
scorecard, scorecard,

Binary file not shown.