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 (
+