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 -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",
|
||||||
|
|
|
||||||
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å)
|
## Å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
159
CLAUDE.md
|
|
@ -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` 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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -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å
|
## 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`.
|
||||||
|
|
|
||||||
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,
|
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), 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(
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den
|
||||||
nye for at handicap-beregningen skal bli riktig videre.
|
nye for at handicap-beregningen skal bli riktig videre.
|
||||||
|
|
||||||
Matcher på tee-navn + kjønn (case-insensitive). Finnes ingen tilsvarende
|
Matcher på tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke
|
||||||
tee på den nye banen for én eller flere spillere, avvises HELE
|
selve utslaget (ADR-029), så 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 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)
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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(".", ",")
|
||||||
|
|
|
||||||
|
|
@ -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 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({
|
function HoleSummaryTable({
|
||||||
teams,
|
teams,
|
||||||
scorecard,
|
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