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