Alt er ferdig, verifisert og live. Oppsummert:

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.
This commit is contained in:
Erol Haagenrud 2026-07-28 11:04:51 +02:00
parent 99b71ab97b
commit 997f3c40ed
10 changed files with 1411 additions and 247 deletions

View file

@ -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'));

View file

@ -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å) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

211
CLAUDE.md
View file

@ -3871,6 +3871,205 @@ Ferdig og verifisert:
`/health` → 200, anonymt `GET /rounds/stats/summary?window=last_5` → 401 `/health` → 200, anonymt `GET /rounds/stats/summary?window=last_5` → 401
(bekrefter query-parameteren ruter riktig gjennom Caddy/rewrites), (bekrefter query-parameteren ruter riktig gjennom Caddy/rewrites),
`teeoff.no` upåvirket. `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: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 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 scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
integrering. 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å.

View file

@ -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 **Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema
(`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner), (`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner),

View file

@ -645,6 +645,14 @@ class Me(BaseModel):
birth_date: date | None birth_date: date | None
gender: str | None gender: str | None
handicap_index: float | 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 home_club: str | None
country: str | None country: str | None
bio: 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, SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
(password_hash IS NOT NULL) AS has_password, two_factor_method, (password_hash IS NOT NULL) AS has_password, two_factor_method,
first_name, last_name, birth_date, gender, 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 mobile_country_code, mobile_number, bag_clubs, avatar_key
FROM app_user WHERE id = $1 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"], birth_date=user_row["birth_date"],
gender=user_row["gender"], gender=user_row["gender"],
handicap_index=user_row["handicap_index"], 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"], home_club=user_row["home_club"],
country=user_row["country"], country=user_row["country"],
bio=user_row["bio"], 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: async with plain_connection() as conn:
rows = await conn.fetch( 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 FROM handicap_history WHERE user_id = $1
ORDER BY recorded_at ORDER BY recorded_at
""", """,
user.user_id, 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) @router.post("/profile/avatar", response_model=Me)

View file

@ -19,9 +19,15 @@ DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte
kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for
runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå. runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå.
HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer HCP: to atskilte tall (ADR-038). `app_user.handicap_index` er MANUELT
IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført, satt, uendret betydning (brukes til Course Handicap i enhver runde med
tellende resultat lagres kun som `round_participant.score_differential`. 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 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 from __future__ import annotations
@ -41,7 +47,10 @@ from ..realtime import broadcast_round_update, live_sockets_for_round
from handicap_engine import ( from handicap_engine import (
adjusted_gross_score, adjusted_gross_score,
allocate_strokes_by_index, allocate_strokes_by_index,
apply_index_caps,
course_handicap, course_handicap,
handicap_index_from_differentials,
low_handicap_index,
round_counts_for_handicap, round_counts_for_handicap,
score_differential, score_differential,
) )
@ -416,6 +425,12 @@ class RoundCreate(BaseModel):
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne # Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
# tidsbruk (completed_at - started_at) når runden fullføres. # tidsbruk (completed_at - started_at) når runden fullføres.
started_at: datetime | None = None 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): class RoundParticipantOut(BaseModel):
@ -442,6 +457,10 @@ class RoundParticipantOut(BaseModel):
counts_for_handicap: bool counts_for_handicap: bool
score_differential: float | None score_differential: float | None
stat_level: StatLevel 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): class RoundOut(BaseModel):
@ -453,6 +472,7 @@ class RoundOut(BaseModel):
played_at: date played_at: date
start_hole: int start_hole: int
holes_planned: int holes_planned: int
play_format: str
started_at: str | None started_at: str | None
completed_at: str | None completed_at: str | None
participants: list[RoundParticipantOut] 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( round_row = await conn.fetchrow(
""" """
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot, 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 FROM round WHERE id = $1
""", """,
round_id, 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.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap, 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 FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id 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 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"], played_at=round_row["played_at"],
start_hole=round_row["start_hole"], start_hole=round_row["start_hole"],
holes_planned=round_row["holes_planned"], 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, 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, completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
@ -542,6 +564,7 @@ async def _create_participant(
handicap_index: float | None, handicap_index: float | None,
stat_level: StatLevel = "strokes_only", stat_level: StatLevel = "strokes_only",
guest_email: str | None = None, guest_email: str | None = None,
exclude_from_handicap: bool = False,
) -> str: ) -> str:
rating = resolved.rating_for(tee_name, gender) rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None course_rating = slope_rating = tee_par = None
@ -556,8 +579,8 @@ async def _create_participant(
INSERT INTO round_participant INSERT INTO round_participant
(round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot, (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, course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
stat_level) stat_level, exclude_from_handicap)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
round_id, round_id,
@ -573,6 +596,7 @@ async def _create_participant(
tee_par, tee_par,
course_handicap_snapshot, course_handicap_snapshot,
stat_level, stat_level,
exclude_from_handicap,
) )
participant_id = participant_row["id"] participant_id = participant_row["id"]
@ -621,8 +645,8 @@ async def create_round(
INSERT INTO round INSERT INTO round
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id, (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name) start_hole, holes_planned, started_at, name, play_format)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
user.user_id, user.user_id,
@ -637,6 +661,7 @@ async def create_round(
body.holes_planned, body.holes_planned,
body.started_at, body.started_at,
body.name.strip() if body.name and body.name.strip() else None, body.name.strip() if body.name and body.name.strip() else None,
body.play_format,
) )
round_id = round_row["id"] round_id = round_row["id"]
@ -645,6 +670,7 @@ async def create_round(
user_id=user.user_id, guest_name=None, is_owner=True, user_id=user.user_id, guest_name=None, is_owner=True,
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"], gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level, stat_level=body.stat_level,
exclude_from_handicap=body.exclude_owner_from_handicap,
) )
return await _load_round_out(conn, round_id, user.user_id) 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_right_pct: float | None
green_miss_tracked_holes: int green_miss_tracked_holes: int
one_putt_pct: float | None 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 scrambling_pct: float | None
sand_save_pct: float | None sand_save_pct: float | None
avg_chip_per_round: float | None avg_chip_per_round: float | None
avg_bunker_per_round: float | None avg_bunker_per_round: float | None
avg_penalty_per_round: float | None avg_penalty_per_round: float | None
avg_anyway_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): 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_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]) 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]) 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 # Retning på bomskudd mot green -- eget spor (approach_result), ikke
# avledet av putte-basert GIR: en spiller kan registrere retning uten # 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_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]) 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: def avg_per_round(field: str) -> float | None:
totals = [] totals = []
for round_id in round_ids: 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_right_pct=miss_pct("right"),
green_miss_tracked_holes=len(green_missed), green_miss_tracked_holes=len(green_missed),
one_putt_pct=one_putt_pct, 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, scrambling_pct=scrambling_pct,
sand_save_pct=sand_save_pct, sand_save_pct=sand_save_pct,
avg_chip_per_round=avg_per_round("chip_count"), avg_chip_per_round=avg_per_round("chip_count"),
avg_bunker_per_round=avg_per_round("bunker_shot_count"), avg_bunker_per_round=avg_per_round("bunker_shot_count"),
avg_penalty_per_round=avg_per_round("penalty_strokes"), avg_penalty_per_round=avg_per_round("penalty_strokes"),
avg_anyway_per_round=avg_per_round("anyway_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.") 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) @router.get("/rounds/{round_id}", response_model=RoundOut)
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut: async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn: async with plain_connection() as conn:
@ -942,6 +1087,9 @@ class RoundUpdate(BaseModel):
name: str | None = Field(default=None, max_length=200) name: str | None = Field(default=None, max_length=200)
holes_planned: Literal[9, 18] | None = None holes_planned: Literal[9, 18] | None = None
start_hole: int | None = Field(default=None, ge=1, le=18) 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 # Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer # etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på # senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
@ -1041,6 +1189,8 @@ async def update_round(
round_id, round_id,
body.name.strip() if body.name.strip() else None, 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: if body.started_at is not None:
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at) await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
if body.completed_at is not None: 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 def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
async with plain_connection() as conn: async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id) 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) 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 # Varsler evt. tilkoblede medspillere om at runden er borte -- deres
# neste refetch vil da naturlig treffe 404 og vise en tydelig feil. # neste refetch vil da naturlig treffe 404 og vise en tydelig feil.
await broadcast_round_update(round_id) await broadcast_round_update(round_id)
@ -1126,6 +1285,10 @@ class ParticipantCreate(BaseModel):
# avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag. # avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
tee_name: str | None = None tee_name: str | None = None
stat_level: StatLevel = "strokes_only" 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) @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, gender=gender, handicap_index=handicap_index,
stat_level=body.stat_level, stat_level=body.stat_level,
guest_email=body.guest_email, guest_email=body.guest_email,
exclude_from_handicap=body.exclude_from_handicap,
) )
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
@ -1203,7 +1367,8 @@ async def add_participant(
rp.is_owner, rp.gender, rp.tee_name_snapshot, rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap, 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 FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 WHERE rp.id = $1
@ -1253,6 +1418,14 @@ class ParticipantUpdate(BaseModel):
guest_name: str | None = Field(default=None, min_length=1, max_length=100) guest_name: str | None = Field(default=None, min_length=1, max_length=100)
gender: Literal["m", "f", "x"] | None = None gender: Literal["m", "f", "x"] | None = None
guest_email: EmailStr | 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) @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.") raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
async with plain_connection() as conn: 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( current = await conn.fetchrow(
""" """
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot, SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_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.completed_at, r.course_source, r.teeoff_facility_slug,
r.teeoff_course_id, r.personal_course_id r.teeoff_course_id, r.personal_course_id
FROM round_participant rp FROM round_participant rp
@ -1283,6 +1460,16 @@ async def update_participant(
if current is None: if current is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") 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"} guest_only_fields = {"guest_name", "gender", "guest_email"}
if current["user_id"] is not None and guest_only_fields & updates.keys(): if current["user_id"] is not None and guest_only_fields & updates.keys():
raise app_error( raise app_error(
@ -1309,6 +1496,9 @@ async def update_participant(
if "guest_email" in updates: if "guest_email" in updates:
values.append(updates["guest_email"]) values.append(updates["guest_email"])
set_clauses.append(f"guest_email = ${len(values)}") 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 rating_changed:
if current["course_source"] == "teeoff": if current["course_source"] == "teeoff":
@ -1363,13 +1553,24 @@ async def update_participant(
rp.is_owner, rp.gender, rp.tee_name_snapshot, rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot, rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap, 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 FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1 WHERE rp.id = $1
""", """,
updated_id, 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) await broadcast_round_update(round_id)
return RoundParticipantOut(**dict(row)) return RoundParticipantOut(**dict(row))
@ -1383,7 +1584,8 @@ async def remove_guest_participant(
async with plain_connection() as conn: async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id) await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow( 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, participant_id, round_id,
) )
if row is None: if row is None:
@ -1391,6 +1593,11 @@ async def remove_guest_participant(
if row["is_owner"]: if row["is_owner"]:
raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.") raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id) 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) 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( 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, course_rating_snapshot::float AS course_rating_snapshot,
slope_rating_snapshot, tee_par_snapshot slope_rating_snapshot, tee_par_snapshot
FROM round_participant WHERE round_id = $1 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) 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) result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id) await broadcast_round_update(round_id)

View file

@ -170,6 +170,10 @@ type Me = {
birth_date: string | null birth_date: string | null
gender: "m" | "f" | "x" | null gender: "m" | "f" | "x" | null
handicap_index: number | 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 home_club: string | null
country: string | null country: string | null
bio: string | null bio: string | null
@ -819,11 +823,81 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
</Button> </Button>
</form> </form>
<ActualHandicapCard me={me} onChanged={onChanged} />
<HandicapHistorySection reloadKey={me.handicap_index} /> <HandicapHistorySection reloadKey={me.handicap_index} />
</section> </section>
) )
} }
// 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<string | null>(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 (
<div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
<span className="text-sm font-semibold text-foreground">Faktisk HCP (beregnet)</span>
{me.computed_handicap_index === null ? (
<p className="text-sm text-muted-foreground">
Ingen beregning ennå -- trengs minst 3 tellende frittstående runder.
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
Basert 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"}
.
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-extrabold tabular-nums text-foreground">
{me.computed_handicap_index}
</span>
<Button
type="button"
variant="outline"
disabled={applying || me.computed_handicap_index === me.handicap_index}
onClick={applyComputed}
className="h-9 rounded-xl text-sm font-semibold"
>
{applying ? "Overfører …" : "Bruk som mitt HCP →"}
</Button>
</div>
</>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
)
}
// HCP-historikk (ADR-031 sitt "naturlig neste steg"-punkt): en append-only // HCP-historikk (ADR-031 sitt "naturlig neste steg"-punkt): en append-only
// logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver // logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver
// gang HCP-feltet faktisk endres til en tallverdi. `reloadKey` (gjeldende // 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. // egen refetch-prop å tre gjennom fra ProfileSection.
function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) { function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) {
const [open, setOpen] = useState(false) 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(() => { useEffect(() => {
if (!open) return if (!open) return
@ -881,7 +957,12 @@ function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) {
year: "numeric", year: "numeric",
})} })}
</span> </span>
<span className="font-semibold tabular-nums text-foreground">{entry.handicap_index}</span> <span className="flex items-center gap-2">
<span className="font-semibold tabular-nums text-foreground">{entry.handicap_index}</span>
<span className="text-xs font-medium text-muted-foreground">
{entry.source === "computed" ? "beregnet" : "manuelt"}
</span>
</span>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -71,6 +71,9 @@ type CourseMeta =
type Gender = "m" | "f" type Gender = "m" | "f"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full" 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 --------------------------------------------------------------- // --- API-typer ---------------------------------------------------------------
@ -198,6 +201,8 @@ export function NewRound() {
startHole: number startHole: number
holes: 9 | 18 holes: 9 | 18
statLevel: StatLevel statLevel: StatLevel
playFormat: PlayFormat
excludeFromHandicap: boolean
}): Promise<{ id: string }> { }): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane") if (!courseMeta) throw new Error("Mangler valgt bane")
const body = const body =
@ -213,6 +218,8 @@ export function NewRound() {
start_hole: payload.startHole, start_hole: payload.startHole,
holes_planned: payload.holes, holes_planned: payload.holes,
stat_level: payload.statLevel, stat_level: payload.statLevel,
play_format: payload.playFormat,
exclude_owner_from_handicap: payload.excludeFromHandicap,
} }
: { : {
course_source: "custom", course_source: "custom",
@ -224,6 +231,8 @@ export function NewRound() {
start_hole: payload.startHole, start_hole: payload.startHole,
holes_planned: payload.holes, holes_planned: payload.holes,
stat_level: payload.statLevel, stat_level: payload.statLevel,
play_format: payload.playFormat,
exclude_owner_from_handicap: payload.excludeFromHandicap,
} }
const res = await fetch("/rounds", { const res = await fetch("/rounds", {
method: "POST", method: "POST",
@ -1110,6 +1119,8 @@ function ConfirmStep({
startHole: number startHole: number
holes: 9 | 18 holes: 9 | 18
statLevel: StatLevel statLevel: StatLevel
playFormat: PlayFormat
excludeFromHandicap: boolean
}) => Promise<{ id: string }> }) => Promise<{ id: string }>
onCreated: (roundId: string) => void onCreated: (roundId: string) => void
}) { }) {
@ -1121,9 +1132,18 @@ function ConfirmStep({
const [startHole, setStartHole] = useState("1") const [startHole, setStartHole] = useState("1")
const [holes, setHoles] = useState<9 | 18>(18) const [holes, setHoles] = useState<9 | 18>(18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only") const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke")
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(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) const selectedTee = compatibleTees.find((t) => t.id === teeId)
async function handleStart() { async function handleStart() {
@ -1143,6 +1163,8 @@ function ConfirmStep({
startHole: Number(startHole), startHole: Number(startHole),
holes, holes,
statLevel, statLevel,
playFormat,
excludeFromHandicap,
}) })
onCreated(created.id) onCreated(created.id)
} catch { } catch {
@ -1289,6 +1311,53 @@ function ConfirmStep({
</div> </div>
</fieldset> </fieldset>
{/* Spilleform (ADR-038) -- selvdeklarert, ingen egen match-motor */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Spilleform</legend>
<div className="grid grid-cols-2 gap-3">
{(
[
{ value: "stroke" as const, label: "Slagspill" },
{ value: "match" as const, label: "Matchspill" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => choosePlayFormat(opt.value)}
aria-pressed={playFormat === opt.value}
className={cn(
"flex min-h-[44px] items-center justify-center rounded-2xl border px-4 py-3 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
playFormat === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
{playFormat === "match" && (
<label className="flex min-h-[44px] items-start gap-3 rounded-2xl border border-border bg-card px-5 py-3">
<input
type="checkbox"
checked={excludeFromHandicap}
onChange={(e) => setExcludeFromHandicap(e.target.checked)}
className="mt-1 size-5 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span className="text-base font-bold text-foreground">
Ekskluder denne runden fra mitt faktiske HCP
</span>
<span className="text-sm text-muted-foreground">
Anbefalt for matchspill -- konsederte hull gir ofte et upålitelig grunnlag for
HCP-beregning. Du kan endre dette senere.
</span>
</span>
</label>
)}
</fieldset>
{/* Hole count */} {/* Hole count */}
<fieldset className="flex flex-col gap-3"> <fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Antall hull</legend> <legend className="mb-1 text-base font-semibold text-foreground">Antall hull</legend>

View file

@ -69,6 +69,9 @@ type Player = {
countsForHandicap: boolean countsForHandicap: boolean
scoreDifferential: number | null scoreDifferential: number | null
statLevel: StatLevel statLevel: StatLevel
// ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av
// countsForHandicap (den automatiske kvalifiseringen).
excludeFromHandicap: boolean
} }
type HoleStat = { type HoleStat = {
@ -163,6 +166,7 @@ type ApiParticipant = {
counts_for_handicap: boolean counts_for_handicap: boolean
score_differential: number | null score_differential: number | null
stat_level: StatLevel stat_level: StatLevel
exclude_from_handicap: boolean
} }
type PersonMatch = { type PersonMatch = {
@ -187,6 +191,7 @@ type ApiRound = {
played_at: string played_at: string
start_hole: number start_hole: number
holes_planned: number holes_planned: number
play_format: string
started_at: string | null started_at: string | null
completed_at: string | null completed_at: string | null
participants: ApiParticipant[] participants: ApiParticipant[]
@ -430,6 +435,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
countsForHandicap: p.counts_for_handicap, countsForHandicap: p.counts_for_handicap,
scoreDifferential: p.score_differential, scoreDifferential: p.score_differential,
statLevel: p.stat_level, statLevel: p.stat_level,
excludeFromHandicap: p.exclude_from_handicap,
})) ?? [] })) ?? []
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne // Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
@ -695,6 +701,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{formatTime(round.started_at)} {formatTime(round.started_at)}
</span> </span>
)} )}
{round.play_format === "match" && (
<Badge variant="outline" className="shrink-0">
Matchspill
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>
@ -1542,7 +1553,15 @@ function PlayerList({
const active = player.id === activePlayerId const active = player.id === activePlayerId
const editing = editingPlayerId === player.id const editing = editingPlayerId === player.id
const canRemove = !player.isSelf && !readOnly && canManage 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 ( return (
<div <div
key={player.id} key={player.id}
@ -1628,6 +1647,7 @@ function PlayerList({
<EditParticipantPanel <EditParticipantPanel
roundId={roundId} roundId={roundId}
player={player} player={player}
restricted={!canEditFull}
onPatch={onPatchParticipant} onPatch={onPatchParticipant}
onClose={() => onToggleEdit(player.id)} onClose={() => onToggleEdit(player.id)}
/> />
@ -1921,6 +1941,9 @@ function EditRoundPanel({
const [startHole, setStartHole] = useState(String(round.start_hole)) const [startHole, setStartHole] = useState(String(round.start_hole))
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at)) const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_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 [saving, setSaving] = useState(false)
const [showChangeCourse, setShowChangeCourse] = useState(false) const [showChangeCourse, setShowChangeCourse] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -1929,7 +1952,8 @@ function EditRoundPanel({
name.trim() !== (round.name ?? "") || name.trim() !== (round.name ?? "") ||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) || (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
toDatetimeLocalValue(round.started_at) !== startedAt || 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() { async function saveMetadata() {
setSaving(true) setSaving(true)
@ -1946,6 +1970,7 @@ function EditRoundPanel({
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) { if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
body.completed_at = fromDatetimeLocalValue(completedAt) body.completed_at = fromDatetimeLocalValue(completedAt)
} }
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat
const result = await onPatch(body) const result = await onPatch(body)
if (!result.ok) setError(result.message) if (!result.ok) setError(result.message)
setSaving(false) setSaving(false)
@ -1976,6 +2001,33 @@ function EditRoundPanel({
/> />
</div> </div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Spilleform</span>
<div className="flex gap-2">
{(
[
{ value: "stroke" as const, label: "Slagspill" },
{ value: "match" as const, label: "Matchspill" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setPlayFormat(opt.value)}
aria-pressed={playFormat === opt.value}
className={cn(
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
playFormat === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
</div>
{!isCompleted && ( {!isCompleted && (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -2079,11 +2131,17 @@ function EditRoundPanel({
function EditParticipantPanel({ function EditParticipantPanel({
roundId, roundId,
player, player,
restricted,
onPatch, onPatch,
onClose, onClose,
}: { }: {
roundId: string roundId: string
player: Player 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<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }> onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onClose: () => void onClose: () => void
}) { }) {
@ -2100,10 +2158,12 @@ function EditParticipantPanel({
const [guestName, setGuestName] = useState(player.name) const [guestName, setGuestName] = useState(player.name)
const [gender, setGender] = useState<Gender>(player.gender) const [gender, setGender] = useState<Gender>(player.gender)
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "") const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (restricted) return
let cancelled = false let cancelled = false
fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" }) fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : [])) .then((res) => (res.ok ? res.json() : []))
@ -2116,7 +2176,7 @@ function EditParticipantPanel({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [roundId]) }, [roundId, restricted])
// Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da // 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 // umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket
@ -2128,18 +2188,23 @@ function EditParticipantPanel({
setSaving(true) setSaving(true)
setError(null) setError(null)
const body: Record<string, unknown> = {} const body: Record<string, unknown> = {}
if (teeName !== player.teeName) body.tee_name = teeName if (!restricted) {
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) if (teeName !== player.teeName) body.tee_name = teeName
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
if (statLevel !== player.statLevel) body.stat_level = statLevel if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
if (isGuest) { if (statLevel !== player.statLevel) body.stat_level = statLevel
const trimmedName = guestName.trim() if (isGuest) {
if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName const trimmedName = guestName.trim()
if (gender !== player.gender) body.gender = apiGender if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName
const trimmedEmail = guestEmail.trim() if (gender !== player.gender) body.gender = apiGender
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail const trimmedEmail = guestEmail.trim()
if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail 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) { if (Object.keys(body).length === 0) {
setSaving(false) setSaving(false)
@ -2159,104 +2224,135 @@ function EditParticipantPanel({
<div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5"> <div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5">
{error && <p className="text-sm font-medium text-destructive">{error}</p>} {error && <p className="text-sm font-medium text-destructive">{error}</p>}
{isGuest && ( {restricted && (
<p className="text-sm text-muted-foreground">
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.
</p>
)}
{!restricted && (
<> <>
{isGuest && (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
Navn
</Label>
<Input
id={`edit-guest-name-${player.id}`}
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
<ChoiceRow
label="Kjønn"
options={[
{ value: "male", label: "Mann" },
{ value: "female", label: "Kvinne" },
{ value: "other", label: "Annet" },
]}
value={gender}
onChange={(v) => setGender(v as Gender)}
readOnly={false}
/>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-email-${player.id}`} className="text-sm font-semibold">
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-guest-email-${player.id}`}
type="email"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
placeholder="navn@eksempel.no"
className="h-11 rounded-xl text-base"
/>
</div>
<div className="border-t border-border" />
</>
)}
<p className="text-sm text-muted-foreground">
Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold"> <Label htmlFor={`edit-tee-${player.id}`} className="text-sm font-semibold">
Navn Utslagssted
</Label>
<select
id={`edit-tee-${player.id}`}
value={teeName}
onChange={(e) => setTeeName(e.target.value)}
disabled={teeOptions === null}
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground disabled:opacity-60"
>
{!availableTees.some((t) => t.name === teeName) && <option value={teeName}>{teeName}</option>}
{availableTees.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
</select>
{teeOptions !== null && availableTees.length === 0 && (
<p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet denne banen.</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-hcp-${player.id}`} className="text-sm font-semibold">
HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label> </Label>
<Input <Input
id={`edit-guest-name-${player.id}`} id={`edit-hcp-${player.id}`}
value={guestName} inputMode="decimal"
onChange={(e) => setGuestName(e.target.value)} type="number"
className="h-11 rounded-xl text-base" step="0.1"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
placeholder="F.eks. 18"
className="h-12 rounded-2xl text-base"
/> />
</div> </div>
<ChoiceRow <ChoiceRow
label="Kjønn" label="Statistikk for denne spilleren"
options={[ options={[
{ value: "male", label: "Mann" }, { value: "strokes_only", label: "Kun slag" },
{ value: "female", label: "Kvinne" }, { value: "strokes_and_putts", label: "Slag og putter" },
{ value: "other", label: "Annet" }, { value: "full", label: "All statistikk" },
]} ]}
value={gender} value={statLevel}
onChange={(v) => setGender(v as Gender)} onChange={(v) => setStatLevel(v as StatLevel)}
readOnly={false} readOnly={false}
/> />
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-guest-email-${player.id}`} className="text-sm font-semibold">
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-guest-email-${player.id}`}
type="email"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
placeholder="navn@eksempel.no"
className="h-11 rounded-xl text-base"
/>
</div>
<div className="border-t border-border" />
</> </>
)} )}
<p className="text-sm text-muted-foreground"> {!isGuest && (
Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil. <label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-card px-4 py-3">
</p> <input
type="checkbox"
<div className="flex flex-col gap-2"> checked={excludeFromHandicap}
<Label htmlFor={`edit-tee-${player.id}`} className="text-sm font-semibold"> onChange={(e) => setExcludeFromHandicap(e.target.checked)}
Utslagssted className="mt-1 size-5 shrink-0 accent-primary"
</Label> />
<select <span className="flex flex-col gap-0.5">
id={`edit-tee-${player.id}`} <span className="text-sm font-bold text-foreground">
value={teeName} Ekskluder fra {player.isSelf ? "mitt" : "spillerens"} faktiske HCP
onChange={(e) => setTeeName(e.target.value)} </span>
disabled={teeOptions === null} <span className="text-sm text-muted-foreground">
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground disabled:opacity-60" F.eks. ved matchspill, eller andre runder som ikke bør telle med i HCP-grunnlaget.
> </span>
{!availableTees.some((t) => t.name === teeName) && <option value={teeName}>{teeName}</option>} </span>
{availableTees.map((t) => ( </label>
<option key={t.name} value={t.name}> )}
{t.name}
</option>
))}
</select>
{teeOptions !== null && availableTees.length === 0 && (
<p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet denne banen.</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-hcp-${player.id}`} className="text-sm font-semibold">
HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
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"
/>
</div>
<ChoiceRow
label="Statistikk for denne spilleren"
options={[
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]}
value={statLevel}
onChange={(v) => setStatLevel(v as StatLevel)}
readOnly={false}
/>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" onClick={onClose} className="h-11 rounded-xl px-5 text-sm font-semibold"> <Button type="button" variant="ghost" onClick={onClose} className="h-11 rounded-xl px-5 text-sm font-semibold">

View file

@ -1,18 +1,27 @@
"use client" "use client"
// Aggregert statistikk på tvers av "Egne runder" (ADR-033), lenket fra // Aggregert statistikk på tvers av "Egne runder" (ADR-033), lenket fra
// dashbordets "Statistikk"-seksjon (2026-07-28, utvidet samme dag med // dashbordets "Statistikk"-seksjon. Egen backend-aggregering
// tidsvindu + miss-retning + forrige-periode-sammenligning). Egen backend- // (GET /rounds/stats/summary) -- ikke bare klientside-utledning av
// aggregering (GET /rounds/stats/summary) -- ikke bare klientside-utledning // round-stats.tsx sin logikk, siden putt/18-hull-regelen (padding av
// av round-stats.tsx sin logikk, siden putt/18-hull-regelen (padding av
// uspilte hull til 2 putter, KUN for dette tallet) og "forrige periode"- // uspilte hull til 2 putter, KUN for dette tallet) og "forrige periode"-
// vinduet må regnes over ALLE fullførte runder samlet, ikke én om gangen. // vinduet må regnes over ALLE fullførte runder samlet, ikke én om gangen.
//
// Visuell versjon 2 (2026-07-28): brukeren delte et referansebilde av en
// konkurrent-app sin statistikkskjerm (donuter med sentertall, kompass-
// diagram for miss-retning) og ba om noe tilsvarende -- håndkodet direkte
// (ingen V0-credits igjen), verifisert med ekte skjermbilder via Chrome
// DevTools MCP i stedet for kun typesjekk, se CLAUDE.md. Inspirert av
// referansen, ikke kopiert: egne farger/ikonografi, ingen flyt-diagram for
// scrambling/sand save (ville antydet en forgrening dataene ikke har -- det
// er to uavhengige prosenter, ikke en beslutningstre), ingen trendlinje
// ennå (for lite rundehistorikk til å være meningsfullt, se CLAUDE.md).
import type React from "react" import type React from "react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, BarChart3, Flag, Target, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react" import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BarChart3, Target, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react"
type ApiStatsSummary = { type ApiStatsSummary = {
rounds_completed: number rounds_completed: number
@ -31,12 +40,23 @@ type ApiStatsSummary = {
green_miss_right_pct: number | null green_miss_right_pct: number | null
green_miss_tracked_holes: number green_miss_tracked_holes: number
one_putt_pct: number | null one_putt_pct: number | null
putt_dist_one_pct: number | null
putt_dist_two_pct: number | null
putt_dist_three_plus_pct: number | null
scrambling_pct: number | null scrambling_pct: number | null
sand_save_pct: number | null sand_save_pct: number | null
avg_chip_per_round: number | null avg_chip_per_round: number | null
avg_bunker_per_round: number | null avg_bunker_per_round: number | null
avg_penalty_per_round: number | null avg_penalty_per_round: number | null
avg_anyway_per_round: number | null avg_anyway_per_round: number | null
avg_to_par_with_gir: number | null
avg_to_par_without_gir: number | null
avg_to_par_fairway_hit: number | null
avg_to_par_fairway_miss: number | null
avg_to_par_with_bunker: number | null
avg_to_par_without_bunker: number | null
avg_to_par_with_anyway: number | null
avg_to_par_without_anyway: number | null
} }
type ApiWindowSummary = { type ApiWindowSummary = {
@ -123,6 +143,18 @@ function StatTile({
) )
} }
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
<div>
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
{children}
</section>
)
}
function Section({ function Section({
title, title,
icon: Icon, icon: Icon,
@ -143,19 +175,164 @@ function Section({
) )
} }
// Horisontal fordelingsbar med retningsandeler (venstre/høyre eller // --- Donut: sentertall + inntil 4 fargede segmenter (conic-gradient) -------
// lang/kort/venstre/høyre) -- pooler kun faktisk registrerte bom. // Gjenbrukt for BÅDE puttfordelingen (3 segmenter) og de enkle rednings-
function MissBar({ // gaugene (1 segment fylt + resten "spor"). Prosent er ALLTID skrevet ut i
tracked, // en forklaringsliste under -- fargen bærer aldri betydningen alene.
function Donut({
segments, segments,
centerValue,
centerLabel,
size = 132,
thickness = 18,
showLegend = true,
}: {
segments: { label: string; value: number; color: string }[]
centerValue: string
centerLabel: string
size?: number
thickness?: number
showLegend?: boolean
}) {
let cursor = 0
const stops: string[] = []
for (const seg of segments) {
const start = cursor
const end = cursor + seg.value
stops.push(`${seg.color} ${start}% ${end}%`)
cursor = end
}
if (cursor < 100) stops.push(`var(--muted) ${cursor}% 100%`)
const gradient = `conic-gradient(${stops.join(", ")})`
const ring = (
<div
className="relative shrink-0 rounded-full"
style={{ width: size, height: size, background: gradient }}
role="img"
aria-label={`${centerLabel}: ${centerValue}. ${segments.map((s) => `${s.label} ${Math.round(s.value)} prosent`).join(", ")}`}
>
<div
className="absolute rounded-full bg-card"
style={{
width: size - thickness * 2,
height: size - thickness * 2,
top: thickness,
left: thickness,
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-extrabold tabular-nums text-foreground">{centerValue}</span>
<span className="text-[11px] font-semibold text-muted-foreground">{centerLabel}</span>
</div>
</div>
)
if (!showLegend) return ring
return (
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-center">
{ring}
<ul className="flex flex-1 flex-col gap-1.5">
{segments.map((s) => (
<li key={s.label} className="flex items-center gap-2 text-sm text-muted-foreground">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: s.color }} />
<span className="flex-1">{s.label}</span>
<span className="font-bold tabular-nums text-foreground">{Math.round(s.value)} %</span>
</li>
))}
</ul>
</div>
)
}
// --- Kompass-diagram for greentreff-miss-retning ----------------------------
// Greentreff i midten, langt over, kort under, venstre til venstre, høyre
// til høyre -- speiler hvor bommen faktisk gikk relativt til green, i
// stedet for en flat fordelingsbar. Alle fire retningsceller viser tallet
// sitt som tekst uansett -- retning er beskrivende, ikke god/dårlig, så
// ingen av dem fargekodes grønn/oransje (kun sentercellen, som ER et
// treff/bom-tall, får grønn fremheving).
function CompassCell({ icon: Icon, label, value }: { icon: typeof ArrowUp; label: string; value: number | null }) {
return (
<div className="flex min-h-16 flex-col items-center justify-center gap-0.5 rounded-2xl border border-border bg-card p-2">
<Icon aria-hidden="true" className="size-4 text-muted-foreground" />
<span className="text-lg font-extrabold tabular-nums text-foreground">{value !== null ? `${Math.round(value)} %` : ""}</span>
<span className="text-[11px] font-semibold text-muted-foreground">{label}</span>
</div>
)
}
function GirCompass({
hitPct,
trackedHoles,
missTrackedHoles,
long,
short,
left,
right,
}: {
hitPct: number | null
trackedHoles: number
missTrackedHoles: number
long: number | null
short: number | null
left: number | null
right: number | null
}) {
if (hitPct === null) {
return <p className="text-sm text-muted-foreground">Ingen greentreff-data registrert ennå.</p>
}
return (
<div className="flex flex-col items-center gap-3">
<div className="grid w-full max-w-[280px] grid-cols-3 grid-rows-3 gap-2.5">
<div aria-hidden="true" />
<CompassCell icon={ArrowUp} label="Langt" value={long} />
<div aria-hidden="true" />
<CompassCell icon={ArrowLeft} label="Venstre" value={left} />
<div className="flex flex-col items-center justify-center gap-0.5 rounded-2xl border-2 border-primary bg-primary/15 p-2">
<Target aria-hidden="true" className="size-5 text-primary" />
<span className="text-2xl font-extrabold tabular-nums text-foreground">{Math.round(hitPct)} %</span>
<span className="text-[11px] font-semibold text-muted-foreground">Greentreff</span>
</div>
<CompassCell icon={ArrowRight} label="Høyre" value={right} />
<div aria-hidden="true" />
<CompassCell icon={ArrowDown} label="Kort" value={short} />
<div aria-hidden="true" />
</div>
<p className="text-center text-xs text-muted-foreground">
{trackedHoles} hull registrert
{missTrackedHoles > 0 && `, ${missTrackedHoles} bom med retning`}
</p>
</div>
)
}
// --- Fairway: enklere venstre/fairway/høyre-bar (ingen langt/kort-akse
// fra utslaget, så ikke et fullt kompass) --------------------------------
function FairwayBar({
tracked,
left,
hit,
right,
}: { }: {
tracked: number tracked: number
segments: { label: string; value: number | null; color: string }[] left: number | null
hit: number | null
right: number | null
}) { }) {
if (tracked === 0) return <p className="text-sm text-muted-foreground">Ingen retning registrert ennå.</p> if (tracked === 0) return <p className="text-sm text-muted-foreground">Ingen utslag registrert ennå.</p>
const segments = [
{ label: "Venstre", value: left, color: "var(--brand-orange)" },
{ label: "Fairway", value: hit, color: "var(--primary)" },
{ label: "Høyre", value: right, color: "var(--brand-orange)" },
]
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted"> <div className="flex h-4 w-full overflow-hidden rounded-full bg-muted">
{segments.map((s) => {segments.map((s) =>
s.value && s.value > 0 ? ( s.value && s.value > 0 ? (
<div key={s.label} style={{ width: `${s.value}%`, backgroundColor: s.color }} title={`${s.label}: ${Math.round(s.value)} %`} /> <div key={s.label} style={{ width: `${s.value}%`, backgroundColor: s.color }} title={`${s.label}: ${Math.round(s.value)} %`} />
@ -174,6 +351,54 @@ function MissBar({
) )
} }
// --- Til par: med vs. uten en gitt hendelse på hullet -----------------------
// Brukerens eksplisitte instruks 2026-07-28: se snitt til par NÅR noe
// inntraff mot NÅR det ikke gjorde det (greentreff, fairwaytreff, bunker,
// anywayslag), i stedet for bare en isolert prosentandel. Fargen følger
// hvilken av de to som FAKTISK var lavest denne perioden (ikke en fast
// antakelse om hvilken side som "skal" være best), siden det er selve
// sammenligningen som er poenget.
function CompareToPar({
title,
aLabel,
aValue,
bLabel,
bValue,
}: {
title: string
aLabel: string
aValue: number | null
bLabel: string
bValue: number | null
}) {
if (aValue === null && bValue === null) {
return (
<div>
<h3 className="text-sm font-bold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">Ingen data registrert ennå.</p>
</div>
)
}
const aBetter = aValue !== null && bValue !== null && aValue < bValue
const bBetter = aValue !== null && bValue !== null && bValue < aValue
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-bold text-foreground">{title}</h3>
<div className="grid grid-cols-2 gap-3">
<div className={`rounded-xl border p-3 ${aBetter ? "border-primary bg-primary/10" : "border-border bg-card"}`}>
<span className="text-xs font-semibold text-muted-foreground">{aLabel}</span>
<div className="text-xl font-extrabold tabular-nums text-foreground">{aValue !== null ? signed(aValue) : ""}</div>
</div>
<div className={`rounded-xl border p-3 ${bBetter ? "border-primary bg-primary/10" : "border-border bg-card"}`}>
<span className="text-xs font-semibold text-muted-foreground">{bLabel}</span>
<div className="text-xl font-extrabold tabular-nums text-foreground">{bValue !== null ? signed(bValue) : ""}</div>
</div>
</div>
</div>
)
}
export function RoundsStatsSummary() { export function RoundsStatsSummary() {
const router = useRouter() const router = useRouter()
const [window, setWindow] = useState<WindowKey>("all") const [window, setWindow] = useState<WindowKey>("all")
@ -219,7 +444,7 @@ export function RoundsStatsSummary() {
</div> </div>
</header> </header>
<main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-8 px-4 py-6 pb-16"> <main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-6 px-4 py-6 pb-16">
<div className="flex gap-2 overflow-x-auto pb-1"> <div className="flex gap-2 overflow-x-auto pb-1">
{WINDOW_OPTIONS.map((opt) => ( {WINDOW_OPTIONS.map((opt) => (
<button <button
@ -263,128 +488,170 @@ export function RoundsStatsSummary() {
const s = data.current const s = data.current
const p = data.previous const p = data.previous
return ( return (
<> <>
<p className="text-base leading-relaxed text-muted-foreground text-pretty"> <p className="text-base leading-relaxed text-muted-foreground text-pretty">
Aggregert over {s.rounds_completed} {s.rounds_completed === 1 ? "fullført runde" : "fullførte runder"} Aggregert over {s.rounds_completed} {s.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}
{p.rounds_completed > 0 && ( {p.rounds_completed > 0 && (
<> <>
{" "} {" "}
· sammenlignet med {p.rounds_completed} {p.rounds_completed === 1 ? "runde" : "runder"} i perioden før · sammenlignet med {p.rounds_completed} {p.rounds_completed === 1 ? "runde" : "runder"} i perioden før
</> </>
)} )}
. .
</p> </p>
<Section title="Oversikt" icon={BarChart3}> <Section title="Oversikt" icon={BarChart3}>
<StatTile label="Runder fullført" value={String(s.rounds_completed)} /> <StatTile label="Runder fullført" value={String(s.rounds_completed)} />
<StatTile <StatTile
label="Snitt til par" label="Snitt til par"
value={s.avg_score_to_par !== null ? signed(s.avg_score_to_par) : ""} value={s.avg_score_to_par !== null ? signed(s.avg_score_to_par) : ""}
delta={<Delta current={s.avg_score_to_par} previous={p.avg_score_to_par} format={signed} goodDirection="down" />} delta={<Delta current={s.avg_score_to_par} previous={p.avg_score_to_par} format={signed} goodDirection="down" />}
/> />
<StatTile <StatTile
label="Putt / 18 hull" label="Putt / 18 hull"
value={s.avg_putts_per_18 !== null ? s.avg_putts_per_18.toFixed(1).replace(".", ",") : ""} value={s.avg_putts_per_18 !== null ? s.avg_putts_per_18.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_putts_per_18} previous={p.avg_putts_per_18} format={signed} goodDirection="down" />} delta={<Delta current={s.avg_putts_per_18} previous={p.avg_putts_per_18} format={signed} goodDirection="down" />}
hint={ hint={
s.putts_tracked_rounds > 0 s.putts_tracked_rounds > 0
? `${s.putts_tracked_rounds} ${s.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing` ? `${s.putts_tracked_rounds} ${s.putts_tracked_rounds === 1 ? "runde med" : "runder med"} puttsporing`
: "Ingen runder med puttsporing ennå" : "Ingen runder med puttsporing ennå"
} }
/> />
</Section> </Section>
<Section title="Fairway og green" icon={Flag}> <Card title="Greentreff (GIR)" subtitle="Hvor treffer og bommer du innspillet, sett fra green?">
<StatTile <GirCompass
label="Fairwaytreff" hitPct={s.gir_pct}
value={s.fairway_hit_pct !== null ? pct(s.fairway_hit_pct) : ""} trackedHoles={s.gir_tracked_holes}
delta={<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />} missTrackedHoles={s.green_miss_tracked_holes}
hint={s.fairway_tracked_holes > 0 ? `${s.fairway_tracked_holes} hull registrert` : undefined} long={s.green_miss_long_pct}
/> short={s.green_miss_short_pct}
<StatTile left={s.green_miss_left_pct}
label="Greentreff (GIR)" right={s.green_miss_right_pct}
value={s.gir_pct !== null ? pct(s.gir_pct) : ""} />
delta={<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />} <div className="flex items-center justify-between border-t border-border pt-3">
hint={s.gir_tracked_holes > 0 ? `${s.gir_tracked_holes} hull registrert` : undefined} <span className="text-sm font-semibold text-muted-foreground">vs. forrige periode</span>
/> <Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />
<StatTile </div>
label="Én-putt" </Card>
value={s.one_putt_pct !== null ? pct(s.one_putt_pct) : ""}
delta={<Delta current={s.one_putt_pct} previous={p.one_putt_pct} format={signedPoints} goodDirection="up" />}
/>
</Section>
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <Card title="Fra utslaget" subtitle="Kun par 4/5, kun registrerte utslag.">
<div> <FairwayBar tracked={s.fairway_tracked_holes} left={s.fairway_left_pct} hit={s.fairway_hit_pct} right={s.fairway_right_pct} />
<h3 className="text-sm font-bold text-foreground">Hvor bommer du fra utslaget?</h3> <div className="flex items-center justify-between border-t border-border pt-3">
<p className="text-xs text-muted-foreground">Kun par 4/5, kun registrerte utslag.</p> <span className="text-sm font-semibold text-muted-foreground">Fairwaytreff vs. forrige periode</span>
</div> <Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />
<MissBar </div>
tracked={s.fairway_tracked_holes} </Card>
segments={[
{ label: "Venstre", value: s.fairway_left_pct, color: "var(--chart-5)" },
{ label: "Fairway", value: s.fairway_hit_pct, color: "var(--primary)" },
{ label: "Høyre", value: s.fairway_right_pct, color: "var(--chart-4)" },
]}
/>
<div>
<h3 className="text-sm font-bold text-foreground">Hvor bommer du innspillet?</h3>
<p className="text-xs text-muted-foreground">Kun hull der greentreff ble bommet og retning registrert.</p>
</div>
<MissBar
tracked={s.green_miss_tracked_holes}
segments={[
{ label: "Langt", value: s.green_miss_long_pct, color: "var(--chart-5)" },
{ label: "Kort", value: s.green_miss_short_pct, color: "var(--chart-4)" },
{ label: "Venstre", value: s.green_miss_left_pct, color: "var(--chart-6)" },
{ label: "Høyre", value: s.green_miss_right_pct, color: "var(--info)" },
]}
/>
</div>
<Section title="Redning" icon={Target}> <Card title="Putting">
<StatTile <Donut
label="Scrambling" centerValue={s.avg_putts_per_18 !== null ? s.avg_putts_per_18.toFixed(1).replace(".", ",") : ""}
value={s.scrambling_pct !== null ? pct(s.scrambling_pct) : ""} centerLabel="putt / 18 hull"
delta={<Delta current={s.scrambling_pct} previous={p.scrambling_pct} format={signedPoints} goodDirection="up" />} segments={
hint="Par eller bedre uten GIR" s.putt_dist_one_pct !== null
/> ? [
<StatTile { label: "1-putt", value: s.putt_dist_one_pct ?? 0, color: "var(--primary)" },
label="Sand save" { label: "2-putt", value: s.putt_dist_two_pct ?? 0, color: "var(--info)" },
value={s.sand_save_pct !== null ? pct(s.sand_save_pct) : ""} { label: "3-putt+", value: s.putt_dist_three_plus_pct ?? 0, color: "var(--brand-orange)" },
delta={<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />} ]
hint="Par eller bedre fra bunker" : []
/> }
</Section> />
{s.putt_dist_one_pct === null && <p className="text-sm text-muted-foreground">Ingen puttfordeling registrert ennå.</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<span className="text-sm font-semibold text-muted-foreground">Én-putt vs. forrige periode</span>
<Delta current={s.one_putt_pct} previous={p.one_putt_pct} format={signedPoints} goodDirection="up" />
</div>
</Card>
<Section title="Chip, bunker og straffeslag" icon={Waves}> <Card title="Redning" subtitle="Par eller bedre etter en bom.">
<StatTile <div className="grid grid-cols-2 gap-4">
label="Chip / runde" <div className="flex flex-col items-center gap-2">
value={s.avg_chip_per_round !== null ? s.avg_chip_per_round.toFixed(1).replace(".", ",") : ""} <Donut
delta={<Delta current={s.avg_chip_per_round} previous={p.avg_chip_per_round} format={signed} goodDirection="down" />} size={104}
/> thickness={14}
<StatTile showLegend={false}
label="Bunkerslag / runde" centerValue={s.scrambling_pct !== null ? `${Math.round(s.scrambling_pct)}%` : ""}
value={s.avg_bunker_per_round !== null ? s.avg_bunker_per_round.toFixed(1).replace(".", ",") : ""} centerLabel=""
delta={<Delta current={s.avg_bunker_per_round} previous={p.avg_bunker_per_round} format={signed} goodDirection="down" />} segments={s.scrambling_pct !== null ? [{ label: "Scrambling", value: s.scrambling_pct, color: "var(--primary)" }] : []}
/> />
<StatTile <span className="text-sm font-bold text-foreground">Scrambling</span>
label="Straffeslag / runde" <Delta current={s.scrambling_pct} previous={p.scrambling_pct} format={signedPoints} goodDirection="up" />
value={s.avg_penalty_per_round !== null ? s.avg_penalty_per_round.toFixed(1).replace(".", ",") : ""} </div>
delta={<Delta current={s.avg_penalty_per_round} previous={p.avg_penalty_per_round} format={signed} goodDirection="down" />} <div className="flex flex-col items-center gap-2">
/> <Donut
</Section> size={104}
thickness={14}
showLegend={false}
centerValue={s.sand_save_pct !== null ? `${Math.round(s.sand_save_pct)}%` : ""}
centerLabel=""
segments={s.sand_save_pct !== null ? [{ label: "Sand save", value: s.sand_save_pct, color: "var(--primary)" }] : []}
/>
<span className="text-sm font-bold text-foreground">Sand save</span>
<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />
</div>
</div>
</Card>
{s.avg_anyway_per_round !== null && ( <Card title="Til par: med vs. uten" subtitle="Snitt til par på enkelthull, splittet på om noe faktisk inntraff.">
<Section title="Annet" icon={Wind}> <CompareToPar
<StatTile title="Greentreff (GIR)"
label="Anywayslag / runde" aLabel="Med GIR"
value={s.avg_anyway_per_round.toFixed(1).replace(".", ",")} aValue={s.avg_to_par_with_gir}
delta={<Delta current={s.avg_anyway_per_round} previous={p.avg_anyway_per_round} format={signed} goodDirection="down" />} bLabel="Uten GIR"
/> bValue={s.avg_to_par_without_gir}
</Section> />
)} <CompareToPar
</> title="Fairwaytreff"
aLabel="Traff fairway"
aValue={s.avg_to_par_fairway_hit}
bLabel="Bom fairway"
bValue={s.avg_to_par_fairway_miss}
/>
<CompareToPar
title="Bunker"
aLabel="I bunker"
aValue={s.avg_to_par_with_bunker}
bLabel="Ikke i bunker"
bValue={s.avg_to_par_without_bunker}
/>
<CompareToPar
title="Anywayslag"
aLabel="Med anywayslag"
aValue={s.avg_to_par_with_anyway}
bLabel="Uten anywayslag"
bValue={s.avg_to_par_without_anyway}
/>
</Card>
<Section title="Chip, bunker og straffeslag" icon={Waves}>
<StatTile
label="Chip / runde"
value={s.avg_chip_per_round !== null ? s.avg_chip_per_round.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_chip_per_round} previous={p.avg_chip_per_round} format={signed} goodDirection="down" />}
/>
<StatTile
label="Bunkerslag / runde"
value={s.avg_bunker_per_round !== null ? s.avg_bunker_per_round.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_bunker_per_round} previous={p.avg_bunker_per_round} format={signed} goodDirection="down" />}
/>
<StatTile
label="Straffeslag / runde"
value={s.avg_penalty_per_round !== null ? s.avg_penalty_per_round.toFixed(1).replace(".", ",") : ""}
delta={<Delta current={s.avg_penalty_per_round} previous={p.avg_penalty_per_round} format={signed} goodDirection="down" />}
/>
</Section>
{s.avg_anyway_per_round !== null && (
<Section title="Annet" icon={Wind}>
<StatTile
label="Anywayslag / runde"
value={s.avg_anyway_per_round.toFixed(1).replace(".", ",")}
delta={<Delta current={s.avg_anyway_per_round} previous={p.avg_anyway_per_round} format={signed} goodDirection="down" />}
/>
</Section>
)}
</>
) )
})() })()
) : null} ) : null}