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:
parent
7d911f67d5
commit
52e0cfaaf9
14 changed files with 701 additions and 152 deletions
|
|
@ -320,7 +320,12 @@
|
|||
"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 -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": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
84
014_tee_gender_to_rating.sql
Normal file
84
014_tee_gender_to_rating.sql
Normal 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;
|
||||
|
|
@ -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` på `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` på
|
||||
`(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å)
|
||||
|
||||
Disse må avklares før eller under de relevante fasene:
|
||||
|
|
|
|||
159
CLAUDE.md
159
CLAUDE.md
|
|
@ -1747,6 +1747,150 @@ Ferdig og verifisert:
|
|||
Verifisert: `/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/
|
||||
`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` på `<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 (1–9 + 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:
|
||||
1. **Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
||||
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å.
|
||||
Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er
|
||||
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
|
||||
(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å.
|
||||
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
|
||||
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 (1–9 + utvidbar "10 eller flere" →
|
||||
10–19) 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å
|
||||
|
||||
Fire punkter rapportert av brukeren fra faktisk bruk av `teecup.teeoff.no`.
|
||||
|
|
|
|||
Binary file not shown.
BIN
SCGA Club Digest.pdf
Normal file
BIN
SCGA Club Digest.pdf
Normal file
Binary file not shown.
|
|
@ -82,7 +82,6 @@ async def compute_and_store_side_handicaps(
|
|||
match_id: str,
|
||||
team_side: str,
|
||||
format_: str,
|
||||
hole_config: str,
|
||||
config: AllowanceConfig,
|
||||
) -> None:
|
||||
"""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
|
||||
tee_rating -- ellers returnerer den uten å skrive noe (se
|
||||
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), så joinen fant alltid null rader. Bekreftet mot ekte
|
||||
produksjonsdata (en front_9 foursome-økt med NULL course_handicap/
|
||||
playing_handicap på alle fire deltakere).
|
||||
|
||||
ADR-029 (2026-07-19): ratingen er også kjønnsspesifikk nå (`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(
|
||||
"""
|
||||
|
|
@ -105,12 +124,14 @@ async def compute_and_store_side_handicaps(
|
|||
tee_rating.par AS par
|
||||
FROM match_participant mp
|
||||
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
|
||||
""",
|
||||
match_id,
|
||||
team_side,
|
||||
hole_config,
|
||||
)
|
||||
|
||||
if format_ in _SIDE_IS_UNIT:
|
||||
|
|
|
|||
|
|
@ -75,27 +75,33 @@ async def create_course(
|
|||
# omfang denne runden -- egen, senere sak når scorekort-skjermen bygges.
|
||||
|
||||
|
||||
class TeeCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
||||
class TeeRatingInput(BaseModel):
|
||||
gender: str = Field(pattern="^[mf]$")
|
||||
course_rating: float = Field(gt=0)
|
||||
slope_rating: int = Field(ge=55, le=155)
|
||||
par: int = Field(ge=27, le=90)
|
||||
|
||||
|
||||
class Tee(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
gender: str | None
|
||||
class TeeCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
# ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- ett fysisk
|
||||
# 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
|
||||
slope_rating: int
|
||||
par: int
|
||||
|
||||
|
||||
_TEE_COLUMNS = """
|
||||
t.id::text, t.name, t.gender, tr.course_rating::float AS course_rating,
|
||||
tr.slope_rating, tr.par
|
||||
"""
|
||||
class Tee(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
ratings: list[TeeRatingOut]
|
||||
|
||||
|
||||
@router.get("/orgs/{organization_id}/courses/{course_id}/tees", response_model=list[Tee])
|
||||
|
|
@ -105,16 +111,29 @@ async def list_tees(
|
|||
) -> list[Tee]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
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
|
||||
JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
|
||||
WHERE t.course_id = $1
|
||||
ORDER BY t.name
|
||||
ORDER BY t.name, tr.gender
|
||||
""",
|
||||
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)
|
||||
|
|
@ -123,6 +142,12 @@ async def create_tee(
|
|||
body: TeeCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> 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():
|
||||
source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id)
|
||||
if source is None:
|
||||
|
|
@ -135,36 +160,32 @@ async def create_tee(
|
|||
)
|
||||
|
||||
tee_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tee (organization_id, course_id, name, gender)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id::text
|
||||
""",
|
||||
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id::text",
|
||||
organization_id,
|
||||
course_id,
|
||||
body.name,
|
||||
body.gender,
|
||||
)
|
||||
rating_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tee_rating (organization_id, tee_id, scope, course_rating, slope_rating, par)
|
||||
VALUES ($1, $2, 'full_18', $3, $4, $5)
|
||||
RETURNING course_rating::float AS course_rating, slope_rating, par
|
||||
""",
|
||||
organization_id,
|
||||
tee_row["id"],
|
||||
body.course_rating,
|
||||
body.slope_rating,
|
||||
body.par,
|
||||
)
|
||||
return Tee(
|
||||
id=tee_row["id"],
|
||||
name=body.name,
|
||||
gender=body.gender,
|
||||
course_rating=rating_row["course_rating"],
|
||||
slope_rating=rating_row["slope_rating"],
|
||||
par=rating_row["par"],
|
||||
)
|
||||
ratings_out: list[TeeRatingOut] = []
|
||||
for r in body.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"],
|
||||
r.gender,
|
||||
r.course_rating,
|
||||
r.slope_rating,
|
||||
r.par,
|
||||
)
|
||||
ratings_out.append(
|
||||
TeeRatingOut(
|
||||
gender=r.gender, course_rating=r.course_rating, slope_rating=r.slope_rating, par=r.par
|
||||
)
|
||||
)
|
||||
return Tee(id=tee_row["id"], name=body.name, ratings=ratings_out)
|
||||
|
||||
|
||||
# --- 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.",
|
||||
)
|
||||
|
||||
# (navn, kjønn, course_rating, slope_rating) -- ADR-019 Beslutning D: kun
|
||||
# full_18-rating importeres, teeoff har ingen egen front9/back9-rating.
|
||||
tee_inputs: list[tuple[str, str, float, int]] = []
|
||||
# ADR-019 Beslutning D: kun full_18-rating importeres, teeoff har ingen
|
||||
# egen front9/back9-rating. ADR-029: kjønn hører til RATINGEN, ikke selve
|
||||
# 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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
raise app_error(
|
||||
400,
|
||||
|
|
@ -422,29 +449,26 @@ async def import_official_course(
|
|||
h["hcp_index"],
|
||||
)
|
||||
|
||||
for name, gender, course_rating, slope_rating in tee_inputs:
|
||||
for name, ratings in tee_inputs:
|
||||
tee_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tee (organization_id, course_id, name, gender)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
""",
|
||||
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
organization_id,
|
||||
course_id,
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ async def add_participant(
|
|||
|
||||
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
|
||||
FROM session WHERE id = $1
|
||||
""",
|
||||
|
|
@ -307,16 +307,51 @@ async def add_participant(
|
|||
)
|
||||
config = parse_allowance_config(session["format"], allowance_override)
|
||||
if config.use_handicap:
|
||||
handicap_index_snapshot = await conn.fetchval(
|
||||
"SELECT handicap_index_snapshot FROM team_roster WHERE id = $1", body.team_roster_id
|
||||
roster_row = await conn.fetchrow(
|
||||
"""
|
||||
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(
|
||||
400,
|
||||
"VALIDATION_FAILED",
|
||||
"Denne spilleren mangler handicap-indeks -- sett en verdi på spilleren "
|
||||
"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(
|
||||
"""
|
||||
|
|
@ -346,7 +381,7 @@ async def add_participant(
|
|||
# fourball: alltid; foursome/greensome/scramble: kun når siden er
|
||||
# komplett) -- se app/handicap.py. `config` allerede parset over.
|
||||
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))
|
||||
|
||||
|
|
|
|||
|
|
@ -688,10 +688,13 @@ async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
|
|||
hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den
|
||||
nye for at handicap-beregningen skal bli riktig videre.
|
||||
|
||||
Matcher på tee-navn + kjønn (case-insensitive). Finnes ingen tilsvarende
|
||||
tee på 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).
|
||||
Matcher på tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke
|
||||
selve utslaget (ADR-029), så remap-en sjekker i tillegg at den nye
|
||||
tee-en faktisk har en full_18-rating for hver berørte spillers kjønn.
|
||||
Finnes ingen tilsvarende, gyldig tee på 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)
|
||||
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(
|
||||
"""
|
||||
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
|
||||
JOIN match m ON m.id = mp.match_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
|
||||
""",
|
||||
session_id,
|
||||
|
|
@ -713,12 +718,14 @@ async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
|
|||
for p in participants:
|
||||
new_tee_id = await conn.fetchval(
|
||||
"""
|
||||
SELECT id::text FROM tee
|
||||
WHERE course_id = $1 AND lower(name) = lower($2) AND gender IS NOT DISTINCT FROM $3
|
||||
SELECT t.id::text FROM tee t
|
||||
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,
|
||||
p["tee_name"],
|
||||
p["tee_gender"],
|
||||
p["player_gender"],
|
||||
)
|
||||
if new_tee_id is None:
|
||||
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(
|
||||
conn, session_id: str, format_: str, hole_config: str, allowance_override: dict | None
|
||||
conn, session_id: str, format_: str, allowance_override: dict | None
|
||||
) -> None:
|
||||
"""Regner om handicap + matchstatus for HVER match i økten -- brukt etter
|
||||
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)
|
||||
for m in matches:
|
||||
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"])
|
||||
|
||||
|
||||
|
|
@ -768,7 +775,7 @@ async def update_session(
|
|||
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
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,
|
||||
)
|
||||
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
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -23,13 +23,12 @@ type ApiRosterEntry = {
|
|||
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 = {
|
||||
id: string
|
||||
name: string
|
||||
gender: "m" | "f" | "x" | null
|
||||
course_rating: number
|
||||
slope_rating: number
|
||||
par: number
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<option value="">Velg spiller</option>
|
||||
{roster.map((p) => (
|
||||
<option key={p.id} value={p.id} disabled={disabledPlayerIds.has(p.id)}>
|
||||
{p.display_name}
|
||||
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
|
||||
{disabledPlayerIds.has(p.id) ? " — valgt" : ""}
|
||||
</option>
|
||||
))}
|
||||
{roster
|
||||
.filter((p) => !disabledPlayerIds.has(p.id))
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name}
|
||||
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
|
|
@ -671,7 +671,7 @@ function AddSlotForm({
|
|||
<option value="">Velg tee</option>
|
||||
{tees.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{teeLabel(t)}
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</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) {
|
||||
if (value === null) return "–"
|
||||
return value.toFixed(1).replace(".", ",")
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Minus,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
WifiOff,
|
||||
|
|
@ -444,13 +442,6 @@ export function SessionScorecard({
|
|||
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)
|
||||
? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null }
|
||||
: (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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stepStroke(unit, -1)}
|
||||
aria-label={`Ett slag mindre for ${unit.label}`}
|
||||
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>
|
||||
<StrokePicker
|
||||
value={value}
|
||||
par={par}
|
||||
isPending={isPending}
|
||||
onSelect={(n) => void submitStroke(currentHole, unit, n)}
|
||||
/>
|
||||
</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 1–9
|
||||
</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({
|
||||
teams,
|
||||
scorecard,
|
||||
|
|
|
|||
BIN
spilletyper-og-spilleformer-2023.pdf
Normal file
BIN
spilletyper-og-spilleformer-2023.pdf
Normal file
Binary file not shown.
Loading…
Reference in a new issue