From 997f3c40ed559cd6f9d390afc8d2316a23e8feec Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 28 Jul 2026 11:04:51 +0200 Subject: [PATCH] Alt er ferdig, verifisert og live. Oppsummert: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hullet lukket: hele WHS-HCP-motoren fantes og var testet, men ble aldri kalt — score_differential ble lagret per runde, men ingen indeks ble noensinne regnet ut. Nå har du: To atskilte tall: manuelt satt HCP (uendret bruk) og et nytt, automatisk beregnet «faktisk HCP» (beste 8-av-≤20 nyeste tellende runder, WHS Rule 5.2, med Low-HCP-cap). «Bruk som mitt HCP»-knapp på /account for å eksplisitt overføre. Eksklusjon per deltaker — hver innlogget spiller (ikke bare eieren) styrer selv om egen deltakelse skal telle, uansett fullført-status. Selvdeklarert spilleform (slagspill/matchspill) — matchspill forhåndsforeslår (ikke tvinger) eksklusjon, begrunnet i WHS sin «most likely score»-regel som TeeCup ikke kan garantere presist. Verifisert med 111/111 scratch-sjekker (inkl. håndregnet WHS-matte) og en ekte nettleser-gjennomgang av hele flyten. Migrasjon 030 kjørt mot ekte teecup_db, begge containere redeployet, alt grønt, teeoff.no upåvirket. Gjenstår (dokumentert i CLAUDE.md/FEATURE_BACKLOG.md, ikke bygget nå): offline-kø for frittstående runder, Stableford, rundedeling/visibility, flere flighter i én runde. --- 030_actual_handicap_index.sql | 39 ++ ARCHITECTURE_DECISIONS.md | 130 +++++ CLAUDE.md | 211 ++++++++ FEATURE_BACKLOG.md | 18 +- app/routers/auth.py | 45 +- app/routers/rounds.py | 244 ++++++++- frontend/components/account-settings.tsx | 85 ++- frontend/components/new-round.tsx | 69 +++ frontend/components/round-detail.tsx | 292 +++++++---- frontend/components/rounds-stats-summary.tsx | 525 ++++++++++++++----- 10 files changed, 1411 insertions(+), 247 deletions(-) create mode 100644 030_actual_handicap_index.sql diff --git a/030_actual_handicap_index.sql b/030_actual_handicap_index.sql new file mode 100644 index 0000000..c521362 --- /dev/null +++ b/030_actual_handicap_index.sql @@ -0,0 +1,39 @@ +-- ===================================================================== +-- TeeCup — faktisk (beregnet) HCP vs. manuelt satt HCP (migrasjon 030, +-- ADR-038) +-- ===================================================================== +-- To atskilte tall (Beslutning A): `app_user.handicap_index` (uendret, +-- manuelt satt, brukes til Course Handicap-beregning som i dag) og en +-- NY `computed_handicap_index` (WHS Handicap Index, Rule 5.2, regnet +-- automatisk fra beste 8 av de ≤20 nyeste tellende Score Differential- +-- ene -- se `handicap_engine.handicap_index_from_differentials`, +-- allerede skrevet og testet, bare aldri kalt før nå). +-- +-- `round_participant.exclude_from_handicap` (Beslutning C) er en +-- EKSPLISITT, manuell opt-out -- uavhengig av `counts_for_handicap` +-- (den AUTOMATISKE WHS-kvalifiseringen, minimum spilte hull). Endelig +-- medregning krever begge: counts_for_handicap AND NOT +-- exclude_from_handicap. Kontrolleres av HVER innlogget deltaker for +-- sin egen rad (app-lag-autorisasjon, ikke noe denne migrasjonen +-- håndhever). +-- +-- `round.play_format` (Beslutning D) er selvdeklarert -- frittstående +-- runder har ingen egen match-motor til å "oppdage" dette selv. +-- +-- `handicap_history.source` (Beslutning F) skiller manuelle +-- profilendringer (eksisterende bruk) fra automatiske beregninger -- +-- samme tabell, to kilder, i stedet for en parallell historikk-tabell. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE app_user ADD COLUMN computed_handicap_index numeric(4,1); +ALTER TABLE app_user ADD COLUMN computed_handicap_index_updated_at timestamptz; + +ALTER TABLE round_participant ADD COLUMN exclude_from_handicap boolean NOT NULL DEFAULT false; + +ALTER TABLE round ADD COLUMN play_format text NOT NULL DEFAULT 'stroke' + CHECK (play_format IN ('stroke', 'match')); + +ALTER TABLE handicap_history ADD COLUMN source text NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'computed')); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 5ed3cde..a8b8128 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -3352,6 +3352,136 @@ prinsipp som ADR-005) + én ny CHECK-verdi, ikke en skjemaendring. --- +## ADR-038: Faktisk (beregnet) HCP vs. manuelt satt HCP + +**Kontekst:** reist 2026-07-28, rett etter en gjennomgang av frittstående +rundeføring (ADR-033) som avdekket et reelt, ubesluttet hull: hele +WHS-indeksmotoren (`handicap_index_from_differentials`/ +`low_handicap_index`/`apply_index_caps` i `handicap_engine.py`, testet +41/41) kalles ALDRI fra noe API-endepunkt — `round_participant. +score_differential` regnes og lagres per runde, men `app_user. +handicap_index` endres kun manuelt (`PATCH /auth/profile`). Bekreftet +eksplisitt i `rounds.py` sin egen moduldoc som "eksplisitt uavklart punkt +i ADR-033". + +Brukeren avklarte at TeeCup skal operere med TO tall, ikke ett: + +### Beslutning A — To atskilte HCP-verdier + +- **Manuelt satt HCP** (eksisterende `app_user.handicap_index`, + ADR-031) — UENDRET betydning og bruk: dette er tallet som brukes til + Course Handicap-beregning i enhver runde/deltaker med mindre + eksplisitt overstyrt per runde (allerede slik i dag via + `handicap_index_snapshot`). Redigeres fritt av brukeren selv, som i + dag. +- **Faktisk HCP** (ny `app_user.computed_handicap_index` + + `computed_handicap_index_updated_at`) — WHS Handicap Index (Rule 5.2), + regnet automatisk fra beste 8 av de ≤20 nyeste TELLENDE Score + Differential-ene, med den offisielle opptrappingstabellen for færre + enn 20 runder (`_INDEX_TABLE_UNDER_20`, allerede implementert og + testet — ingen ny motorkode trengs, kun at den faktisk kalles). Aldri + direkte redigerbar — kun avledet. + +### Beslutning B — Uttrykkelig "overfør til manuelt HCP"-handling + +Golferen får en egen, eksplisitt knapp ("Bruk som mitt HCP") i +`/account` som kopierer gjeldende `computed_handicap_index` inn i +`handicap_index` (samme skrivevei/historikk-logging som en vanlig +manuell PATCH). INGEN automatisk synkronisering noen vei — de to +tallene lever bevisst uavhengig av hverandre helt til brukeren selv +trykker knappen. + +### Beslutning C — Eksplisitt eksklusjon per deltaker, kontrollert av HVER innlogget deltaker selv + +Ny `round_participant.exclude_from_handicap boolean DEFAULT false` — +uavhengig av `counts_for_handicap` (som fortsatt er den AUTOMATISKE +WHS-kvalifiseringen, minimum spilte hull). Endelig medregning i faktisk +HCP krever BEGGE: `counts_for_handicap AND NOT exclude_from_handicap`. + +**Avklart eksplisitt med bruker (AskUserQuestion), IKKE gjettet:** hver +INNLOGGET deltaker (eier ELLER en lenket medspiller, ADR-036 fase 3) +styrer SIN EGEN rads eksklusjon — ikke bare runde-eieren. Autorisasjon i +`PATCH .../participants/{id}` utvidet presist til akkurat dette ene +feltet: en ikke-eier kan KUN sende `exclude_from_handicap` (ethvert +annet felt i samme kall avvises 403) og KUN på sin egen rad (`rp.user_id +== requester`) — alle andre felt (tee/HCP/kjønn/navn/stat_level) forblir +strengt eier-only, uendret. Feltet er bevisst IKKE en del av +`rating_changed`-sperren i `update_participant` — kan endres uansett +fullført-status, siden det ikke påvirker AGS/differensial-matematikken +i seg selv, kun om resultatet TELLER i det HELE tatt. + +### Beslutning D — Spilleform (slagspill/matchspill), selvdeklarert + +Ny `round.play_format text DEFAULT 'stroke' CHECK (IN ('stroke', +'match'))`. Frittstående runder har i dag INGEN uavhengig måte å +"oppdage" matchspill på (ingen egen match-motor, ulikt turnering-siden) +— feltet er derfor selvdeklarert av brukeren ved oppsett (og fritt +redigerbart senere, som ren metadata, uendret av fullført-status). + +**WHS-kilde lest og lagt til grunn** (`WHS_Rules_of_Handicapping_2024. +pdf`, Rule 3.3 "When a Hole is Started But Player Does Not Hole Out"): +matchspill-scorer ER teknisk et gyldig HCP-grunnlag under WHS (Rule +2.1a), MEN et hull som avgjøres/konsederes før utspilt krever en +subjektiv "most likely score" (avgrenset til netto dobbel bogey) — noe +TeeCups rene tallregistrering ikke har noen UI-vei til å representere +presist. Dette begrunner brukerens instinkt ("man bruker vanligvis ikke +matchspill til å beregne hcp") uten å hardkode et forbud: WHS tillater +det teknisk, men TeeCup kan ikke garantere et pålitelig grunnlag for +det i dag. + +**Derfor: spør, ikke tving.** Når `play_format = 'match'` velges (ved +opprettelse, ELLER ved senere tillegg av en lenket medspiller), foreslår +frontend `exclude_from_handicap = true` som FORHÅNDSVALGT verdi med en +forklarende tekst — brukeren kan uansett overstyre til `false` (f.eks. +en fullstendig utspilt vennskapelig 18-hulls "matchspill" der alle hull +faktisk ble spilt ut). Ingen server-side tvang — `play_format` og +`exclude_from_handicap` er to uavhengige felt i skjemaet; kun +frontendens forhåndsutfylling kobler dem sammen. + +### Beslutning E — Datagrunnlag: designet for BEGGE kilder, bygget for én + +**Avklart eksplisitt med bruker:** "faktisk HCP" skal på sikt kunne +telle scorer fra BÅDE frittstående runder OG organisasjons-/ +turneringsscoring, men v1 bygger KUN kilden for frittstående runder. +Løst med ett tynt, bevisst uabstrahert skjøtepunkt — IKKE en ny, +generell "scoring record"-tabell (ville vært for tidlig abstraksjon for +en kilde som ikke finnes ennå, se CLAUDE.md sin regel mot design for +hypotetiske fremtidige krav): en enkelt funksjon +`_gather_qualifying_differentials(conn, user_id)` i `rounds.py` henter +i dag KUN fra `round_participant JOIN round`, med en kommentar som +peker ut nøyaktig dette som skjøtepunktet for en fremtidig UNION mot +turnering-siden (som uansett trenger sin egen ADR-037-baserte +poeng-/differensial-motor FØRST, se ADR-037 Beslutning D). + +### Beslutning F — Low Handicap Index / cap-historikk gjenbruker `handicap_history`, ikke en ny tabell + +Rule 5.7 (Low Handicap Index, laveste indeks i 365 dager) og Rule 5.8 +(soft/hard cap) krever en historikk av TIDLIGERE beregnede indeksverdier +å sammenligne mot — samme grunnform som den eksisterende +`handicap_history`-tabellen (ADR-031-oppfølging, migrasjon 018), men +den logger i dag KUN manuelle profilendringer. Løst med én ny kolonne +`handicap_history.source text DEFAULT 'manual' CHECK (IN ('manual', +'computed'))` fremfor en parallell tabell — samme tabell, to kilder, +`low_handicap_index()` filtrerer på `source = 'computed'`. Det aller +første beregnede indekstallet for en bruker har ingen tidligere +`computed`-historikk å låses mot — capping hoppes bevisst over da +(Rule 5.8 sitt "Low HI" er per definisjon udefinert før en første indeks +er etablert), ikke behandlet som en feil. + +### Konsekvens — hva denne ADR-en IKKE dekker + +- Turnering-/organisasjonsscoring teller fortsatt IKKE mot faktisk HCP + (Beslutning E) — venter på ADR-037s videre arbeid. +- Ingen automatisk periodisk "aging" av gamle differensialer utover de + 20 nyeste (Rule 5.5) — løst implisitt ved at spørringen alltid henter + KUN de 20 nyeste, ikke en egen bakgrunnsjobb. +- Stableford-format for frittstående runder (se FEATURE_BACKLOG.md) er + fortsatt ikke bygget — påvirker ikke denne ADR-en, siden Score + Differential regnes fra Adjusted Gross Score uavhengig av + poengformat. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index 13c8a80..b6c6f9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3871,6 +3871,205 @@ Ferdig og verifisert: `/health` → 200, anonymt `GET /rounds/stats/summary?window=last_5` → 401 (bekrefter query-parameteren ruter riktig gjennom Caddy/rewrites), `teeoff.no` upåvirket. +- **Statistikk-siden: visuell v2 (kompass-diagram + donuter), HÅNDKODET og + BROWSERVERIFISERT MED EKTE ITERASJON, LIVE (2026-07-28), samme dag:** + brukeren delte et referansebilde av en konkurrent-app sin statistikk- + skjerm (donuter med sentertall, kompass for miss-retning) og ba om noe + tilsvarende. Skrev først et fullt V0-prompt (data-kontrakt, komponent- + for-komponent) — brukeren var tom for V0-credits og spurte eksplisitt om + jeg kunne "gi promptet til meg selv" og bygge det direkte, siden Chrome + DevTools MCP nå gjør det mulig å faktisk SE resultatet underveis (ulikt + tidligere håndkodede runder denne uken som kun var typesjekket). + **Backend:** tre nye felt i `RoundStatsSummary`/`_summarize_rounds` + (`app/routers/rounds.py`) — `putt_dist_one_pct`/`two_pct`/ + `three_plus_pct`, EGNE eksakte bøtter (`putts==1`/`==2`/`>=3`, samme som + `round-stats.tsx` sin `puttCategories`), bevisst forskjellig fra det + allerede eksisterende `one_putt_pct` (som bruker `<=1` — en annen, + allerede etablert rate, ikke en distribusjon). + **Frontend, `rounds-stats-summary.tsx` skrevet om betydelig:** ny + `GirCompass` — greentreff-prosenten i midten (grønn fremheving), fire + retningsceller rundt (Langt=topp, Kort=bunn, Venstre=venstre, + Høyre=høyre), samme visuelle språk som den allerede etablerte + `DirectionCross`/`DirButton` fra `round-detail.tsx` (plusstegn-rutenett, + `min-h-16 rounded-2xl border`-celler) — bevisst IKKE fargekodet + grønn/oransje på retningscellene (retning er beskrivende, ikke god/ + dårlig), kun sentercellen. Ny gjenbrukbar `Donut`-komponent + (conic-gradient, sentertall + valgfri forklaringsliste via + `showLegend`) brukt til BÅDE puttfordelingen (3 segmenter: 1-putt/ + 2-putt/3-putt+) og to enkle rednings-gauger (scrambling/sand save, ett + segment). Bevisst IKKE et flyt-/beslutningstre-diagram for scrambling/ + sand save (som referansebildet hadde) — det er to uavhengige prosenter i + datamodellen, ikke en forgrening, et flytdiagram ville antydet en + struktur som ikke finnes. Bevisst INGEN trendlinje (referansebildets + fjerde element) — for lite rundehistorikk til å vise noe meningsfullt + ennå, samme vurdering som tidligere samme dag. + **Reelt funn UNDER selve visuell iterasjon, ikke bare kodegjennomgang:** + første versjon av "Redning"-kortet viste samme tall TO GANGER per gauge + (`Donut` sin egen auto-genererte forklaringsliste "● Scrambling 9 %" RETT + OVER en egen, manuelt lagt til "Scrambling"-bildetekst) — sett direkte i + et ekte skjermbilde, ikke antatt. Fikset ved å legge til en + `showLegend`-prop på `Donut` og slå den av for de to gaugene, beholdt kun + min egen kompakte bildetekst+delta under ringen. + **Verifisert:** ekte typesjekket produksjonsbuild (to runder — én for + hovedversjonen, én for legend-fiksen), full backend-import-sjekk i + prod-imaget, OG ekte skjermbilder tatt FØR og ETTER legend-fiksen i en + innlogget nettleser-sesjon (samme mønster som sticky-kolonne-bug-fiksen + tidligere denne uken) — kompasset viser reelle tall (39 % greentreff, + 75 % kort, 13/13 % venstre/høyre, 0 % langt for `hei@erol.no` sine 2 + runder), puttfordelings-donuten viser korrekt fargede segmenter (17/61/ + 22 %), vindu-velgeren fungerer fortsatt uendret (klikket "Siste 5" etter + redesignet, ingen konsoll-feil). Ingen migrasjon. + **Rullet ut live 2026-07-28**, `docker compose up -d --build teecup_api + teecup_frontend` (kjørt to ganger). `/health` → 200 begge ganger, + `teeoff.no` upåvirket. + **Oppfølging, samme dag:** brukeren ba om at fairway-baren sin venstre/ + høyre-bom bruker SAMME farge (i stedet for to ulike chart-farger) -- + begge er tross alt bare "bom", ingen grunn til å skille dem visuelt. + Byttet begge til `--brand-orange` (appens etablerte "bom/over par"-farge), + beholdt grønn kun for selve fairwaytreffet. Verifisert med et nytt + skjermbilde. Rullet ut, ingen migrasjon. +- **"Til par: med vs. uten"-splitt, BYGGET, TESTET OG LIVE (2026-07-28), + samme dag:** brukeren ba eksplisitt om snitt-til-par splittet på om en + hull-hendelse inntraff eller ikke -- greentreff, fairwaytreff, bunker, + OG anywayslag (eksplisitt fremhevet). Fire nye par felt i + `RoundStatsSummary`/`_summarize_rounds` (`app/routers/rounds.py`): + `avg_to_par_with/without_gir`, `_fairway_hit/miss`, `_with/without_bunker`, + `_with/without_anyway` -- pooler ENKELTHULL på tvers av alle runder i + perioden (ikke per-runde-snitt, siden dette er hull-nivå-betingelser), + samme `diff()`-formel som `round-stats.tsx` sin `avgToParWithGir`/ + `avgToParFairwayHit`/`avgToParWithBunker` bruker for én runde (portert + uendret) -- anywayslag-splitten er en ny, konsekvent utvidelse av + akkurat samme mønster (fantes ikke fra før for enkeltrunder heller). + Ny `CompareToPar`-komponent i `rounds-stats-summary.tsx`: to bokser side + om side, den med FAKTISK lavest til-par denne perioden fremheves grønn + -- ingen hardkodet antakelse om hvilken side som "skal" vinne (bekreftet + reelt i data: "Med anywayslag" var faktisk verre enn "Uten anywayslag" + som forventet, men "I bunker" var marginalt BEDRE enn "Ikke i bunker" + for denne brukerens 2 runder -- fremhevingen fulgte automatisk det + virkelige tallet, ikke en antakelse). + **Verifisert:** en frittstående Python-simulering av alle fire splittene + mot et hånd-konstruert 5-hulls datasett (eksakte forventede gjennomsnitt + regnet ut for hånd og sammenlignet), full backend-import-sjekk i + prod-imaget, ekte typesjekket build, OG et ekte skjermbilde i innlogget + nettleser som viste reelle, korrekt utregnede og korrekt fargede tall + for alle fire kategoriene. Ingen migrasjon. + **Rullet ut live 2026-07-28**, `docker compose up -d --build teecup_api + teecup_frontend`, `/health` → 200, `teeoff.no` upåvirket. + +- **Gjennomgang av frittstående runder (ADR-033) + det viktigste hullet + lukket: faktisk (beregnet) HCP, BYGGET, SCRATCH-/BROWSERVERIFISERT OG + LIVE (2026-07-28, ADR-038):** brukeren ba om en vurdering av om alt var + tenkt gjennom for single-runder. Fant ved grep at hele WHS-indeksmotoren + (`handicap_index_from_differentials`/`low_handicap_index`/ + `apply_index_caps` i `handicap_engine.py`, 41/41 testet siden ADR-033) + ALDRI ble kalt fra noe API-endepunkt — `round_participant. + score_differential` ble regnet og lagret per runde, men `app_user. + handicap_index` endret seg kun manuelt. Bekreftet eksplisitt i + `rounds.py` sin egen moduldoc ("skjer IKKE automatisk her -- eksplisitt + uavklart punkt i ADR-033"). Sekundære, mindre hull notert samtidig + (offline-kø kun for turnering-scorekortet, ikke frittstående runder; + ingen Stableford; ingen rundedeling/visibility) — brukeren valgte å ta + tak i HCP-hullet. + **To load-bærende design-avklaringer** (AskUserQuestion, se ADR-038): + (1) hver INNLOGGET deltaker (ikke bare eieren) styrer sin egen + eksklusjon fra faktisk HCP; (2) faktisk HCP designes NÅ for å kunne + inkludere begge kilder (frittstående runder OG en fremtidig turnering- + kilde), men v1 bygger kun runder-delen — løst med ett bevisst tynt, + navngitt skjøtepunkt (`_gather_qualifying_differentials`), IKKE en ny + generell "scoring record"-tabell (for tidlig abstraksjon). + **Ny migrasjon `030_actual_handicap_index.sql`:** `app_user. + computed_handicap_index`/`computed_handicap_index_updated_at` (den + faktiske, beregnede WHS-indeksen — ALDRI direkte redigerbar, kun + avledet), `round_participant.exclude_from_handicap` (manuell opt-out, + uavhengig av den automatiske `counts_for_handicap`-kvalifiseringen), + `round.play_format` (`stroke`/`match`, selvdeklarert — ingen egen + match-motor for frittstående runder), `handicap_history.source` + (`manual`/`computed` — gjenbruker EKSISTERENDE tabell fra ADR-031- + oppfølgingen i stedet for en parallell historikk-tabell, siden Low + Handicap Index/cap (Rule 5.7/5.8) trenger nøyaktig samme + dato+indeks-form som allerede fantes der). + **WHS-kilde lest og lagt til grunn** (`WHS_Rules_of_Handicapping_2024. + pdf`, Rule 3.3): matchspill-scorer ER teknisk et gyldig HCP-grunnlag + under WHS, MEN et konsedert/ikke-utspilt hull krever en subjektiv "most + likely score" TeeCups rene slagregistrering ikke har noen vei til å + representere presist — begrunner "spør (med anbefalt eksklusjon), ikke + tving"-designet i stedet for et hardkodet forbud mot matchspill i + HCP-grunnlaget. + **Backend (`app/routers/rounds.py`):** ny `_recompute_computed_ + handicap_index(conn, user_id)` — henter de ≤20 nyeste kvalifiserende + differensialene, kaller den allerede-testede motoren, henter tidligere + `computed`-historikk for Low HI-cap (hopper bevisst over capping ved + FØRSTE beregning noensinne — Low HI er udefinert før en indeks er + etablert). Kalt fra `complete_round` (alle deltakere med `user_id`), + `update_participant` (eksklusjon endret på en ALLEREDE fullført runde), + `remove_guest_participant` (dekker faktisk enhver ikke-eier-fjerning, + til tross for navnet) og `delete_round` (fanger berørte brukere FØR + kaskade-slettingen fjerner radene). `update_participant` sin + autorisasjon utvidet presist: en ikke-eier kan KUN sende + `exclude_from_handicap`, KUN på sin egen rad (`_OWNER_ONLY_ + PARTICIPANT_FIELDS`-sjekk + eksplisitt eier-eller-selv-gate) — alle + andre felt forblir strengt eier-only, uendret. + **Backend (`app/routers/auth.py`):** `Me` fikk `computed_handicap_ + index`/`computed_handicap_index_updated_at`. Ny `POST /auth/profile/ + handicap/apply-computed` — kopierer gjeldende beregnet verdi inn i det + manuelt satte HCP-et (samme skrivevei/historikk-logging som en vanlig + manuell PATCH, kun `source='manual'`). `GET /auth/profile/handicap- + history` eksponerer nå `source` også. + **Frontend:** `/my-rounds/new` fikk en "Spilleform"-bryter + (Slagspill/Matchspill) — velges Matchspill, forhåndsutfylles (ikke + tvinges) en eksklusjons-avkrysning med forklarende tekst. + `round-detail.tsx` fikk en "Matchspill"-badge i headeren, en + spilleform-bryter i `EditRoundPanel` (ren metadata, redigerbar uansett + fullført-status), og `EditParticipantPanel` fikk en ny `restricted`- + modus: en ikke-eier som redigerer SIN EGEN rad, ELLER EIEREN etter at + runden er fullført, ser KUN eksklusjons-toggelen (ikke tee/HCP/navn/ + statistikk) — `PlayerList` sin "Rediger"-knapp vises nå også for en + ikke-eiers egen rad, ikke bare for eieren. `/account` fikk et nytt + "Faktisk HCP (beregnet)"-kort (verdi + sist-beregnet-dato + + "Bruk som mitt HCP →"-knapp), og HCP-historikk-listen viser nå + "beregnet"/"manuelt" per rad. + **Scratch-verifisert grundig, 111/111 sjekker** (isolert + `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API- + container via ekte HTTP, samme mønster som hele prosjektet): + hånd-utregnet WHS-matte bekreftet PRESIST (tre runder med kjente + differensialer [10.0, 12.0, 14.0] ga nøyaktig 8.0 — beste-1-av-3 med + -2.0-justering fra Rule 5.2a-tabellen), <3 tellende runder gir fortsatt + `null` (ikke en feil), sletting av en tellende runde regner faktisk HCP + på nytt (tilbake til `null` under 3), matchspill-runde eksplisitt + ekskludert ved opprettelse telles korrekt IKKE med, "Bruk som mitt + HCP"-overføring bekreftet (+ `handicap_history` bærer nå begge kilder), + og hele autorisasjonsmatrisen for eksklusjons-feltet (medspiller nektes + andre felt og eierens rad, men kan endre EGEN eksklusjon; eieren kan + fortsatt overstyre medspillerens). `test_isolation.sql` 12/12 uendret. + **Reelt funn UNDER selve scratch-oppsettet, ikke i produksjon:** første + forsøk på en engangs API-container monterte kildekoden til feil sti + (`/app/app` i stedet for `/srv/app`, som er `Dockerfile` sin faktiske + `WORKDIR`) — containeren boot-et rent, men kjørte stille det GAMLE, + innbakte imagekoden uendret. Fanget FØR noe ble stolt på, ved at + `/auth/me` manglet det nye feltet helt i et faktisk API-svar — rettet + ved å montere til riktig `/srv`-sti, deretter bekreftet på nytt. + **Ekte nettleser-verifisert** (Chrome DevTools MCP, engangs `next dev`- + container mot scratch-backend — samme "sett resultatet, ikke bare + typesjekk det"-arbeidsmåte som resten av uken): logget inn via ekte + magic-link, opprettet en matchspill-runde → bekreftet + forhåndsutfylt-men-overstyrbar eksklusjonsavkrysning i skjemaet, + "Matchspill"-badge i rundens header, fullførte runden → bekreftet + `EditParticipantPanel` automatisk bytter til restriktert modus (kun + eksklusjons-toggel, ingen av de andre feltene), lagret en endring → + bekreftet ekte `PATCH .../participants/{id}` 200 i nettverksfanen. + Ekte typesjekket produksjonsbuild (alle 24 ruter) kjørt og bekreftet. + **Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: migrasjon + 030 kjørt mot ekte `teecup_db` (alle fire nye kolonnesettene bekreftet, + `test_isolation.sql` fortsatt 12/12 mot ekte database), deretter + `docker compose up -d --build teecup_api teecup_frontend`. Begge + containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` + upåvirket. Verifisert presist at den nye ruten faktisk når FastAPI: + anonymt `POST /auth/profile/handicap/apply-computed` over ekte https ga + korrekt `401 NOT_AUTHENTICATED`, ikke en rå 404. + **Bevisst utenfor omfang, notert i ADR-038:** turnering-/organisasjons- + scoring teller fortsatt ikke mot faktisk HCP (venter på videre + ADR-037-arbeid), ingen egen "aging"-bakgrunnsjobb utover at spørringen + alltid henter kun de 20 nyeste differensialene. Neste steg: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet @@ -4075,3 +4274,15 @@ Neste steg: 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 integrering. +10. **Resterende hull fra 2026-07-28-gjennomgangen av frittstående runder, + fortsatt IKKE bygget** (det viktigste — faktisk HCP — er tettet, se + ADR-038 over): (a) offline-kø (ADR-028) er kun koblet til turnering- + scorekortet (`session-scorecard.tsx`), IKKE frittstående runder + (`round-detail.tsx`/`ScoringWizard`) — nettopp der man oftest står + alene ute på banen uten dekning; (b) ingen Stableford-poengberegning + for frittstående runder (kun rå slag/differensial), inkl. en uløst + "plukket opp ballen"-tilstand; (c) rundedeling/visibility (ADR-036 + fase 2, public/private/friends) fortsatt ikke bygget; (d) flere + flighter i én frittstående runde fortsatt kun drøftet (se + FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke + til rundehendelser ennå. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 6479345..97407f5 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -2650,7 +2650,23 @@ se over) står fortsatt som anbefaling, uendret av presiseringen. --- -## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-25 (se ADR-033 for full detalj per dag) +## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-28 (se ADR-033/ADR-038 for full detalj per dag) + +**Oppdatering 2026-07-28 (ADR-038):** en full gjennomgang av eksisterende +funksjonalitet mot .md-filene avdekket at hele WHS-indeksmotoren i +`handicap_engine.py` (bygget/testet under ADR-033) aldri ble kalt fra +noe API-endepunkt — score-differensialer ble lagret per runde, men +`app_user.handicap_index` endret seg kun manuelt. Bygget, scratch- +verifisert (111/111) og rullet ut live: to atskilte HCP-tall (manuelt +vs. faktisk/beregnet), en eksplisitt "overfør til manuelt HCP"-handling, +en manuell eksklusjons-toggel per deltaker (kontrollert av HVER +innlogget deltaker for sin egen rad), og selvdeklarert spilleform +(slagspill/matchspill) med en anbefalt-men-overstyrbar eksklusjon for +matchspill. Se CLAUDE.md-status 2026-07-28 for full detalj (migrasjon +`030`, backend/frontend-endringer, verifisering). +**Fortsatt IKKE tettet, notert samme runde:** offline-kø kun for +turnering-scorekortet (ikke frittstående runder), ingen Stableford, +rundedeling/visibility (ADR-036 fase 2) fortsatt ikke bygget. **Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema (`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner), diff --git a/app/routers/auth.py b/app/routers/auth.py index e889e3f..03b00d2 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -645,6 +645,14 @@ class Me(BaseModel): birth_date: date | None gender: str | None handicap_index: float | None + # Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet + # automatisk fra beste 8 av de ≤20 nyeste tellende Score + # Differential-ene fra frittstående runder. Aldri direkte redigerbar + # -- kun `handicap_index` over er det (manuelt satt, brukt til + # beregning i runder som i dag). `None` betyr færre enn 3 tellende + # runder ennå, ikke en feil. + computed_handicap_index: float | None + computed_handicap_index_updated_at: str | None home_club: str | None country: str | None bio: str | None @@ -685,7 +693,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: SELECT id::text AS id, email::text AS email, display_name, preferred_locale, (password_hash IS NOT NULL) AS has_password, two_factor_method, first_name, last_name, birth_date, gender, - handicap_index::float AS handicap_index, home_club, country, bio, + handicap_index::float AS handicap_index, + computed_handicap_index::float AS computed_handicap_index, + computed_handicap_index_updated_at, + home_club, country, bio, mobile_country_code, mobile_number, bag_clubs, avatar_key FROM app_user WHERE id = $1 """, @@ -765,6 +776,11 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: birth_date=user_row["birth_date"], gender=user_row["gender"], handicap_index=user_row["handicap_index"], + computed_handicap_index=user_row["computed_handicap_index"], + computed_handicap_index_updated_at=( + user_row["computed_handicap_index_updated_at"].isoformat() + if user_row["computed_handicap_index_updated_at"] else None + ), home_club=user_row["home_club"], country=user_row["country"], bio=user_row["bio"], @@ -872,13 +888,36 @@ async def get_handicap_history(user: CurrentUser = Depends(get_current_user)) -> async with plain_connection() as conn: rows = await conn.fetch( """ - SELECT handicap_index::float AS handicap_index, recorded_at + SELECT handicap_index::float AS handicap_index, recorded_at, source FROM handicap_history WHERE user_id = $1 ORDER BY recorded_at """, user.user_id, ) - return [{"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat()} for r in rows] + return [ + {"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat(), "source": r["source"]} + for r in rows + ] + + +@router.post("/profile/handicap/apply-computed", response_model=Me) +async def apply_computed_handicap(user: CurrentUser = Depends(get_current_user)) -> Me: + """ADR-038 Beslutning B -- eksplisitt "Bruk som mitt HCP"-handling. + Kopierer gjeldende computed_handicap_index inn i det manuelt satte + handicap_index. INGEN automatisk synkronisering noen vei ellers -- + de to tallene lever uavhengig helt til brukeren selv trykker denne.""" + async with plain_connection() as conn: + computed = await conn.fetchval( + "SELECT computed_handicap_index::float FROM app_user WHERE id = $1", user.user_id + ) + if computed is None: + raise app_error(400, "VALIDATION_FAILED", "Ingen faktisk HCP er beregnet ennå.") + await conn.execute("UPDATE app_user SET handicap_index = $2 WHERE id = $1", user.user_id, computed) + await conn.execute( + "INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'manual')", + user.user_id, computed, + ) + return await me(user) @router.post("/profile/avatar", response_model=Me) diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 12a072e..f258bf5 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -19,9 +19,15 @@ DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå. -HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer -IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført, -tellende resultat lagres kun som `round_participant.score_differential`. +HCP: to atskilte tall (ADR-038). `app_user.handicap_index` er MANUELT +satt, uendret betydning (brukes til Course Handicap i enhver runde med +mindre eksplisitt overstyrt). `app_user.computed_handicap_index` er det +FAKTISKE, automatisk beregnede WHS Handicap Index (Rule 5.2) -- +oppdateres av `_recompute_computed_handicap_index()` ved fullføring, ved +eksklusjons-endring på en allerede fullført runde, og ved fjerning/ +sletting av en tellende deltaker/runde. Kun frittstående runder teller +med i v1 (se `_gather_qualifying_differentials` for skjøtepunktet mot en +fremtidig turnering-kilde, ADR-038 Beslutning E). """ from __future__ import annotations @@ -41,7 +47,10 @@ from ..realtime import broadcast_round_update, live_sockets_for_round from handicap_engine import ( adjusted_gross_score, allocate_strokes_by_index, + apply_index_caps, course_handicap, + handicap_index_from_differentials, + low_handicap_index, round_counts_for_handicap, score_differential, ) @@ -416,6 +425,12 @@ class RoundCreate(BaseModel): # Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne # tidsbruk (completed_at - started_at) når runden fullføres. started_at: datetime | None = None + # Selvdeklarert spilleform (ADR-038 Beslutning D) -- ingen egen + # match-motor for frittstående runder, så dette taes brukerens ord for. + play_format: Literal["stroke", "match"] = "stroke" + # Frontend forhåndsutfyller denne til True når play_format='match' + # (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse. + exclude_owner_from_handicap: bool = False class RoundParticipantOut(BaseModel): @@ -442,6 +457,10 @@ class RoundParticipantOut(BaseModel): counts_for_handicap: bool score_differential: float | None stat_level: StatLevel + # ADR-038 Beslutning C -- manuell opt-out, uavhengig av + # counts_for_handicap. Kontrolleres av DENNE deltakeren selv (eller + # runde-eieren), se update_participant. + exclude_from_handicap: bool class RoundOut(BaseModel): @@ -453,6 +472,7 @@ class RoundOut(BaseModel): played_at: date start_hole: int holes_planned: int + play_format: str started_at: str | None completed_at: str | None participants: list[RoundParticipantOut] @@ -470,7 +490,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: round_row = await conn.fetchrow( """ SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot, - played_at, start_hole, holes_planned, started_at, completed_at + played_at, start_hole, holes_planned, play_format, started_at, completed_at FROM round WHERE id = $1 """, round_id, @@ -482,7 +502,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: rp.is_owner, rp.gender, rp.tee_name_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.course_handicap_snapshot, rp.counts_for_handicap, - rp.score_differential::float AS score_differential, rp.stat_level + rp.score_differential::float AS score_differential, rp.stat_level, + rp.exclude_from_handicap FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at @@ -520,6 +541,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: played_at=round_row["played_at"], start_hole=round_row["start_hole"], holes_planned=round_row["holes_planned"], + play_format=round_row["play_format"], started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None, completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None, participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], @@ -542,6 +564,7 @@ async def _create_participant( handicap_index: float | None, stat_level: StatLevel = "strokes_only", guest_email: str | None = None, + exclude_from_handicap: bool = False, ) -> str: rating = resolved.rating_for(tee_name, gender) course_rating = slope_rating = tee_par = None @@ -556,8 +579,8 @@ async def _create_participant( INSERT INTO round_participant (round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot, course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot, - stat_level) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + stat_level, exclude_from_handicap) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id::text AS id """, round_id, @@ -573,6 +596,7 @@ async def _create_participant( tee_par, course_handicap_snapshot, stat_level, + exclude_from_handicap, ) participant_id = participant_row["id"] @@ -621,8 +645,8 @@ async def create_round( INSERT INTO round (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, - start_hole, holes_planned, started_at, name) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + start_hole, holes_planned, started_at, name, play_format) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id::text AS id """, user.user_id, @@ -637,6 +661,7 @@ async def create_round( body.holes_planned, body.started_at, body.name.strip() if body.name and body.name.strip() else None, + body.play_format, ) round_id = round_row["id"] @@ -645,6 +670,7 @@ async def create_round( user_id=user.user_id, guest_name=None, is_owner=True, gender=owner_row["gender"], handicap_index=owner_row["handicap_index"], stat_level=body.stat_level, + exclude_from_handicap=body.exclude_owner_from_handicap, ) return await _load_round_out(conn, round_id, user.user_id) @@ -686,12 +712,28 @@ class RoundStatsSummary(BaseModel): green_miss_right_pct: float | None green_miss_tracked_holes: int one_putt_pct: float | None + putt_dist_one_pct: float | None + putt_dist_two_pct: float | None + putt_dist_three_plus_pct: float | None scrambling_pct: float | None sand_save_pct: float | None avg_chip_per_round: float | None avg_bunker_per_round: float | None avg_penalty_per_round: float | None avg_anyway_per_round: float | None + # Til-par splittet på om en hendelse inntraff på HULLET -- pooler + # enkelthull på tvers av alle runder (ikke per-runde-snitt, siden dette + # er hull-nivå-betingelser), samme formel som `round-stats.tsx` sin + # `avgToParWithGir`/`avgToParFairwayHit`/`avgToParWithBunker` bruker + # for én runde. + avg_to_par_with_gir: float | None + avg_to_par_without_gir: float | None + avg_to_par_fairway_hit: float | None + avg_to_par_fairway_miss: float | None + avg_to_par_with_bunker: float | None + avg_to_par_without_bunker: float | None + avg_to_par_with_anyway: float | None + avg_to_par_without_anyway: float | None class RoundStatsWindowSummary(BaseModel): @@ -773,6 +815,14 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level gir_eligible = [h for h in all_played if h["putts"] is not None] gir_pct = avg([100.0 if is_gir(h) else 0.0 for h in gir_eligible]) one_putt_pct = avg([100.0 if h["putts"] <= 1 else 0.0 for h in gir_eligible]) + # Puttfordeling (donut-vennlig, 3 gjensidig utelukkende bøtter) -- egne + # eksakte grenser (==1/==2/>=3), SAMME bøtter som `round-stats.tsx` sin + # `puttCategories` bruker per enkeltrunde, ikke samme grense som + # `one_putt_pct` over (som bevisst bruker <=1 -- en litt annen, + # allerede etablert "hvor ofte putter du bra"-rate, ikke en distribusjon). + putt_dist_one_pct = avg([100.0 if h["putts"] == 1 else 0.0 for h in gir_eligible]) + putt_dist_two_pct = avg([100.0 if h["putts"] == 2 else 0.0 for h in gir_eligible]) + putt_dist_three_plus_pct = avg([100.0 if h["putts"] >= 3 else 0.0 for h in gir_eligible]) # Retning på bomskudd mot green -- eget spor (approach_result), ikke # avledet av putte-basert GIR: en spiller kan registrere retning uten @@ -788,6 +838,29 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level sand_eligible = [h for h in gir_missed if h["bunker_shot_count"] is not None and h["bunker_shot_count"] >= 1] sand_save_pct = avg([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in sand_eligible]) + # Til-par splittet på hull-nivå-hendelser (brukerens eksplisitte + # instruks 2026-07-28) -- pooler ENKELTHULL på tvers av alle runder i + # perioden, samme `diff()`-formel som `round-stats.tsx` sin + # `avgToParWithGir`/`avgToParFairwayHit`/`avgToParWithBunker` (portert + # uendret, anywayslag-splitten er en ny, konsekvent utvidelse av samme + # mønster -- fantes ikke fra før for enkeltrunder). + def diff(h) -> int: + return h["score"] - h["par"] + + avg_to_par_with_gir = avg([diff(h) for h in gir_eligible if is_gir(h)]) + avg_to_par_without_gir = avg([diff(h) for h in gir_eligible if not is_gir(h)]) + + avg_to_par_fairway_hit = avg([diff(h) for h in fairway_tracked if h["tee_shot_result"] == "fairway"]) + avg_to_par_fairway_miss = avg([diff(h) for h in fairway_tracked if h["tee_shot_result"] in ("left", "right")]) + + bunker_eligible = [h for h in all_played if h["bunker_shot_count"] is not None] + avg_to_par_with_bunker = avg([diff(h) for h in bunker_eligible if h["bunker_shot_count"] >= 1]) + avg_to_par_without_bunker = avg([diff(h) for h in bunker_eligible if h["bunker_shot_count"] == 0]) + + anyway_eligible = [h for h in all_played if h["anyway_strokes"] is not None] + avg_to_par_with_anyway = avg([diff(h) for h in anyway_eligible if h["anyway_strokes"] >= 1]) + avg_to_par_without_anyway = avg([diff(h) for h in anyway_eligible if h["anyway_strokes"] == 0]) + def avg_per_round(field: str) -> float | None: totals = [] for round_id in round_ids: @@ -813,12 +886,23 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level green_miss_right_pct=miss_pct("right"), green_miss_tracked_holes=len(green_missed), one_putt_pct=one_putt_pct, + putt_dist_one_pct=putt_dist_one_pct, + putt_dist_two_pct=putt_dist_two_pct, + putt_dist_three_plus_pct=putt_dist_three_plus_pct, scrambling_pct=scrambling_pct, sand_save_pct=sand_save_pct, avg_chip_per_round=avg_per_round("chip_count"), avg_bunker_per_round=avg_per_round("bunker_shot_count"), avg_penalty_per_round=avg_per_round("penalty_strokes"), avg_anyway_per_round=avg_per_round("anyway_strokes"), + avg_to_par_with_gir=avg_to_par_with_gir, + avg_to_par_without_gir=avg_to_par_without_gir, + avg_to_par_fairway_hit=avg_to_par_fairway_hit, + avg_to_par_fairway_miss=avg_to_par_fairway_miss, + avg_to_par_with_bunker=avg_to_par_with_bunker, + avg_to_par_without_bunker=avg_to_par_without_bunker, + avg_to_par_with_anyway=avg_to_par_with_anyway, + avg_to_par_without_anyway=avg_to_par_without_anyway, ) @@ -917,6 +1001,67 @@ async def _get_accessible_round_or_404(conn, round_id: str, user_id: str): raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.") +# --------------------------------------------------------------------------- +# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder -- +# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en +# fremtidig UNION mot turnering-/organisasjonsscoring (ADR-037), ikke bygget +# ennå. Ingen ny, generell "scoring record"-tabell -- for tidlig abstraksjon +# for en kilde som ikke finnes. +# --------------------------------------------------------------------------- + +async def _gather_qualifying_differentials(conn, user_id: str) -> list[tuple[date, float]]: + rows = await conn.fetch( + """ + SELECT r.played_at, rp.score_differential::float AS score_differential + FROM round_participant rp + JOIN round r ON r.id = rp.round_id + WHERE rp.user_id = $1 + AND rp.counts_for_handicap + AND NOT rp.exclude_from_handicap + AND rp.score_differential IS NOT NULL + ORDER BY r.played_at DESC + """, + user_id, + ) + return [(row["played_at"], row["score_differential"]) for row in rows] + + +async def _recompute_computed_handicap_index(conn, user_id: str) -> None: + """Rule 5.2 (beste 8-av-≤20) + Rule 5.7/5.8 (Low HI-cap). Ingen tidligere + 'computed'-historikk å låse mot -- capping hoppes bevisst over (Low HI + er udefinert før en første indeks er etablert), ikke en feil.""" + differentials = await _gather_qualifying_differentials(conn, user_id) + new_index = handicap_index_from_differentials([d for _, d in differentials][:20]) + + if new_index is None: + await conn.execute( + "UPDATE app_user SET computed_handicap_index = NULL, computed_handicap_index_updated_at = now() " + "WHERE id = $1", + user_id, + ) + return + + history_rows = await conn.fetch( + """ + SELECT recorded_at::date AS d, handicap_index::float AS idx + FROM handicap_history WHERE user_id = $1 AND source = 'computed' + ORDER BY recorded_at + """, + user_id, + ) + low = low_handicap_index([(row["d"], row["idx"]) for row in history_rows], date.today()) + final_index = apply_index_caps(new_index, low) if low is not None else new_index + + await conn.execute( + "UPDATE app_user SET computed_handicap_index = $2, computed_handicap_index_updated_at = now() WHERE id = $1", + user_id, final_index, + ) + await conn.execute( + "INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'computed')", + user_id, final_index, + ) + + @router.get("/rounds/{round_id}", response_model=RoundOut) async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut: async with plain_connection() as conn: @@ -942,6 +1087,9 @@ class RoundUpdate(BaseModel): name: str | None = Field(default=None, max_length=200) holes_planned: Literal[9, 18] | None = None start_hole: int | None = Field(default=None, ge=1, le=18) + # Ren metadata (ADR-038 Beslutning D) -- rører aldri AGS/differensial- + # matematikken selv, kan derfor endres uansett fullført-status. + play_format: Literal["stroke", "match"] | None = None # Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i # etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer # senere, og vil rette opp tidsbruken). completed_at kan KUN settes på @@ -1041,6 +1189,8 @@ async def update_round( round_id, body.name.strip() if body.name.strip() else None, ) + if body.play_format is not None: + await conn.execute("UPDATE round SET play_format = $2 WHERE id = $1", round_id, body.play_format) if body.started_at is not None: await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at) if body.completed_at is not None: @@ -1097,7 +1247,16 @@ async def update_round( async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None: async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) + # Fanges FØR sletting -- kaskaden fjerner round_participant-radene, + # så de tellende brukerne må hentes ut nå for å kunne regnes på nytt. + affected_users = await conn.fetch( + "SELECT user_id::text AS user_id FROM round_participant " + "WHERE round_id = $1 AND user_id IS NOT NULL AND counts_for_handicap", + round_id, + ) await conn.execute("DELETE FROM round WHERE id = $1", round_id) + for row in affected_users: + await _recompute_computed_handicap_index(conn, row["user_id"]) # Varsler evt. tilkoblede medspillere om at runden er borte -- deres # neste refetch vil da naturlig treffe 404 og vise en tydelig feil. await broadcast_round_update(round_id) @@ -1126,6 +1285,10 @@ class ParticipantCreate(BaseModel): # avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag. tee_name: str | None = None stat_level: StatLevel = "strokes_only" + # ADR-038 Beslutning C/D -- kun meningsfullt for user_id-varianten (en + # gjest har ingen computed_handicap_index å beskytte). Frontend + # forhåndsutfyller True når rundens play_format='match'. + exclude_from_handicap: bool = False @router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201) @@ -1195,6 +1358,7 @@ async def add_participant( gender=gender, handicap_index=handicap_index, stat_level=body.stat_level, guest_email=body.guest_email, + exclude_from_handicap=body.exclude_from_handicap, ) row = await conn.fetchrow( """ @@ -1203,7 +1367,8 @@ async def add_participant( rp.is_owner, rp.gender, rp.tee_name_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.course_handicap_snapshot, rp.counts_for_handicap, - rp.score_differential::float AS score_differential, rp.stat_level + rp.score_differential::float AS score_differential, rp.stat_level, + rp.exclude_from_handicap FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.id = $1 @@ -1253,6 +1418,14 @@ class ParticipantUpdate(BaseModel): guest_name: str | None = Field(default=None, min_length=1, max_length=100) gender: Literal["m", "f", "x"] | None = None guest_email: EmailStr | None = None + # ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket + # medspiller som redigerer SIN EGEN rad) får sende her. + exclude_from_handicap: bool | None = None + + +# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder -- +# eier-only, se update_participant sin autorisasjonssjekk. +_OWNER_ONLY_PARTICIPANT_FIELDS = {"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email"} @router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut) @@ -1267,11 +1440,15 @@ async def update_participant( raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) + # Eier ELLER en lenket medspiller (som kun får røre SIN EGEN + # eksklusjons-flagg, se under) -- ikke strengt eier-only lenger. + await _get_accessible_round_or_404(conn, round_id, user.user_id) current = await conn.fetchrow( """ SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot, + rp.counts_for_handicap, + r.owner_user_id::text AS round_owner_user_id, r.completed_at, r.course_source, r.teeoff_facility_slug, r.teeoff_course_id, r.personal_course_id FROM round_participant rp @@ -1283,6 +1460,16 @@ async def update_participant( if current is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + is_owner = current["round_owner_user_id"] == user.user_id + if not is_owner: + if _OWNER_ONLY_PARTICIPANT_FIELDS & updates.keys(): + raise app_error( + 403, "NOT_AUTHORIZED", + "Du kan kun endre om DIN egen deltakelse skal telle mot ditt faktiske HCP her.", + ) + if current["user_id"] != user.user_id: + raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.") + guest_only_fields = {"guest_name", "gender", "guest_email"} if current["user_id"] is not None and guest_only_fields & updates.keys(): raise app_error( @@ -1309,6 +1496,9 @@ async def update_participant( if "guest_email" in updates: values.append(updates["guest_email"]) set_clauses.append(f"guest_email = ${len(values)}") + if "exclude_from_handicap" in updates: + values.append(updates["exclude_from_handicap"]) + set_clauses.append(f"exclude_from_handicap = ${len(values)}") if rating_changed: if current["course_source"] == "teeoff": @@ -1363,13 +1553,24 @@ async def update_participant( rp.is_owner, rp.gender, rp.tee_name_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.course_handicap_snapshot, rp.counts_for_handicap, - rp.score_differential::float AS score_differential, rp.stat_level + rp.score_differential::float AS score_differential, rp.stat_level, + rp.exclude_from_handicap FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.id = $1 """, updated_id, ) + + # En eksklusjons-endring på en ALLEREDE FULLFØRT, tellende runde + # endrer umiddelbart hvilke differensialer faktisk HCP skal regnes + # fra -- regn på nytt med en gang, ikke vent til neste fullføring. + if ( + "exclude_from_handicap" in updates + and current["user_id"] is not None + and current["completed_at"] is not None + ): + await _recompute_computed_handicap_index(conn, current["user_id"]) await broadcast_round_update(round_id) return RoundParticipantOut(**dict(row)) @@ -1383,7 +1584,8 @@ async def remove_guest_participant( async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) row = await conn.fetchrow( - "SELECT is_owner FROM round_participant WHERE id = $1 AND round_id = $2", + "SELECT is_owner, user_id::text AS user_id, counts_for_handicap FROM round_participant " + "WHERE id = $1 AND round_id = $2", participant_id, round_id, ) if row is None: @@ -1391,6 +1593,11 @@ async def remove_guest_participant( if row["is_owner"]: raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.") await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id) + + # Fjerner en tellende differensial fra grunnlaget -- regn faktisk + # HCP på nytt for den fjernede spilleren (ikke eieren, som er igjen). + if row["user_id"] is not None and row["counts_for_handicap"]: + await _recompute_computed_handicap_index(conn, row["user_id"]) await broadcast_round_update(round_id) @@ -1687,7 +1894,8 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ participants = await conn.fetch( """ - SELECT id::text AS id, handicap_index_snapshot::float AS handicap_index_snapshot, + SELECT id::text AS id, user_id::text AS user_id, + handicap_index_snapshot::float AS handicap_index_snapshot, course_rating_snapshot::float AS course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot FROM round_participant WHERE round_id = $1 @@ -1723,6 +1931,14 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ ) await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id) + + # Faktisk HCP (ADR-038) -- alle differensialer for runden er nå + # skrevet, regn på nytt for hver deltaker med egen konto (gjester + # har ingen computed_handicap_index å oppdatere). + for p in participants: + if p["user_id"] is not None: + await _recompute_computed_handicap_index(conn, p["user_id"]) + result = await _load_round_out(conn, round_id, user.user_id) await broadcast_round_update(round_id) diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index bc95ef0..ede3976 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -170,6 +170,10 @@ type Me = { birth_date: string | null gender: "m" | "f" | "x" | null handicap_index: number | null + // Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet + // automatisk fra frittstående runder. Aldri direkte redigerbar. + computed_handicap_index: number | null + computed_handicap_index_updated_at: string | null home_club: string | null country: string | null bio: string | null @@ -819,11 +823,81 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { + ) } +// Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet automatisk +// fra frittstående runder (round.py sin _recompute_computed_handicap_index). +// Aldri direkte redigerbar -- kun "manuelt satt HCP" over er det. Denne +// kortet lar brukeren EKSPLISITT kopiere over verdien, ingen automatikk. +function ActualHandicapCard({ me, onChanged }: { me: Me; onChanged: () => void }) { + const [applying, setApplying] = useState(false) + const [error, setError] = useState(null) + + async function applyComputed() { + setApplying(true) + setError(null) + try { + const res = await fetch("/auth/profile/handicap/apply-computed", { + method: "POST", + credentials: "include", + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å overføre HCP.") + } + onChanged() + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + } finally { + setApplying(false) + } + } + + return ( +
+ Faktisk HCP (beregnet) + {me.computed_handicap_index === null ? ( +

+ Ingen beregning ennå -- trengs minst 3 tellende frittstående runder. +

+ ) : ( + <> +

+ Basert på dine siste tellende runder (WHS Rule 5.2), sist beregnet{" "} + {me.computed_handicap_index_updated_at + ? new Date(me.computed_handicap_index_updated_at).toLocaleDateString("no-NO", { + day: "numeric", + month: "short", + year: "numeric", + }) + : "nylig"} + . +

+
+ + {me.computed_handicap_index} + + +
+ + )} + {error &&

{error}

} +
+ ) +} + // HCP-historikk (ADR-031 sitt "naturlig neste steg"-punkt): en append-only // logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver // gang HCP-feltet faktisk endres til en tallverdi. `reloadKey` (gjeldende @@ -831,7 +905,9 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { // egen refetch-prop å tre gjennom fra ProfileSection. function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) { const [open, setOpen] = useState(false) - const [entries, setEntries] = useState<{ handicap_index: number; recorded_at: string }[] | null>(null) + const [entries, setEntries] = useState< + { handicap_index: number; recorded_at: string; source: "manual" | "computed" }[] | null + >(null) useEffect(() => { if (!open) return @@ -881,7 +957,12 @@ function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) { year: "numeric", })} - {entry.handicap_index} + + {entry.handicap_index} + + {entry.source === "computed" ? "beregnet" : "manuelt"} + + ))} diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx index 15d29ad..3d4904b 100644 --- a/frontend/components/new-round.tsx +++ b/frontend/components/new-round.tsx @@ -71,6 +71,9 @@ type CourseMeta = type Gender = "m" | "f" type StatLevel = "strokes_only" | "strokes_and_putts" | "full" +// ADR-038 -- selvdeklarert spilleform. Ingen egen match-motor for +// frittstående runder, så dette taes brukerens ord for. +type PlayFormat = "stroke" | "match" // --- API-typer --------------------------------------------------------------- @@ -198,6 +201,8 @@ export function NewRound() { startHole: number holes: 9 | 18 statLevel: StatLevel + playFormat: PlayFormat + excludeFromHandicap: boolean }): Promise<{ id: string }> { if (!courseMeta) throw new Error("Mangler valgt bane") const body = @@ -213,6 +218,8 @@ export function NewRound() { start_hole: payload.startHole, holes_planned: payload.holes, stat_level: payload.statLevel, + play_format: payload.playFormat, + exclude_owner_from_handicap: payload.excludeFromHandicap, } : { course_source: "custom", @@ -224,6 +231,8 @@ export function NewRound() { start_hole: payload.startHole, holes_planned: payload.holes, stat_level: payload.statLevel, + play_format: payload.playFormat, + exclude_owner_from_handicap: payload.excludeFromHandicap, } const res = await fetch("/rounds", { method: "POST", @@ -1110,6 +1119,8 @@ function ConfirmStep({ startHole: number holes: 9 | 18 statLevel: StatLevel + playFormat: PlayFormat + excludeFromHandicap: boolean }) => Promise<{ id: string }> onCreated: (roundId: string) => void }) { @@ -1121,9 +1132,18 @@ function ConfirmStep({ const [startHole, setStartHole] = useState("1") const [holes, setHoles] = useState<9 | 18>(18) const [statLevel, setStatLevel] = useState("strokes_only") + const [playFormat, setPlayFormat] = useState("stroke") + const [excludeFromHandicap, setExcludeFromHandicap] = useState(false) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + function choosePlayFormat(next: PlayFormat) { + setPlayFormat(next) + // ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når + // matchspill velges. Brukeren kan uansett overstyre under. + setExcludeFromHandicap(next === "match") + } + const selectedTee = compatibleTees.find((t) => t.id === teeId) async function handleStart() { @@ -1143,6 +1163,8 @@ function ConfirmStep({ startHole: Number(startHole), holes, statLevel, + playFormat, + excludeFromHandicap, }) onCreated(created.id) } catch { @@ -1289,6 +1311,53 @@ function ConfirmStep({ + {/* Spilleform (ADR-038) -- selvdeklarert, ingen egen match-motor */} +
+ Spilleform +
+ {( + [ + { value: "stroke" as const, label: "Slagspill" }, + { value: "match" as const, label: "Matchspill" }, + ] + ).map((opt) => ( + + ))} +
+ {playFormat === "match" && ( + + )} +
+ {/* Hole count */}
Antall hull diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index b51599a..e7fc0f2 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -69,6 +69,9 @@ type Player = { countsForHandicap: boolean scoreDifferential: number | null statLevel: StatLevel + // ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av + // countsForHandicap (den automatiske kvalifiseringen). + excludeFromHandicap: boolean } type HoleStat = { @@ -163,6 +166,7 @@ type ApiParticipant = { counts_for_handicap: boolean score_differential: number | null stat_level: StatLevel + exclude_from_handicap: boolean } type PersonMatch = { @@ -187,6 +191,7 @@ type ApiRound = { played_at: string start_hole: number holes_planned: number + play_format: string started_at: string | null completed_at: string | null participants: ApiParticipant[] @@ -430,6 +435,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { countsForHandicap: p.counts_for_handicap, scoreDifferential: p.score_differential, statLevel: p.stat_level, + excludeFromHandicap: p.exclude_from_handicap, })) ?? [] // Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne @@ -695,6 +701,11 @@ export function RoundDetail({ roundId }: { roundId: string }) { {formatTime(round.started_at)} )} + {round.play_format === "match" && ( + + Matchspill + + )} @@ -1542,7 +1553,15 @@ function PlayerList({ const active = player.id === activePlayerId const editing = editingPlayerId === player.id const canRemove = !player.isSelf && !readOnly && canManage - const canEdit = canManage && !readOnly + // ADR-038 -- full redigering (utslag/HCP/navn/statistikk) er + // fortsatt eier-only OG sperret etter fullføring. Men enhver + // lenket deltaker (eier ELLER medspiller, kun sin EGEN rad) skal + // kunne endre "ekskluder fra faktisk HCP" UANSETT fullført-status + // -- dekket av en RESTRIKTERT variant av samme panel, kun + // meningsfullt for en spiller med egen konto (userId). + const canEditFull = canManage && !readOnly + const canEditRestricted = (canManage || player.isSelf) && player.userId !== null + const canEdit = canEditFull || canEditRestricted return (
onToggleEdit(player.id)} /> @@ -1921,6 +1941,9 @@ function EditRoundPanel({ const [startHole, setStartHole] = useState(String(round.start_hole)) const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at)) const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at)) + // ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri + // AGS/differensial-matematikken selv). + const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke") const [saving, setSaving] = useState(false) const [showChangeCourse, setShowChangeCourse] = useState(false) const [error, setError] = useState(null) @@ -1929,7 +1952,8 @@ function EditRoundPanel({ name.trim() !== (round.name ?? "") || (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) || toDatetimeLocalValue(round.started_at) !== startedAt || - (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) + (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) || + playFormat !== (round.play_format === "match" ? "match" : "stroke") async function saveMetadata() { setSaving(true) @@ -1946,6 +1970,7 @@ function EditRoundPanel({ if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) { body.completed_at = fromDatetimeLocalValue(completedAt) } + if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat const result = await onPatch(body) if (!result.ok) setError(result.message) setSaving(false) @@ -1976,6 +2001,33 @@ function EditRoundPanel({ />
+
+ Spilleform +
+ {( + [ + { value: "stroke" as const, label: "Slagspill" }, + { value: "match" as const, label: "Matchspill" }, + ] + ).map((opt) => ( + + ))} +
+
+ {!isCompleted && ( <>
@@ -2079,11 +2131,17 @@ function EditRoundPanel({ function EditParticipantPanel({ roundId, player, + restricted, onPatch, onClose, }: { roundId: string player: Player + // ADR-038 -- true når viewer IKKE har full redigeringsrett her (en + // ikke-eier som redigerer sin EGEN rad, ELLER eieren etter at runden er + // fullført) -- viser da KUN eksklusjons-toggelen, ingen av de andre + // feltene under. + restricted: boolean onPatch: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> onClose: () => void }) { @@ -2100,10 +2158,12 @@ function EditParticipantPanel({ const [guestName, setGuestName] = useState(player.name) const [gender, setGender] = useState(player.gender) const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "") + const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) useEffect(() => { + if (restricted) return let cancelled = false fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) @@ -2116,7 +2176,7 @@ function EditParticipantPanel({ return () => { cancelled = true } - }, [roundId]) + }, [roundId, restricted]) // Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da // umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket @@ -2128,18 +2188,23 @@ function EditParticipantPanel({ setSaving(true) setError(null) const body: Record = {} - if (teeName !== player.teeName) body.tee_name = teeName - const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) - const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null - if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp - if (statLevel !== player.statLevel) body.stat_level = statLevel - if (isGuest) { - const trimmedName = guestName.trim() - if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName - if (gender !== player.gender) body.gender = apiGender - const trimmedEmail = guestEmail.trim() - const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail - if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail + if (!restricted) { + if (teeName !== player.teeName) body.tee_name = teeName + const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) + const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null + if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp + if (statLevel !== player.statLevel) body.stat_level = statLevel + if (isGuest) { + const trimmedName = guestName.trim() + if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName + if (gender !== player.gender) body.gender = apiGender + const trimmedEmail = guestEmail.trim() + const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail + if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail + } + } + if (!isGuest && excludeFromHandicap !== player.excludeFromHandicap) { + body.exclude_from_handicap = excludeFromHandicap } if (Object.keys(body).length === 0) { setSaving(false) @@ -2159,104 +2224,135 @@ function EditParticipantPanel({
{error &&

{error}

} - {isGuest && ( + {restricted && ( +

+ Runden er fullført, eller du har ikke rett til å endre de andre feltene her -- du kan + likevel endre om {player.isSelf ? "din egen deltakelse" : "spillerens deltakelse"} skal + telle mot {player.isSelf ? "ditt" : "spillerens"} faktiske HCP. +

+ )} + + {!restricted && ( <> + {isGuest && ( + <> +
+ + setGuestName(e.target.value)} + className="h-11 rounded-xl text-base" + /> +
+ + setGender(v as Gender)} + readOnly={false} + /> + +
+ + setGuestEmail(e.target.value)} + placeholder="navn@eksempel.no" + className="h-11 rounded-xl text-base" + /> +
+ +
+ + )} + +

+ Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil. +

+
-
+ +
+ setGuestName(e.target.value)} - className="h-11 rounded-xl text-base" + id={`edit-hcp-${player.id}`} + inputMode="decimal" + type="number" + step="0.1" + value={hcp} + onChange={(e) => setHcp(e.target.value)} + placeholder="F.eks. 18" + className="h-12 rounded-2xl text-base" />
setGender(v as Gender)} + value={statLevel} + onChange={(v) => setStatLevel(v as StatLevel)} readOnly={false} /> - -
- - setGuestEmail(e.target.value)} - placeholder="navn@eksempel.no" - className="h-11 rounded-xl text-base" - /> -
- -
)} -

- Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil. -

- -
- - - {teeOptions !== null && availableTees.length === 0 && ( -

Fant ingen utslag med rating for dette kjønnet på denne banen.

- )} -
- -
- - setHcp(e.target.value)} - placeholder="F.eks. 18" - className="h-12 rounded-2xl text-base" - /> -
- - setStatLevel(v as StatLevel)} - readOnly={false} - /> + {!isGuest && ( + + )}