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:
parent
99b71ab97b
commit
997f3c40ed
10 changed files with 1411 additions and 247 deletions
39
030_actual_handicap_index.sql
Normal file
39
030_actual_handicap_index.sql
Normal 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'));
|
||||
|
|
@ -3352,6 +3352,136 @@ prinsipp som ADR-005) + én ny CHECK-verdi, ikke en skjemaendring.
|
|||
|
||||
---
|
||||
|
||||
## ADR-038: Faktisk (beregnet) HCP vs. manuelt satt HCP
|
||||
|
||||
**Kontekst:** reist 2026-07-28, rett etter en gjennomgang av frittstående
|
||||
rundeføring (ADR-033) som avdekket et reelt, ubesluttet hull: hele
|
||||
WHS-indeksmotoren (`handicap_index_from_differentials`/
|
||||
`low_handicap_index`/`apply_index_caps` i `handicap_engine.py`, testet
|
||||
41/41) kalles ALDRI fra noe API-endepunkt — `round_participant.
|
||||
score_differential` regnes og lagres per runde, men `app_user.
|
||||
handicap_index` endres kun manuelt (`PATCH /auth/profile`). Bekreftet
|
||||
eksplisitt i `rounds.py` sin egen moduldoc som "eksplisitt uavklart punkt
|
||||
i ADR-033".
|
||||
|
||||
Brukeren avklarte at TeeCup skal operere med TO tall, ikke ett:
|
||||
|
||||
### Beslutning A — To atskilte HCP-verdier
|
||||
|
||||
- **Manuelt satt HCP** (eksisterende `app_user.handicap_index`,
|
||||
ADR-031) — UENDRET betydning og bruk: dette er tallet som brukes til
|
||||
Course Handicap-beregning i enhver runde/deltaker med mindre
|
||||
eksplisitt overstyrt per runde (allerede slik i dag via
|
||||
`handicap_index_snapshot`). Redigeres fritt av brukeren selv, som i
|
||||
dag.
|
||||
- **Faktisk HCP** (ny `app_user.computed_handicap_index` +
|
||||
`computed_handicap_index_updated_at`) — WHS Handicap Index (Rule 5.2),
|
||||
regnet automatisk fra beste 8 av de ≤20 nyeste TELLENDE Score
|
||||
Differential-ene, med den offisielle opptrappingstabellen for færre
|
||||
enn 20 runder (`_INDEX_TABLE_UNDER_20`, allerede implementert og
|
||||
testet — ingen ny motorkode trengs, kun at den faktisk kalles). Aldri
|
||||
direkte redigerbar — kun avledet.
|
||||
|
||||
### Beslutning B — Uttrykkelig "overfør til manuelt HCP"-handling
|
||||
|
||||
Golferen får en egen, eksplisitt knapp ("Bruk som mitt HCP") i
|
||||
`/account` som kopierer gjeldende `computed_handicap_index` inn i
|
||||
`handicap_index` (samme skrivevei/historikk-logging som en vanlig
|
||||
manuell PATCH). INGEN automatisk synkronisering noen vei — de to
|
||||
tallene lever bevisst uavhengig av hverandre helt til brukeren selv
|
||||
trykker knappen.
|
||||
|
||||
### Beslutning C — Eksplisitt eksklusjon per deltaker, kontrollert av HVER innlogget deltaker selv
|
||||
|
||||
Ny `round_participant.exclude_from_handicap boolean DEFAULT false` —
|
||||
uavhengig av `counts_for_handicap` (som fortsatt er den AUTOMATISKE
|
||||
WHS-kvalifiseringen, minimum spilte hull). Endelig medregning i faktisk
|
||||
HCP krever BEGGE: `counts_for_handicap AND NOT exclude_from_handicap`.
|
||||
|
||||
**Avklart eksplisitt med bruker (AskUserQuestion), IKKE gjettet:** hver
|
||||
INNLOGGET deltaker (eier ELLER en lenket medspiller, ADR-036 fase 3)
|
||||
styrer SIN EGEN rads eksklusjon — ikke bare runde-eieren. Autorisasjon i
|
||||
`PATCH .../participants/{id}` utvidet presist til akkurat dette ene
|
||||
feltet: en ikke-eier kan KUN sende `exclude_from_handicap` (ethvert
|
||||
annet felt i samme kall avvises 403) og KUN på sin egen rad (`rp.user_id
|
||||
== requester`) — alle andre felt (tee/HCP/kjønn/navn/stat_level) forblir
|
||||
strengt eier-only, uendret. Feltet er bevisst IKKE en del av
|
||||
`rating_changed`-sperren i `update_participant` — kan endres uansett
|
||||
fullført-status, siden det ikke påvirker AGS/differensial-matematikken
|
||||
i seg selv, kun om resultatet TELLER i det HELE tatt.
|
||||
|
||||
### Beslutning D — Spilleform (slagspill/matchspill), selvdeklarert
|
||||
|
||||
Ny `round.play_format text DEFAULT 'stroke' CHECK (IN ('stroke',
|
||||
'match'))`. Frittstående runder har i dag INGEN uavhengig måte å
|
||||
"oppdage" matchspill på (ingen egen match-motor, ulikt turnering-siden)
|
||||
— feltet er derfor selvdeklarert av brukeren ved oppsett (og fritt
|
||||
redigerbart senere, som ren metadata, uendret av fullført-status).
|
||||
|
||||
**WHS-kilde lest og lagt til grunn** (`WHS_Rules_of_Handicapping_2024.
|
||||
pdf`, Rule 3.3 "When a Hole is Started But Player Does Not Hole Out"):
|
||||
matchspill-scorer ER teknisk et gyldig HCP-grunnlag under WHS (Rule
|
||||
2.1a), MEN et hull som avgjøres/konsederes før utspilt krever en
|
||||
subjektiv "most likely score" (avgrenset til netto dobbel bogey) — noe
|
||||
TeeCups rene tallregistrering ikke har noen UI-vei til å representere
|
||||
presist. Dette begrunner brukerens instinkt ("man bruker vanligvis ikke
|
||||
matchspill til å beregne hcp") uten å hardkode et forbud: WHS tillater
|
||||
det teknisk, men TeeCup kan ikke garantere et pålitelig grunnlag for
|
||||
det i dag.
|
||||
|
||||
**Derfor: spør, ikke tving.** Når `play_format = 'match'` velges (ved
|
||||
opprettelse, ELLER ved senere tillegg av en lenket medspiller), foreslår
|
||||
frontend `exclude_from_handicap = true` som FORHÅNDSVALGT verdi med en
|
||||
forklarende tekst — brukeren kan uansett overstyre til `false` (f.eks.
|
||||
en fullstendig utspilt vennskapelig 18-hulls "matchspill" der alle hull
|
||||
faktisk ble spilt ut). Ingen server-side tvang — `play_format` og
|
||||
`exclude_from_handicap` er to uavhengige felt i skjemaet; kun
|
||||
frontendens forhåndsutfylling kobler dem sammen.
|
||||
|
||||
### Beslutning E — Datagrunnlag: designet for BEGGE kilder, bygget for én
|
||||
|
||||
**Avklart eksplisitt med bruker:** "faktisk HCP" skal på sikt kunne
|
||||
telle scorer fra BÅDE frittstående runder OG organisasjons-/
|
||||
turneringsscoring, men v1 bygger KUN kilden for frittstående runder.
|
||||
Løst med ett tynt, bevisst uabstrahert skjøtepunkt — IKKE en ny,
|
||||
generell "scoring record"-tabell (ville vært for tidlig abstraksjon for
|
||||
en kilde som ikke finnes ennå, se CLAUDE.md sin regel mot design for
|
||||
hypotetiske fremtidige krav): en enkelt funksjon
|
||||
`_gather_qualifying_differentials(conn, user_id)` i `rounds.py` henter
|
||||
i dag KUN fra `round_participant JOIN round`, med en kommentar som
|
||||
peker ut nøyaktig dette som skjøtepunktet for en fremtidig UNION mot
|
||||
turnering-siden (som uansett trenger sin egen ADR-037-baserte
|
||||
poeng-/differensial-motor FØRST, se ADR-037 Beslutning D).
|
||||
|
||||
### Beslutning F — Low Handicap Index / cap-historikk gjenbruker `handicap_history`, ikke en ny tabell
|
||||
|
||||
Rule 5.7 (Low Handicap Index, laveste indeks i 365 dager) og Rule 5.8
|
||||
(soft/hard cap) krever en historikk av TIDLIGERE beregnede indeksverdier
|
||||
å sammenligne mot — samme grunnform som den eksisterende
|
||||
`handicap_history`-tabellen (ADR-031-oppfølging, migrasjon 018), men
|
||||
den logger i dag KUN manuelle profilendringer. Løst med én ny kolonne
|
||||
`handicap_history.source text DEFAULT 'manual' CHECK (IN ('manual',
|
||||
'computed'))` fremfor en parallell tabell — samme tabell, to kilder,
|
||||
`low_handicap_index()` filtrerer på `source = 'computed'`. Det aller
|
||||
første beregnede indekstallet for en bruker har ingen tidligere
|
||||
`computed`-historikk å låses mot — capping hoppes bevisst over da
|
||||
(Rule 5.8 sitt "Low HI" er per definisjon udefinert før en første indeks
|
||||
er etablert), ikke behandlet som en feil.
|
||||
|
||||
### Konsekvens — hva denne ADR-en IKKE dekker
|
||||
|
||||
- Turnering-/organisasjonsscoring teller fortsatt IKKE mot faktisk HCP
|
||||
(Beslutning E) — venter på ADR-037s videre arbeid.
|
||||
- Ingen automatisk periodisk "aging" av gamle differensialer utover de
|
||||
20 nyeste (Rule 5.5) — løst implisitt ved at spørringen alltid henter
|
||||
KUN de 20 nyeste, ikke en egen bakgrunnsjobb.
|
||||
- Stableford-format for frittstående runder (se FEATURE_BACKLOG.md) er
|
||||
fortsatt ikke bygget — påvirker ikke denne ADR-en, siden Score
|
||||
Differential regnes fra Adjusted Gross Score uavhengig av
|
||||
poengformat.
|
||||
|
||||
---
|
||||
|
||||
## Åpne spørsmål (ikke besluttet ennå)
|
||||
|
||||
Disse må avklares før eller under de relevante fasene:
|
||||
|
|
|
|||
211
CLAUDE.md
211
CLAUDE.md
|
|
@ -3871,6 +3871,205 @@ Ferdig og verifisert:
|
|||
`/health` → 200, anonymt `GET /rounds/stats/summary?window=last_5` → 401
|
||||
(bekrefter query-parameteren ruter riktig gjennom Caddy/rewrites),
|
||||
`teeoff.no` upåvirket.
|
||||
- **Statistikk-siden: visuell v2 (kompass-diagram + donuter), HÅNDKODET og
|
||||
BROWSERVERIFISERT MED EKTE ITERASJON, LIVE (2026-07-28), samme dag:**
|
||||
brukeren delte et referansebilde av en konkurrent-app sin statistikk-
|
||||
skjerm (donuter med sentertall, kompass for miss-retning) og ba om noe
|
||||
tilsvarende. Skrev først et fullt V0-prompt (data-kontrakt, komponent-
|
||||
for-komponent) — brukeren var tom for V0-credits og spurte eksplisitt om
|
||||
jeg kunne "gi promptet til meg selv" og bygge det direkte, siden Chrome
|
||||
DevTools MCP nå gjør det mulig å faktisk SE resultatet underveis (ulikt
|
||||
tidligere håndkodede runder denne uken som kun var typesjekket).
|
||||
**Backend:** tre nye felt i `RoundStatsSummary`/`_summarize_rounds`
|
||||
(`app/routers/rounds.py`) — `putt_dist_one_pct`/`two_pct`/
|
||||
`three_plus_pct`, EGNE eksakte bøtter (`putts==1`/`==2`/`>=3`, samme som
|
||||
`round-stats.tsx` sin `puttCategories`), bevisst forskjellig fra det
|
||||
allerede eksisterende `one_putt_pct` (som bruker `<=1` — en annen,
|
||||
allerede etablert rate, ikke en distribusjon).
|
||||
**Frontend, `rounds-stats-summary.tsx` skrevet om betydelig:** ny
|
||||
`GirCompass` — greentreff-prosenten i midten (grønn fremheving), fire
|
||||
retningsceller rundt (Langt=topp, Kort=bunn, Venstre=venstre,
|
||||
Høyre=høyre), samme visuelle språk som den allerede etablerte
|
||||
`DirectionCross`/`DirButton` fra `round-detail.tsx` (plusstegn-rutenett,
|
||||
`min-h-16 rounded-2xl border`-celler) — bevisst IKKE fargekodet
|
||||
grønn/oransje på retningscellene (retning er beskrivende, ikke god/
|
||||
dårlig), kun sentercellen. Ny gjenbrukbar `Donut`-komponent
|
||||
(conic-gradient, sentertall + valgfri forklaringsliste via
|
||||
`showLegend`) brukt til BÅDE puttfordelingen (3 segmenter: 1-putt/
|
||||
2-putt/3-putt+) og to enkle rednings-gauger (scrambling/sand save, ett
|
||||
segment). Bevisst IKKE et flyt-/beslutningstre-diagram for scrambling/
|
||||
sand save (som referansebildet hadde) — det er to uavhengige prosenter i
|
||||
datamodellen, ikke en forgrening, et flytdiagram ville antydet en
|
||||
struktur som ikke finnes. Bevisst INGEN trendlinje (referansebildets
|
||||
fjerde element) — for lite rundehistorikk til å vise noe meningsfullt
|
||||
ennå, samme vurdering som tidligere samme dag.
|
||||
**Reelt funn UNDER selve visuell iterasjon, ikke bare kodegjennomgang:**
|
||||
første versjon av "Redning"-kortet viste samme tall TO GANGER per gauge
|
||||
(`Donut` sin egen auto-genererte forklaringsliste "● Scrambling 9 %" RETT
|
||||
OVER en egen, manuelt lagt til "Scrambling"-bildetekst) — sett direkte i
|
||||
et ekte skjermbilde, ikke antatt. Fikset ved å legge til en
|
||||
`showLegend`-prop på `Donut` og slå den av for de to gaugene, beholdt kun
|
||||
min egen kompakte bildetekst+delta under ringen.
|
||||
**Verifisert:** ekte typesjekket produksjonsbuild (to runder — én for
|
||||
hovedversjonen, én for legend-fiksen), full backend-import-sjekk i
|
||||
prod-imaget, OG ekte skjermbilder tatt FØR og ETTER legend-fiksen i en
|
||||
innlogget nettleser-sesjon (samme mønster som sticky-kolonne-bug-fiksen
|
||||
tidligere denne uken) — kompasset viser reelle tall (39 % greentreff,
|
||||
75 % kort, 13/13 % venstre/høyre, 0 % langt for `hei@erol.no` sine 2
|
||||
runder), puttfordelings-donuten viser korrekt fargede segmenter (17/61/
|
||||
22 %), vindu-velgeren fungerer fortsatt uendret (klikket "Siste 5" etter
|
||||
redesignet, ingen konsoll-feil). Ingen migrasjon.
|
||||
**Rullet ut live 2026-07-28**, `docker compose up -d --build teecup_api
|
||||
teecup_frontend` (kjørt to ganger). `/health` → 200 begge ganger,
|
||||
`teeoff.no` upåvirket.
|
||||
**Oppfølging, samme dag:** brukeren ba om at fairway-baren sin venstre/
|
||||
høyre-bom bruker SAMME farge (i stedet for to ulike chart-farger) --
|
||||
begge er tross alt bare "bom", ingen grunn til å skille dem visuelt.
|
||||
Byttet begge til `--brand-orange` (appens etablerte "bom/over par"-farge),
|
||||
beholdt grønn kun for selve fairwaytreffet. Verifisert med et nytt
|
||||
skjermbilde. Rullet ut, ingen migrasjon.
|
||||
- **"Til par: med vs. uten"-splitt, BYGGET, TESTET OG LIVE (2026-07-28),
|
||||
samme dag:** brukeren ba eksplisitt om snitt-til-par splittet på om en
|
||||
hull-hendelse inntraff eller ikke -- greentreff, fairwaytreff, bunker,
|
||||
OG anywayslag (eksplisitt fremhevet). Fire nye par felt i
|
||||
`RoundStatsSummary`/`_summarize_rounds` (`app/routers/rounds.py`):
|
||||
`avg_to_par_with/without_gir`, `_fairway_hit/miss`, `_with/without_bunker`,
|
||||
`_with/without_anyway` -- pooler ENKELTHULL på tvers av alle runder i
|
||||
perioden (ikke per-runde-snitt, siden dette er hull-nivå-betingelser),
|
||||
samme `diff()`-formel som `round-stats.tsx` sin `avgToParWithGir`/
|
||||
`avgToParFairwayHit`/`avgToParWithBunker` bruker for én runde (portert
|
||||
uendret) -- anywayslag-splitten er en ny, konsekvent utvidelse av
|
||||
akkurat samme mønster (fantes ikke fra før for enkeltrunder heller).
|
||||
Ny `CompareToPar`-komponent i `rounds-stats-summary.tsx`: to bokser side
|
||||
om side, den med FAKTISK lavest til-par denne perioden fremheves grønn
|
||||
-- ingen hardkodet antakelse om hvilken side som "skal" vinne (bekreftet
|
||||
reelt i data: "Med anywayslag" var faktisk verre enn "Uten anywayslag"
|
||||
som forventet, men "I bunker" var marginalt BEDRE enn "Ikke i bunker"
|
||||
for denne brukerens 2 runder -- fremhevingen fulgte automatisk det
|
||||
virkelige tallet, ikke en antakelse).
|
||||
**Verifisert:** en frittstående Python-simulering av alle fire splittene
|
||||
mot et hånd-konstruert 5-hulls datasett (eksakte forventede gjennomsnitt
|
||||
regnet ut for hånd og sammenlignet), full backend-import-sjekk i
|
||||
prod-imaget, ekte typesjekket build, OG et ekte skjermbilde i innlogget
|
||||
nettleser som viste reelle, korrekt utregnede og korrekt fargede tall
|
||||
for alle fire kategoriene. Ingen migrasjon.
|
||||
**Rullet ut live 2026-07-28**, `docker compose up -d --build teecup_api
|
||||
teecup_frontend`, `/health` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
- **Gjennomgang av frittstående runder (ADR-033) + det viktigste hullet
|
||||
lukket: faktisk (beregnet) HCP, BYGGET, SCRATCH-/BROWSERVERIFISERT OG
|
||||
LIVE (2026-07-28, ADR-038):** brukeren ba om en vurdering av om alt var
|
||||
tenkt gjennom for single-runder. Fant ved grep at hele WHS-indeksmotoren
|
||||
(`handicap_index_from_differentials`/`low_handicap_index`/
|
||||
`apply_index_caps` i `handicap_engine.py`, 41/41 testet siden ADR-033)
|
||||
ALDRI ble kalt fra noe API-endepunkt — `round_participant.
|
||||
score_differential` ble regnet og lagret per runde, men `app_user.
|
||||
handicap_index` endret seg kun manuelt. Bekreftet eksplisitt i
|
||||
`rounds.py` sin egen moduldoc ("skjer IKKE automatisk her -- eksplisitt
|
||||
uavklart punkt i ADR-033"). Sekundære, mindre hull notert samtidig
|
||||
(offline-kø kun for turnering-scorekortet, ikke frittstående runder;
|
||||
ingen Stableford; ingen rundedeling/visibility) — brukeren valgte å ta
|
||||
tak i HCP-hullet.
|
||||
**To load-bærende design-avklaringer** (AskUserQuestion, se ADR-038):
|
||||
(1) hver INNLOGGET deltaker (ikke bare eieren) styrer sin egen
|
||||
eksklusjon fra faktisk HCP; (2) faktisk HCP designes NÅ for å kunne
|
||||
inkludere begge kilder (frittstående runder OG en fremtidig turnering-
|
||||
kilde), men v1 bygger kun runder-delen — løst med ett bevisst tynt,
|
||||
navngitt skjøtepunkt (`_gather_qualifying_differentials`), IKKE en ny
|
||||
generell "scoring record"-tabell (for tidlig abstraksjon).
|
||||
**Ny migrasjon `030_actual_handicap_index.sql`:** `app_user.
|
||||
computed_handicap_index`/`computed_handicap_index_updated_at` (den
|
||||
faktiske, beregnede WHS-indeksen — ALDRI direkte redigerbar, kun
|
||||
avledet), `round_participant.exclude_from_handicap` (manuell opt-out,
|
||||
uavhengig av den automatiske `counts_for_handicap`-kvalifiseringen),
|
||||
`round.play_format` (`stroke`/`match`, selvdeklarert — ingen egen
|
||||
match-motor for frittstående runder), `handicap_history.source`
|
||||
(`manual`/`computed` — gjenbruker EKSISTERENDE tabell fra ADR-031-
|
||||
oppfølgingen i stedet for en parallell historikk-tabell, siden Low
|
||||
Handicap Index/cap (Rule 5.7/5.8) trenger nøyaktig samme
|
||||
dato+indeks-form som allerede fantes der).
|
||||
**WHS-kilde lest og lagt til grunn** (`WHS_Rules_of_Handicapping_2024.
|
||||
pdf`, Rule 3.3): matchspill-scorer ER teknisk et gyldig HCP-grunnlag
|
||||
under WHS, MEN et konsedert/ikke-utspilt hull krever en subjektiv "most
|
||||
likely score" TeeCups rene slagregistrering ikke har noen vei til å
|
||||
representere presist — begrunner "spør (med anbefalt eksklusjon), ikke
|
||||
tving"-designet i stedet for et hardkodet forbud mot matchspill i
|
||||
HCP-grunnlaget.
|
||||
**Backend (`app/routers/rounds.py`):** ny `_recompute_computed_
|
||||
handicap_index(conn, user_id)` — henter de ≤20 nyeste kvalifiserende
|
||||
differensialene, kaller den allerede-testede motoren, henter tidligere
|
||||
`computed`-historikk for Low HI-cap (hopper bevisst over capping ved
|
||||
FØRSTE beregning noensinne — Low HI er udefinert før en indeks er
|
||||
etablert). Kalt fra `complete_round` (alle deltakere med `user_id`),
|
||||
`update_participant` (eksklusjon endret på en ALLEREDE fullført runde),
|
||||
`remove_guest_participant` (dekker faktisk enhver ikke-eier-fjerning,
|
||||
til tross for navnet) og `delete_round` (fanger berørte brukere FØR
|
||||
kaskade-slettingen fjerner radene). `update_participant` sin
|
||||
autorisasjon utvidet presist: en ikke-eier kan KUN sende
|
||||
`exclude_from_handicap`, KUN på sin egen rad (`_OWNER_ONLY_
|
||||
PARTICIPANT_FIELDS`-sjekk + eksplisitt eier-eller-selv-gate) — alle
|
||||
andre felt forblir strengt eier-only, uendret.
|
||||
**Backend (`app/routers/auth.py`):** `Me` fikk `computed_handicap_
|
||||
index`/`computed_handicap_index_updated_at`. Ny `POST /auth/profile/
|
||||
handicap/apply-computed` — kopierer gjeldende beregnet verdi inn i det
|
||||
manuelt satte HCP-et (samme skrivevei/historikk-logging som en vanlig
|
||||
manuell PATCH, kun `source='manual'`). `GET /auth/profile/handicap-
|
||||
history` eksponerer nå `source` også.
|
||||
**Frontend:** `/my-rounds/new` fikk en "Spilleform"-bryter
|
||||
(Slagspill/Matchspill) — velges Matchspill, forhåndsutfylles (ikke
|
||||
tvinges) en eksklusjons-avkrysning med forklarende tekst.
|
||||
`round-detail.tsx` fikk en "Matchspill"-badge i headeren, en
|
||||
spilleform-bryter i `EditRoundPanel` (ren metadata, redigerbar uansett
|
||||
fullført-status), og `EditParticipantPanel` fikk en ny `restricted`-
|
||||
modus: en ikke-eier som redigerer SIN EGEN rad, ELLER EIEREN etter at
|
||||
runden er fullført, ser KUN eksklusjons-toggelen (ikke tee/HCP/navn/
|
||||
statistikk) — `PlayerList` sin "Rediger"-knapp vises nå også for en
|
||||
ikke-eiers egen rad, ikke bare for eieren. `/account` fikk et nytt
|
||||
"Faktisk HCP (beregnet)"-kort (verdi + sist-beregnet-dato +
|
||||
"Bruk som mitt HCP →"-knapp), og HCP-historikk-listen viser nå
|
||||
"beregnet"/"manuelt" per rad.
|
||||
**Scratch-verifisert grundig, 111/111 sjekker** (isolert
|
||||
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
|
||||
container via ekte HTTP, samme mønster som hele prosjektet):
|
||||
hånd-utregnet WHS-matte bekreftet PRESIST (tre runder med kjente
|
||||
differensialer [10.0, 12.0, 14.0] ga nøyaktig 8.0 — beste-1-av-3 med
|
||||
-2.0-justering fra Rule 5.2a-tabellen), <3 tellende runder gir fortsatt
|
||||
`null` (ikke en feil), sletting av en tellende runde regner faktisk HCP
|
||||
på nytt (tilbake til `null` under 3), matchspill-runde eksplisitt
|
||||
ekskludert ved opprettelse telles korrekt IKKE med, "Bruk som mitt
|
||||
HCP"-overføring bekreftet (+ `handicap_history` bærer nå begge kilder),
|
||||
og hele autorisasjonsmatrisen for eksklusjons-feltet (medspiller nektes
|
||||
andre felt og eierens rad, men kan endre EGEN eksklusjon; eieren kan
|
||||
fortsatt overstyre medspillerens). `test_isolation.sql` 12/12 uendret.
|
||||
**Reelt funn UNDER selve scratch-oppsettet, ikke i produksjon:** første
|
||||
forsøk på en engangs API-container monterte kildekoden til feil sti
|
||||
(`/app/app` i stedet for `/srv/app`, som er `Dockerfile` sin faktiske
|
||||
`WORKDIR`) — containeren boot-et rent, men kjørte stille det GAMLE,
|
||||
innbakte imagekoden uendret. Fanget FØR noe ble stolt på, ved at
|
||||
`/auth/me` manglet det nye feltet helt i et faktisk API-svar — rettet
|
||||
ved å montere til riktig `/srv`-sti, deretter bekreftet på nytt.
|
||||
**Ekte nettleser-verifisert** (Chrome DevTools MCP, engangs `next dev`-
|
||||
container mot scratch-backend — samme "sett resultatet, ikke bare
|
||||
typesjekk det"-arbeidsmåte som resten av uken): logget inn via ekte
|
||||
magic-link, opprettet en matchspill-runde → bekreftet
|
||||
forhåndsutfylt-men-overstyrbar eksklusjonsavkrysning i skjemaet,
|
||||
"Matchspill"-badge i rundens header, fullførte runden → bekreftet
|
||||
`EditParticipantPanel` automatisk bytter til restriktert modus (kun
|
||||
eksklusjons-toggel, ingen av de andre feltene), lagret en endring →
|
||||
bekreftet ekte `PATCH .../participants/{id}` 200 i nettverksfanen.
|
||||
Ekte typesjekket produksjonsbuild (alle 24 ruter) kjørt og bekreftet.
|
||||
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: migrasjon
|
||||
030 kjørt mot ekte `teecup_db` (alle fire nye kolonnesettene bekreftet,
|
||||
`test_isolation.sql` fortsatt 12/12 mot ekte database), deretter
|
||||
`docker compose up -d --build teecup_api teecup_frontend`. Begge
|
||||
containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||
upåvirket. Verifisert presist at den nye ruten faktisk når FastAPI:
|
||||
anonymt `POST /auth/profile/handicap/apply-computed` over ekte https ga
|
||||
korrekt `401 NOT_AUTHENTICATED`, ikke en rå 404.
|
||||
**Bevisst utenfor omfang, notert i ADR-038:** turnering-/organisasjons-
|
||||
scoring teller fortsatt ikke mot faktisk HCP (venter på videre
|
||||
ADR-037-arbeid), ingen egen "aging"-bakgrunnsjobb utover at spørringen
|
||||
alltid henter kun de 20 nyeste differensialene.
|
||||
|
||||
Neste steg:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
|
|
@ -4075,3 +4274,15 @@ Neste steg:
|
|||
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
|
||||
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
||||
integrering.
|
||||
10. **Resterende hull fra 2026-07-28-gjennomgangen av frittstående runder,
|
||||
fortsatt IKKE bygget** (det viktigste — faktisk HCP — er tettet, se
|
||||
ADR-038 over): (a) offline-kø (ADR-028) er kun koblet til turnering-
|
||||
scorekortet (`session-scorecard.tsx`), IKKE frittstående runder
|
||||
(`round-detail.tsx`/`ScoringWizard`) — nettopp der man oftest står
|
||||
alene ute på banen uten dekning; (b) ingen Stableford-poengberegning
|
||||
for frittstående runder (kun rå slag/differensial), inkl. en uløst
|
||||
"plukket opp ballen"-tilstand; (c) rundedeling/visibility (ADR-036
|
||||
fase 2, public/private/friends) fortsatt ikke bygget; (d) flere
|
||||
flighter i én frittstående runde fortsatt kun drøftet (se
|
||||
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
||||
til rundehendelser ennå.
|
||||
|
|
|
|||
|
|
@ -2650,7 +2650,23 @@ se over) står fortsatt som anbefaling, uendret av presiseringen.
|
|||
|
||||
---
|
||||
|
||||
## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-25 (se ADR-033 for full detalj per dag)
|
||||
## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-28 (se ADR-033/ADR-038 for full detalj per dag)
|
||||
|
||||
**Oppdatering 2026-07-28 (ADR-038):** en full gjennomgang av eksisterende
|
||||
funksjonalitet mot .md-filene avdekket at hele WHS-indeksmotoren i
|
||||
`handicap_engine.py` (bygget/testet under ADR-033) aldri ble kalt fra
|
||||
noe API-endepunkt — score-differensialer ble lagret per runde, men
|
||||
`app_user.handicap_index` endret seg kun manuelt. Bygget, scratch-
|
||||
verifisert (111/111) og rullet ut live: to atskilte HCP-tall (manuelt
|
||||
vs. faktisk/beregnet), en eksplisitt "overfør til manuelt HCP"-handling,
|
||||
en manuell eksklusjons-toggel per deltaker (kontrollert av HVER
|
||||
innlogget deltaker for sin egen rad), og selvdeklarert spilleform
|
||||
(slagspill/matchspill) med en anbefalt-men-overstyrbar eksklusjon for
|
||||
matchspill. Se CLAUDE.md-status 2026-07-28 for full detalj (migrasjon
|
||||
`030`, backend/frontend-endringer, verifisering).
|
||||
**Fortsatt IKKE tettet, notert samme runde:** offline-kø kun for
|
||||
turnering-scorekortet (ikke frittstående runder), ingen Stableford,
|
||||
rundedeling/visibility (ADR-036 fase 2) fortsatt ikke bygget.
|
||||
|
||||
**Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema
|
||||
(`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner),
|
||||
|
|
|
|||
|
|
@ -645,6 +645,14 @@ class Me(BaseModel):
|
|||
birth_date: date | None
|
||||
gender: str | None
|
||||
handicap_index: float | None
|
||||
# Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet
|
||||
# automatisk fra beste 8 av de ≤20 nyeste tellende Score
|
||||
# Differential-ene fra frittstående runder. Aldri direkte redigerbar
|
||||
# -- kun `handicap_index` over er det (manuelt satt, brukt til
|
||||
# beregning i runder som i dag). `None` betyr færre enn 3 tellende
|
||||
# runder ennå, ikke en feil.
|
||||
computed_handicap_index: float | None
|
||||
computed_handicap_index_updated_at: str | None
|
||||
home_club: str | None
|
||||
country: str | None
|
||||
bio: str | None
|
||||
|
|
@ -685,7 +693,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
|||
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
|
||||
(password_hash IS NOT NULL) AS has_password, two_factor_method,
|
||||
first_name, last_name, birth_date, gender,
|
||||
handicap_index::float AS handicap_index, home_club, country, bio,
|
||||
handicap_index::float AS handicap_index,
|
||||
computed_handicap_index::float AS computed_handicap_index,
|
||||
computed_handicap_index_updated_at,
|
||||
home_club, country, bio,
|
||||
mobile_country_code, mobile_number, bag_clubs, avatar_key
|
||||
FROM app_user WHERE id = $1
|
||||
""",
|
||||
|
|
@ -765,6 +776,11 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
|||
birth_date=user_row["birth_date"],
|
||||
gender=user_row["gender"],
|
||||
handicap_index=user_row["handicap_index"],
|
||||
computed_handicap_index=user_row["computed_handicap_index"],
|
||||
computed_handicap_index_updated_at=(
|
||||
user_row["computed_handicap_index_updated_at"].isoformat()
|
||||
if user_row["computed_handicap_index_updated_at"] else None
|
||||
),
|
||||
home_club=user_row["home_club"],
|
||||
country=user_row["country"],
|
||||
bio=user_row["bio"],
|
||||
|
|
@ -872,13 +888,36 @@ async def get_handicap_history(user: CurrentUser = Depends(get_current_user)) ->
|
|||
async with plain_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT handicap_index::float AS handicap_index, recorded_at
|
||||
SELECT handicap_index::float AS handicap_index, recorded_at, source
|
||||
FROM handicap_history WHERE user_id = $1
|
||||
ORDER BY recorded_at
|
||||
""",
|
||||
user.user_id,
|
||||
)
|
||||
return [{"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat()} for r in rows]
|
||||
return [
|
||||
{"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat(), "source": r["source"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/profile/handicap/apply-computed", response_model=Me)
|
||||
async def apply_computed_handicap(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||
"""ADR-038 Beslutning B -- eksplisitt "Bruk som mitt HCP"-handling.
|
||||
Kopierer gjeldende computed_handicap_index inn i det manuelt satte
|
||||
handicap_index. INGEN automatisk synkronisering noen vei ellers --
|
||||
de to tallene lever uavhengig helt til brukeren selv trykker denne."""
|
||||
async with plain_connection() as conn:
|
||||
computed = await conn.fetchval(
|
||||
"SELECT computed_handicap_index::float FROM app_user WHERE id = $1", user.user_id
|
||||
)
|
||||
if computed is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ingen faktisk HCP er beregnet ennå.")
|
||||
await conn.execute("UPDATE app_user SET handicap_index = $2 WHERE id = $1", user.user_id, computed)
|
||||
await conn.execute(
|
||||
"INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'manual')",
|
||||
user.user_id, computed,
|
||||
)
|
||||
return await me(user)
|
||||
|
||||
|
||||
@router.post("/profile/avatar", response_model=Me)
|
||||
|
|
|
|||
|
|
@ -19,9 +19,15 @@ DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte
|
|||
kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for
|
||||
runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå.
|
||||
|
||||
HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer
|
||||
IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført,
|
||||
tellende resultat lagres kun som `round_participant.score_differential`.
|
||||
HCP: to atskilte tall (ADR-038). `app_user.handicap_index` er MANUELT
|
||||
satt, uendret betydning (brukes til Course Handicap i enhver runde med
|
||||
mindre eksplisitt overstyrt). `app_user.computed_handicap_index` er det
|
||||
FAKTISKE, automatisk beregnede WHS Handicap Index (Rule 5.2) --
|
||||
oppdateres av `_recompute_computed_handicap_index()` ved fullføring, ved
|
||||
eksklusjons-endring på en allerede fullført runde, og ved fjerning/
|
||||
sletting av en tellende deltaker/runde. Kun frittstående runder teller
|
||||
med i v1 (se `_gather_qualifying_differentials` for skjøtepunktet mot en
|
||||
fremtidig turnering-kilde, ADR-038 Beslutning E).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -41,7 +47,10 @@ from ..realtime import broadcast_round_update, live_sockets_for_round
|
|||
from handicap_engine import (
|
||||
adjusted_gross_score,
|
||||
allocate_strokes_by_index,
|
||||
apply_index_caps,
|
||||
course_handicap,
|
||||
handicap_index_from_differentials,
|
||||
low_handicap_index,
|
||||
round_counts_for_handicap,
|
||||
score_differential,
|
||||
)
|
||||
|
|
@ -416,6 +425,12 @@ class RoundCreate(BaseModel):
|
|||
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
|
||||
# tidsbruk (completed_at - started_at) når runden fullføres.
|
||||
started_at: datetime | None = None
|
||||
# Selvdeklarert spilleform (ADR-038 Beslutning D) -- ingen egen
|
||||
# match-motor for frittstående runder, så dette taes brukerens ord for.
|
||||
play_format: Literal["stroke", "match"] = "stroke"
|
||||
# Frontend forhåndsutfyller denne til True når play_format='match'
|
||||
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
|
||||
exclude_owner_from_handicap: bool = False
|
||||
|
||||
|
||||
class RoundParticipantOut(BaseModel):
|
||||
|
|
@ -442,6 +457,10 @@ class RoundParticipantOut(BaseModel):
|
|||
counts_for_handicap: bool
|
||||
score_differential: float | None
|
||||
stat_level: StatLevel
|
||||
# ADR-038 Beslutning C -- manuell opt-out, uavhengig av
|
||||
# counts_for_handicap. Kontrolleres av DENNE deltakeren selv (eller
|
||||
# runde-eieren), se update_participant.
|
||||
exclude_from_handicap: bool
|
||||
|
||||
|
||||
class RoundOut(BaseModel):
|
||||
|
|
@ -453,6 +472,7 @@ class RoundOut(BaseModel):
|
|||
played_at: date
|
||||
start_hole: int
|
||||
holes_planned: int
|
||||
play_format: str
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
participants: list[RoundParticipantOut]
|
||||
|
|
@ -470,7 +490,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
|
||||
played_at, start_hole, holes_planned, started_at, completed_at
|
||||
played_at, start_hole, holes_planned, play_format, started_at, completed_at
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -482,7 +502,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
rp.exclude_from_handicap
|
||||
FROM round_participant rp
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
|
||||
|
|
@ -520,6 +541,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
played_at=round_row["played_at"],
|
||||
start_hole=round_row["start_hole"],
|
||||
holes_planned=round_row["holes_planned"],
|
||||
play_format=round_row["play_format"],
|
||||
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
|
||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
|
|
@ -542,6 +564,7 @@ async def _create_participant(
|
|||
handicap_index: float | None,
|
||||
stat_level: StatLevel = "strokes_only",
|
||||
guest_email: str | None = None,
|
||||
exclude_from_handicap: bool = False,
|
||||
) -> str:
|
||||
rating = resolved.rating_for(tee_name, gender)
|
||||
course_rating = slope_rating = tee_par = None
|
||||
|
|
@ -556,8 +579,8 @@ async def _create_participant(
|
|||
INSERT INTO round_participant
|
||||
(round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot,
|
||||
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
|
||||
stat_level)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
stat_level, exclude_from_handicap)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -573,6 +596,7 @@ async def _create_participant(
|
|||
tee_par,
|
||||
course_handicap_snapshot,
|
||||
stat_level,
|
||||
exclude_from_handicap,
|
||||
)
|
||||
participant_id = participant_row["id"]
|
||||
|
||||
|
|
@ -621,8 +645,8 @@ async def create_round(
|
|||
INSERT INTO round
|
||||
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||
start_hole, holes_planned, started_at, name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
start_hole, holes_planned, started_at, name, play_format)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
user.user_id,
|
||||
|
|
@ -637,6 +661,7 @@ async def create_round(
|
|||
body.holes_planned,
|
||||
body.started_at,
|
||||
body.name.strip() if body.name and body.name.strip() else None,
|
||||
body.play_format,
|
||||
)
|
||||
round_id = round_row["id"]
|
||||
|
||||
|
|
@ -645,6 +670,7 @@ async def create_round(
|
|||
user_id=user.user_id, guest_name=None, is_owner=True,
|
||||
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
|
||||
stat_level=body.stat_level,
|
||||
exclude_from_handicap=body.exclude_owner_from_handicap,
|
||||
)
|
||||
|
||||
return await _load_round_out(conn, round_id, user.user_id)
|
||||
|
|
@ -686,12 +712,28 @@ class RoundStatsSummary(BaseModel):
|
|||
green_miss_right_pct: float | None
|
||||
green_miss_tracked_holes: int
|
||||
one_putt_pct: float | None
|
||||
putt_dist_one_pct: float | None
|
||||
putt_dist_two_pct: float | None
|
||||
putt_dist_three_plus_pct: float | None
|
||||
scrambling_pct: float | None
|
||||
sand_save_pct: float | None
|
||||
avg_chip_per_round: float | None
|
||||
avg_bunker_per_round: float | None
|
||||
avg_penalty_per_round: float | None
|
||||
avg_anyway_per_round: float | None
|
||||
# Til-par splittet på om en hendelse inntraff på HULLET -- pooler
|
||||
# enkelthull på tvers av alle runder (ikke per-runde-snitt, siden dette
|
||||
# er hull-nivå-betingelser), samme formel som `round-stats.tsx` sin
|
||||
# `avgToParWithGir`/`avgToParFairwayHit`/`avgToParWithBunker` bruker
|
||||
# for én runde.
|
||||
avg_to_par_with_gir: float | None
|
||||
avg_to_par_without_gir: float | None
|
||||
avg_to_par_fairway_hit: float | None
|
||||
avg_to_par_fairway_miss: float | None
|
||||
avg_to_par_with_bunker: float | None
|
||||
avg_to_par_without_bunker: float | None
|
||||
avg_to_par_with_anyway: float | None
|
||||
avg_to_par_without_anyway: float | None
|
||||
|
||||
|
||||
class RoundStatsWindowSummary(BaseModel):
|
||||
|
|
@ -773,6 +815,14 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level
|
|||
gir_eligible = [h for h in all_played if h["putts"] is not None]
|
||||
gir_pct = avg([100.0 if is_gir(h) else 0.0 for h in gir_eligible])
|
||||
one_putt_pct = avg([100.0 if h["putts"] <= 1 else 0.0 for h in gir_eligible])
|
||||
# Puttfordeling (donut-vennlig, 3 gjensidig utelukkende bøtter) -- egne
|
||||
# eksakte grenser (==1/==2/>=3), SAMME bøtter som `round-stats.tsx` sin
|
||||
# `puttCategories` bruker per enkeltrunde, ikke samme grense som
|
||||
# `one_putt_pct` over (som bevisst bruker <=1 -- en litt annen,
|
||||
# allerede etablert "hvor ofte putter du bra"-rate, ikke en distribusjon).
|
||||
putt_dist_one_pct = avg([100.0 if h["putts"] == 1 else 0.0 for h in gir_eligible])
|
||||
putt_dist_two_pct = avg([100.0 if h["putts"] == 2 else 0.0 for h in gir_eligible])
|
||||
putt_dist_three_plus_pct = avg([100.0 if h["putts"] >= 3 else 0.0 for h in gir_eligible])
|
||||
|
||||
# Retning på bomskudd mot green -- eget spor (approach_result), ikke
|
||||
# avledet av putte-basert GIR: en spiller kan registrere retning uten
|
||||
|
|
@ -788,6 +838,29 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level
|
|||
sand_eligible = [h for h in gir_missed if h["bunker_shot_count"] is not None and h["bunker_shot_count"] >= 1]
|
||||
sand_save_pct = avg([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in sand_eligible])
|
||||
|
||||
# Til-par splittet på hull-nivå-hendelser (brukerens eksplisitte
|
||||
# instruks 2026-07-28) -- pooler ENKELTHULL på tvers av alle runder i
|
||||
# perioden, samme `diff()`-formel som `round-stats.tsx` sin
|
||||
# `avgToParWithGir`/`avgToParFairwayHit`/`avgToParWithBunker` (portert
|
||||
# uendret, anywayslag-splitten er en ny, konsekvent utvidelse av samme
|
||||
# mønster -- fantes ikke fra før for enkeltrunder).
|
||||
def diff(h) -> int:
|
||||
return h["score"] - h["par"]
|
||||
|
||||
avg_to_par_with_gir = avg([diff(h) for h in gir_eligible if is_gir(h)])
|
||||
avg_to_par_without_gir = avg([diff(h) for h in gir_eligible if not is_gir(h)])
|
||||
|
||||
avg_to_par_fairway_hit = avg([diff(h) for h in fairway_tracked if h["tee_shot_result"] == "fairway"])
|
||||
avg_to_par_fairway_miss = avg([diff(h) for h in fairway_tracked if h["tee_shot_result"] in ("left", "right")])
|
||||
|
||||
bunker_eligible = [h for h in all_played if h["bunker_shot_count"] is not None]
|
||||
avg_to_par_with_bunker = avg([diff(h) for h in bunker_eligible if h["bunker_shot_count"] >= 1])
|
||||
avg_to_par_without_bunker = avg([diff(h) for h in bunker_eligible if h["bunker_shot_count"] == 0])
|
||||
|
||||
anyway_eligible = [h for h in all_played if h["anyway_strokes"] is not None]
|
||||
avg_to_par_with_anyway = avg([diff(h) for h in anyway_eligible if h["anyway_strokes"] >= 1])
|
||||
avg_to_par_without_anyway = avg([diff(h) for h in anyway_eligible if h["anyway_strokes"] == 0])
|
||||
|
||||
def avg_per_round(field: str) -> float | None:
|
||||
totals = []
|
||||
for round_id in round_ids:
|
||||
|
|
@ -813,12 +886,23 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level
|
|||
green_miss_right_pct=miss_pct("right"),
|
||||
green_miss_tracked_holes=len(green_missed),
|
||||
one_putt_pct=one_putt_pct,
|
||||
putt_dist_one_pct=putt_dist_one_pct,
|
||||
putt_dist_two_pct=putt_dist_two_pct,
|
||||
putt_dist_three_plus_pct=putt_dist_three_plus_pct,
|
||||
scrambling_pct=scrambling_pct,
|
||||
sand_save_pct=sand_save_pct,
|
||||
avg_chip_per_round=avg_per_round("chip_count"),
|
||||
avg_bunker_per_round=avg_per_round("bunker_shot_count"),
|
||||
avg_penalty_per_round=avg_per_round("penalty_strokes"),
|
||||
avg_anyway_per_round=avg_per_round("anyway_strokes"),
|
||||
avg_to_par_with_gir=avg_to_par_with_gir,
|
||||
avg_to_par_without_gir=avg_to_par_without_gir,
|
||||
avg_to_par_fairway_hit=avg_to_par_fairway_hit,
|
||||
avg_to_par_fairway_miss=avg_to_par_fairway_miss,
|
||||
avg_to_par_with_bunker=avg_to_par_with_bunker,
|
||||
avg_to_par_without_bunker=avg_to_par_without_bunker,
|
||||
avg_to_par_with_anyway=avg_to_par_with_anyway,
|
||||
avg_to_par_without_anyway=avg_to_par_without_anyway,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -917,6 +1001,67 @@ async def _get_accessible_round_or_404(conn, round_id: str, user_id: str):
|
|||
raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder --
|
||||
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en
|
||||
# fremtidig UNION mot turnering-/organisasjonsscoring (ADR-037), ikke bygget
|
||||
# ennå. Ingen ny, generell "scoring record"-tabell -- for tidlig abstraksjon
|
||||
# for en kilde som ikke finnes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _gather_qualifying_differentials(conn, user_id: str) -> list[tuple[date, float]]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT r.played_at, rp.score_differential::float AS score_differential
|
||||
FROM round_participant rp
|
||||
JOIN round r ON r.id = rp.round_id
|
||||
WHERE rp.user_id = $1
|
||||
AND rp.counts_for_handicap
|
||||
AND NOT rp.exclude_from_handicap
|
||||
AND rp.score_differential IS NOT NULL
|
||||
ORDER BY r.played_at DESC
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
return [(row["played_at"], row["score_differential"]) for row in rows]
|
||||
|
||||
|
||||
async def _recompute_computed_handicap_index(conn, user_id: str) -> None:
|
||||
"""Rule 5.2 (beste 8-av-≤20) + Rule 5.7/5.8 (Low HI-cap). Ingen tidligere
|
||||
'computed'-historikk å låse mot -- capping hoppes bevisst over (Low HI
|
||||
er udefinert før en første indeks er etablert), ikke en feil."""
|
||||
differentials = await _gather_qualifying_differentials(conn, user_id)
|
||||
new_index = handicap_index_from_differentials([d for _, d in differentials][:20])
|
||||
|
||||
if new_index is None:
|
||||
await conn.execute(
|
||||
"UPDATE app_user SET computed_handicap_index = NULL, computed_handicap_index_updated_at = now() "
|
||||
"WHERE id = $1",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
history_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT recorded_at::date AS d, handicap_index::float AS idx
|
||||
FROM handicap_history WHERE user_id = $1 AND source = 'computed'
|
||||
ORDER BY recorded_at
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
low = low_handicap_index([(row["d"], row["idx"]) for row in history_rows], date.today())
|
||||
final_index = apply_index_caps(new_index, low) if low is not None else new_index
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE app_user SET computed_handicap_index = $2, computed_handicap_index_updated_at = now() WHERE id = $1",
|
||||
user_id, final_index,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'computed')",
|
||||
user_id, final_index,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}", response_model=RoundOut)
|
||||
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
|
||||
async with plain_connection() as conn:
|
||||
|
|
@ -942,6 +1087,9 @@ class RoundUpdate(BaseModel):
|
|||
name: str | None = Field(default=None, max_length=200)
|
||||
holes_planned: Literal[9, 18] | None = None
|
||||
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||
# Ren metadata (ADR-038 Beslutning D) -- rører aldri AGS/differensial-
|
||||
# matematikken selv, kan derfor endres uansett fullført-status.
|
||||
play_format: Literal["stroke", "match"] | None = None
|
||||
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
|
||||
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
|
||||
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
|
||||
|
|
@ -1041,6 +1189,8 @@ async def update_round(
|
|||
round_id,
|
||||
body.name.strip() if body.name.strip() else None,
|
||||
)
|
||||
if body.play_format is not None:
|
||||
await conn.execute("UPDATE round SET play_format = $2 WHERE id = $1", round_id, body.play_format)
|
||||
if body.started_at is not None:
|
||||
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
|
||||
if body.completed_at is not None:
|
||||
|
|
@ -1097,7 +1247,16 @@ async def update_round(
|
|||
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
# Fanges FØR sletting -- kaskaden fjerner round_participant-radene,
|
||||
# så de tellende brukerne må hentes ut nå for å kunne regnes på nytt.
|
||||
affected_users = await conn.fetch(
|
||||
"SELECT user_id::text AS user_id FROM round_participant "
|
||||
"WHERE round_id = $1 AND user_id IS NOT NULL AND counts_for_handicap",
|
||||
round_id,
|
||||
)
|
||||
await conn.execute("DELETE FROM round WHERE id = $1", round_id)
|
||||
for row in affected_users:
|
||||
await _recompute_computed_handicap_index(conn, row["user_id"])
|
||||
# Varsler evt. tilkoblede medspillere om at runden er borte -- deres
|
||||
# neste refetch vil da naturlig treffe 404 og vise en tydelig feil.
|
||||
await broadcast_round_update(round_id)
|
||||
|
|
@ -1126,6 +1285,10 @@ class ParticipantCreate(BaseModel):
|
|||
# avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
|
||||
tee_name: str | None = None
|
||||
stat_level: StatLevel = "strokes_only"
|
||||
# ADR-038 Beslutning C/D -- kun meningsfullt for user_id-varianten (en
|
||||
# gjest har ingen computed_handicap_index å beskytte). Frontend
|
||||
# forhåndsutfyller True når rundens play_format='match'.
|
||||
exclude_from_handicap: bool = False
|
||||
|
||||
|
||||
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
|
||||
|
|
@ -1195,6 +1358,7 @@ async def add_participant(
|
|||
gender=gender, handicap_index=handicap_index,
|
||||
stat_level=body.stat_level,
|
||||
guest_email=body.guest_email,
|
||||
exclude_from_handicap=body.exclude_from_handicap,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
|
|
@ -1203,7 +1367,8 @@ async def add_participant(
|
|||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
rp.exclude_from_handicap
|
||||
FROM round_participant rp
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.id = $1
|
||||
|
|
@ -1253,6 +1418,14 @@ class ParticipantUpdate(BaseModel):
|
|||
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
gender: Literal["m", "f", "x"] | None = None
|
||||
guest_email: EmailStr | None = None
|
||||
# ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
|
||||
# medspiller som redigerer SIN EGEN rad) får sende her.
|
||||
exclude_from_handicap: bool | None = None
|
||||
|
||||
|
||||
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
|
||||
# eier-only, se update_participant sin autorisasjonssjekk.
|
||||
_OWNER_ONLY_PARTICIPANT_FIELDS = {"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email"}
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
|
||||
|
|
@ -1267,11 +1440,15 @@ async def update_participant(
|
|||
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
# Eier ELLER en lenket medspiller (som kun får røre SIN EGEN
|
||||
# eksklusjons-flagg, se under) -- ikke strengt eier-only lenger.
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
current = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.counts_for_handicap,
|
||||
r.owner_user_id::text AS round_owner_user_id,
|
||||
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
||||
r.teeoff_course_id, r.personal_course_id
|
||||
FROM round_participant rp
|
||||
|
|
@ -1283,6 +1460,16 @@ async def update_participant(
|
|||
if current is None:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||
|
||||
is_owner = current["round_owner_user_id"] == user.user_id
|
||||
if not is_owner:
|
||||
if _OWNER_ONLY_PARTICIPANT_FIELDS & updates.keys():
|
||||
raise app_error(
|
||||
403, "NOT_AUTHORIZED",
|
||||
"Du kan kun endre om DIN egen deltakelse skal telle mot ditt faktiske HCP her.",
|
||||
)
|
||||
if current["user_id"] != user.user_id:
|
||||
raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.")
|
||||
|
||||
guest_only_fields = {"guest_name", "gender", "guest_email"}
|
||||
if current["user_id"] is not None and guest_only_fields & updates.keys():
|
||||
raise app_error(
|
||||
|
|
@ -1309,6 +1496,9 @@ async def update_participant(
|
|||
if "guest_email" in updates:
|
||||
values.append(updates["guest_email"])
|
||||
set_clauses.append(f"guest_email = ${len(values)}")
|
||||
if "exclude_from_handicap" in updates:
|
||||
values.append(updates["exclude_from_handicap"])
|
||||
set_clauses.append(f"exclude_from_handicap = ${len(values)}")
|
||||
|
||||
if rating_changed:
|
||||
if current["course_source"] == "teeoff":
|
||||
|
|
@ -1363,13 +1553,24 @@ async def update_participant(
|
|||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
rp.exclude_from_handicap
|
||||
FROM round_participant rp
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.id = $1
|
||||
""",
|
||||
updated_id,
|
||||
)
|
||||
|
||||
# En eksklusjons-endring på en ALLEREDE FULLFØRT, tellende runde
|
||||
# endrer umiddelbart hvilke differensialer faktisk HCP skal regnes
|
||||
# fra -- regn på nytt med en gang, ikke vent til neste fullføring.
|
||||
if (
|
||||
"exclude_from_handicap" in updates
|
||||
and current["user_id"] is not None
|
||||
and current["completed_at"] is not None
|
||||
):
|
||||
await _recompute_computed_handicap_index(conn, current["user_id"])
|
||||
await broadcast_round_update(round_id)
|
||||
return RoundParticipantOut(**dict(row))
|
||||
|
||||
|
|
@ -1383,7 +1584,8 @@ async def remove_guest_participant(
|
|||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
row = await conn.fetchrow(
|
||||
"SELECT is_owner FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
"SELECT is_owner, user_id::text AS user_id, counts_for_handicap FROM round_participant "
|
||||
"WHERE id = $1 AND round_id = $2",
|
||||
participant_id, round_id,
|
||||
)
|
||||
if row is None:
|
||||
|
|
@ -1391,6 +1593,11 @@ async def remove_guest_participant(
|
|||
if row["is_owner"]:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
|
||||
await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id)
|
||||
|
||||
# Fjerner en tellende differensial fra grunnlaget -- regn faktisk
|
||||
# HCP på nytt for den fjernede spilleren (ikke eieren, som er igjen).
|
||||
if row["user_id"] is not None and row["counts_for_handicap"]:
|
||||
await _recompute_computed_handicap_index(conn, row["user_id"])
|
||||
await broadcast_round_update(round_id)
|
||||
|
||||
|
||||
|
|
@ -1687,7 +1894,8 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
|
|||
|
||||
participants = await conn.fetch(
|
||||
"""
|
||||
SELECT id::text AS id, handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
SELECT id::text AS id, user_id::text AS user_id,
|
||||
handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
course_rating_snapshot::float AS course_rating_snapshot,
|
||||
slope_rating_snapshot, tee_par_snapshot
|
||||
FROM round_participant WHERE round_id = $1
|
||||
|
|
@ -1723,6 +1931,14 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
|
|||
)
|
||||
|
||||
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
|
||||
|
||||
# Faktisk HCP (ADR-038) -- alle differensialer for runden er nå
|
||||
# skrevet, regn på nytt for hver deltaker med egen konto (gjester
|
||||
# har ingen computed_handicap_index å oppdatere).
|
||||
for p in participants:
|
||||
if p["user_id"] is not None:
|
||||
await _recompute_computed_handicap_index(conn, p["user_id"])
|
||||
|
||||
result = await _load_round_out(conn, round_id, user.user_id)
|
||||
|
||||
await broadcast_round_update(round_id)
|
||||
|
|
|
|||
|
|
@ -170,6 +170,10 @@ type Me = {
|
|||
birth_date: string | null
|
||||
gender: "m" | "f" | "x" | null
|
||||
handicap_index: number | null
|
||||
// Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet
|
||||
// automatisk fra frittstående runder. Aldri direkte redigerbar.
|
||||
computed_handicap_index: number | null
|
||||
computed_handicap_index_updated_at: string | null
|
||||
home_club: string | null
|
||||
country: string | null
|
||||
bio: string | null
|
||||
|
|
@ -819,11 +823,81 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
</Button>
|
||||
</form>
|
||||
|
||||
<ActualHandicapCard me={me} onChanged={onChanged} />
|
||||
<HandicapHistorySection reloadKey={me.handicap_index} />
|
||||
</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 på dine siste tellende runder (WHS Rule 5.2), sist beregnet{" "}
|
||||
{me.computed_handicap_index_updated_at
|
||||
? new Date(me.computed_handicap_index_updated_at).toLocaleDateString("no-NO", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: "nylig"}
|
||||
.
|
||||
</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
|
||||
// logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver
|
||||
// gang HCP-feltet faktisk endres til en tallverdi. `reloadKey` (gjeldende
|
||||
|
|
@ -831,7 +905,9 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
// egen refetch-prop å tre gjennom fra ProfileSection.
|
||||
function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [entries, setEntries] = useState<{ handicap_index: number; recorded_at: string }[] | null>(null)
|
||||
const [entries, setEntries] = useState<
|
||||
{ handicap_index: number; recorded_at: string; source: "manual" | "computed" }[] | null
|
||||
>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -881,7 +957,12 @@ function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) {
|
|||
year: "numeric",
|
||||
})}
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ type CourseMeta =
|
|||
|
||||
type Gender = "m" | "f"
|
||||
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
|
||||
// ADR-038 -- selvdeklarert spilleform. Ingen egen match-motor for
|
||||
// frittstående runder, så dette taes brukerens ord for.
|
||||
type PlayFormat = "stroke" | "match"
|
||||
|
||||
// --- API-typer ---------------------------------------------------------------
|
||||
|
||||
|
|
@ -198,6 +201,8 @@ export function NewRound() {
|
|||
startHole: number
|
||||
holes: 9 | 18
|
||||
statLevel: StatLevel
|
||||
playFormat: PlayFormat
|
||||
excludeFromHandicap: boolean
|
||||
}): Promise<{ id: string }> {
|
||||
if (!courseMeta) throw new Error("Mangler valgt bane")
|
||||
const body =
|
||||
|
|
@ -213,6 +218,8 @@ export function NewRound() {
|
|||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
play_format: payload.playFormat,
|
||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||
}
|
||||
: {
|
||||
course_source: "custom",
|
||||
|
|
@ -224,6 +231,8 @@ export function NewRound() {
|
|||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
play_format: payload.playFormat,
|
||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||
}
|
||||
const res = await fetch("/rounds", {
|
||||
method: "POST",
|
||||
|
|
@ -1110,6 +1119,8 @@ function ConfirmStep({
|
|||
startHole: number
|
||||
holes: 9 | 18
|
||||
statLevel: StatLevel
|
||||
playFormat: PlayFormat
|
||||
excludeFromHandicap: boolean
|
||||
}) => Promise<{ id: string }>
|
||||
onCreated: (roundId: string) => void
|
||||
}) {
|
||||
|
|
@ -1121,9 +1132,18 @@ function ConfirmStep({
|
|||
const [startHole, setStartHole] = useState("1")
|
||||
const [holes, setHoles] = useState<9 | 18>(18)
|
||||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||||
const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke")
|
||||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
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)
|
||||
|
||||
async function handleStart() {
|
||||
|
|
@ -1143,6 +1163,8 @@ function ConfirmStep({
|
|||
startHole: Number(startHole),
|
||||
holes,
|
||||
statLevel,
|
||||
playFormat,
|
||||
excludeFromHandicap,
|
||||
})
|
||||
onCreated(created.id)
|
||||
} catch {
|
||||
|
|
@ -1289,6 +1311,53 @@ function ConfirmStep({
|
|||
</div>
|
||||
</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 */}
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 text-base font-semibold text-foreground">Antall hull</legend>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ type Player = {
|
|||
countsForHandicap: boolean
|
||||
scoreDifferential: number | null
|
||||
statLevel: StatLevel
|
||||
// ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av
|
||||
// countsForHandicap (den automatiske kvalifiseringen).
|
||||
excludeFromHandicap: boolean
|
||||
}
|
||||
|
||||
type HoleStat = {
|
||||
|
|
@ -163,6 +166,7 @@ type ApiParticipant = {
|
|||
counts_for_handicap: boolean
|
||||
score_differential: number | null
|
||||
stat_level: StatLevel
|
||||
exclude_from_handicap: boolean
|
||||
}
|
||||
|
||||
type PersonMatch = {
|
||||
|
|
@ -187,6 +191,7 @@ type ApiRound = {
|
|||
played_at: string
|
||||
start_hole: number
|
||||
holes_planned: number
|
||||
play_format: string
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
participants: ApiParticipant[]
|
||||
|
|
@ -430,6 +435,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
countsForHandicap: p.counts_for_handicap,
|
||||
scoreDifferential: p.score_differential,
|
||||
statLevel: p.stat_level,
|
||||
excludeFromHandicap: p.exclude_from_handicap,
|
||||
})) ?? []
|
||||
|
||||
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
||||
|
|
@ -695,6 +701,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
{formatTime(round.started_at)}
|
||||
</span>
|
||||
)}
|
||||
{round.play_format === "match" && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
Matchspill
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1542,7 +1553,15 @@ function PlayerList({
|
|||
const active = player.id === activePlayerId
|
||||
const editing = editingPlayerId === player.id
|
||||
const canRemove = !player.isSelf && !readOnly && canManage
|
||||
const canEdit = canManage && !readOnly
|
||||
// ADR-038 -- full redigering (utslag/HCP/navn/statistikk) er
|
||||
// fortsatt eier-only OG sperret etter fullføring. Men enhver
|
||||
// lenket deltaker (eier ELLER medspiller, kun sin EGEN rad) skal
|
||||
// kunne endre "ekskluder fra faktisk HCP" UANSETT fullført-status
|
||||
// -- dekket av en RESTRIKTERT variant av samme panel, kun
|
||||
// meningsfullt for en spiller med egen konto (userId).
|
||||
const canEditFull = canManage && !readOnly
|
||||
const canEditRestricted = (canManage || player.isSelf) && player.userId !== null
|
||||
const canEdit = canEditFull || canEditRestricted
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
|
|
@ -1628,6 +1647,7 @@ function PlayerList({
|
|||
<EditParticipantPanel
|
||||
roundId={roundId}
|
||||
player={player}
|
||||
restricted={!canEditFull}
|
||||
onPatch={onPatchParticipant}
|
||||
onClose={() => onToggleEdit(player.id)}
|
||||
/>
|
||||
|
|
@ -1921,6 +1941,9 @@ function EditRoundPanel({
|
|||
const [startHole, setStartHole] = useState(String(round.start_hole))
|
||||
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
|
||||
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
|
||||
// ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri
|
||||
// AGS/differensial-matematikken selv).
|
||||
const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -1929,7 +1952,8 @@ function EditRoundPanel({
|
|||
name.trim() !== (round.name ?? "") ||
|
||||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
||||
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
||||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt)
|
||||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
|
||||
playFormat !== (round.play_format === "match" ? "match" : "stroke")
|
||||
|
||||
async function saveMetadata() {
|
||||
setSaving(true)
|
||||
|
|
@ -1946,6 +1970,7 @@ function EditRoundPanel({
|
|||
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
|
||||
body.completed_at = fromDatetimeLocalValue(completedAt)
|
||||
}
|
||||
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat
|
||||
const result = await onPatch(body)
|
||||
if (!result.ok) setError(result.message)
|
||||
setSaving(false)
|
||||
|
|
@ -1976,6 +2001,33 @@ function EditRoundPanel({
|
|||
/>
|
||||
</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 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
@ -2079,11 +2131,17 @@ function EditRoundPanel({
|
|||
function EditParticipantPanel({
|
||||
roundId,
|
||||
player,
|
||||
restricted,
|
||||
onPatch,
|
||||
onClose,
|
||||
}: {
|
||||
roundId: string
|
||||
player: Player
|
||||
// ADR-038 -- true når viewer IKKE har full redigeringsrett her (en
|
||||
// ikke-eier som redigerer sin EGEN rad, ELLER eieren etter at runden er
|
||||
// fullført) -- viser da KUN eksklusjons-toggelen, ingen av de andre
|
||||
// feltene under.
|
||||
restricted: boolean
|
||||
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||
onClose: () => void
|
||||
}) {
|
||||
|
|
@ -2100,10 +2158,12 @@ function EditParticipantPanel({
|
|||
const [guestName, setGuestName] = useState(player.name)
|
||||
const [gender, setGender] = useState<Gender>(player.gender)
|
||||
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
||||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (restricted) return
|
||||
let cancelled = false
|
||||
fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
|
|
@ -2116,7 +2176,7 @@ function EditParticipantPanel({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId])
|
||||
}, [roundId, restricted])
|
||||
|
||||
// Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da
|
||||
// umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket
|
||||
|
|
@ -2128,6 +2188,7 @@ function EditParticipantPanel({
|
|||
setSaving(true)
|
||||
setError(null)
|
||||
const body: Record<string, unknown> = {}
|
||||
if (!restricted) {
|
||||
if (teeName !== player.teeName) body.tee_name = teeName
|
||||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||||
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
|
||||
|
|
@ -2141,6 +2202,10 @@ function EditParticipantPanel({
|
|||
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
|
||||
if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail
|
||||
}
|
||||
}
|
||||
if (!isGuest && excludeFromHandicap !== player.excludeFromHandicap) {
|
||||
body.exclude_from_handicap = excludeFromHandicap
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
setSaving(false)
|
||||
onClose()
|
||||
|
|
@ -2159,6 +2224,16 @@ function EditParticipantPanel({
|
|||
<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>}
|
||||
|
||||
{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">
|
||||
|
|
@ -2257,6 +2332,27 @@ function EditParticipantPanel({
|
|||
onChange={(v) => setStatLevel(v as StatLevel)}
|
||||
readOnly={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isGuest && (
|
||||
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-card px-4 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-sm font-bold text-foreground">
|
||||
Ekskluder fra {player.isSelf ? "mitt" : "spillerens"} faktiske HCP
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
F.eks. ved matchspill, eller andre runder som ikke bør telle med i HCP-grunnlaget.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
"use client"
|
||||
|
||||
// Aggregert statistikk på tvers av "Egne runder" (ADR-033), lenket fra
|
||||
// dashbordets "Statistikk"-seksjon (2026-07-28, utvidet samme dag med
|
||||
// tidsvindu + miss-retning + forrige-periode-sammenligning). Egen backend-
|
||||
// aggregering (GET /rounds/stats/summary) -- ikke bare klientside-utledning
|
||||
// av round-stats.tsx sin logikk, siden putt/18-hull-regelen (padding av
|
||||
// dashbordets "Statistikk"-seksjon. Egen backend-aggregering
|
||||
// (GET /rounds/stats/summary) -- ikke bare klientside-utledning 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"-
|
||||
// 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 { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 = {
|
||||
rounds_completed: number
|
||||
|
|
@ -31,12 +40,23 @@ type ApiStatsSummary = {
|
|||
green_miss_right_pct: number | null
|
||||
green_miss_tracked_holes: number
|
||||
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
|
||||
sand_save_pct: number | null
|
||||
avg_chip_per_round: number | null
|
||||
avg_bunker_per_round: number | null
|
||||
avg_penalty_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 = {
|
||||
|
|
@ -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({
|
||||
title,
|
||||
icon: Icon,
|
||||
|
|
@ -143,19 +175,164 @@ function Section({
|
|||
)
|
||||
}
|
||||
|
||||
// Horisontal fordelingsbar med retningsandeler (venstre/høyre eller
|
||||
// lang/kort/venstre/høyre) -- pooler kun faktisk registrerte bom.
|
||||
function MissBar({
|
||||
tracked,
|
||||
// --- Donut: sentertall + inntil 4 fargede segmenter (conic-gradient) -------
|
||||
// Gjenbrukt for BÅDE puttfordelingen (3 segmenter) og de enkle rednings-
|
||||
// gaugene (1 segment fylt + resten "spor"). Prosent er ALLTID skrevet ut i
|
||||
// en forklaringsliste under -- fargen bærer aldri betydningen alene.
|
||||
|
||||
function Donut({
|
||||
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
|
||||
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 (
|
||||
<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) =>
|
||||
s.value && s.value > 0 ? (
|
||||
<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() {
|
||||
const router = useRouter()
|
||||
const [window, setWindow] = useState<WindowKey>("all")
|
||||
|
|
@ -219,7 +444,7 @@ export function RoundsStatsSummary() {
|
|||
</div>
|
||||
</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">
|
||||
{WINDOW_OPTIONS.map((opt) => (
|
||||
<button
|
||||
|
|
@ -294,68 +519,110 @@ export function RoundsStatsSummary() {
|
|||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Fairway og green" icon={Flag}>
|
||||
<StatTile
|
||||
label="Fairwaytreff"
|
||||
value={s.fairway_hit_pct !== null ? pct(s.fairway_hit_pct) : "–"}
|
||||
delta={<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />}
|
||||
hint={s.fairway_tracked_holes > 0 ? `${s.fairway_tracked_holes} hull registrert` : undefined}
|
||||
<Card title="Greentreff (GIR)" subtitle="Hvor treffer og bommer du innspillet, sett fra green?">
|
||||
<GirCompass
|
||||
hitPct={s.gir_pct}
|
||||
trackedHoles={s.gir_tracked_holes}
|
||||
missTrackedHoles={s.green_miss_tracked_holes}
|
||||
long={s.green_miss_long_pct}
|
||||
short={s.green_miss_short_pct}
|
||||
left={s.green_miss_left_pct}
|
||||
right={s.green_miss_right_pct}
|
||||
/>
|
||||
<StatTile
|
||||
label="Greentreff (GIR)"
|
||||
value={s.gir_pct !== null ? pct(s.gir_pct) : "–"}
|
||||
delta={<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />}
|
||||
hint={s.gir_tracked_holes > 0 ? `${s.gir_tracked_holes} hull registrert` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="Én-putt"
|
||||
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 items-center justify-between border-t border-border pt-3">
|
||||
<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" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-foreground">Hvor bommer du fra utslaget?</h3>
|
||||
<p className="text-xs text-muted-foreground">Kun par 4/5, kun registrerte utslag.</p>
|
||||
</div>
|
||||
<MissBar
|
||||
tracked={s.fairway_tracked_holes}
|
||||
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 på 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)" },
|
||||
]}
|
||||
/>
|
||||
<Card title="Fra utslaget" subtitle="Kun par 4/5, kun registrerte utslag.">
|
||||
<FairwayBar tracked={s.fairway_tracked_holes} left={s.fairway_left_pct} hit={s.fairway_hit_pct} right={s.fairway_right_pct} />
|
||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||
<span className="text-sm font-semibold text-muted-foreground">Fairwaytreff vs. forrige periode</span>
|
||||
<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Section title="Redning" icon={Target}>
|
||||
<StatTile
|
||||
label="Scrambling"
|
||||
value={s.scrambling_pct !== null ? pct(s.scrambling_pct) : "–"}
|
||||
delta={<Delta current={s.scrambling_pct} previous={p.scrambling_pct} format={signedPoints} goodDirection="up" />}
|
||||
hint="Par eller bedre uten GIR"
|
||||
<Card title="Putting">
|
||||
<Donut
|
||||
centerValue={s.avg_putts_per_18 !== null ? s.avg_putts_per_18.toFixed(1).replace(".", ",") : "–"}
|
||||
centerLabel="putt / 18 hull"
|
||||
segments={
|
||||
s.putt_dist_one_pct !== null
|
||||
? [
|
||||
{ label: "1-putt", value: s.putt_dist_one_pct ?? 0, color: "var(--primary)" },
|
||||
{ label: "2-putt", value: s.putt_dist_two_pct ?? 0, color: "var(--info)" },
|
||||
{ label: "3-putt+", value: s.putt_dist_three_plus_pct ?? 0, color: "var(--brand-orange)" },
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="Sand save"
|
||||
value={s.sand_save_pct !== null ? pct(s.sand_save_pct) : "–"}
|
||||
delta={<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />}
|
||||
hint="Par eller bedre fra bunker"
|
||||
{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>
|
||||
|
||||
<Card title="Redning" subtitle="Par eller bedre etter en bom.">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Donut
|
||||
size={104}
|
||||
thickness={14}
|
||||
showLegend={false}
|
||||
centerValue={s.scrambling_pct !== null ? `${Math.round(s.scrambling_pct)}%` : "–"}
|
||||
centerLabel=""
|
||||
segments={s.scrambling_pct !== null ? [{ label: "Scrambling", value: s.scrambling_pct, color: "var(--primary)" }] : []}
|
||||
/>
|
||||
</Section>
|
||||
<span className="text-sm font-bold text-foreground">Scrambling</span>
|
||||
<Delta current={s.scrambling_pct} previous={p.scrambling_pct} format={signedPoints} goodDirection="up" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Donut
|
||||
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>
|
||||
|
||||
<Card title="Til par: med vs. uten" subtitle="Snitt til par på enkelthull, splittet på om noe faktisk inntraff.">
|
||||
<CompareToPar
|
||||
title="Greentreff (GIR)"
|
||||
aLabel="Med GIR"
|
||||
aValue={s.avg_to_par_with_gir}
|
||||
bLabel="Uten GIR"
|
||||
bValue={s.avg_to_par_without_gir}
|
||||
/>
|
||||
<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
|
||||
|
|
|
|||
Loading…
Reference in a new issue