This commit is contained in:
parent
955e320663
commit
bcb712810f
13 changed files with 720 additions and 47 deletions
28
093_round_session_net_hunt_display.sql
Normal file
28
093_round_session_net_hunt_display.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 093
|
||||||
|
-- "Jakter Jesper"-visningsmodus (ADR-106) utvidet til frittstående
|
||||||
|
-- runder OG lagturnering-økter -- del 2, se ARCHITECTURE_DECISIONS.md.
|
||||||
|
-- =====================================================================
|
||||||
|
-- `round.net_display_style`: samme betydning som `tournament.net_
|
||||||
|
-- display_style` (migrasjon 092) -- 'standard' (default, uendret
|
||||||
|
-- gradvis netto-visning) eller 'hunt' (helt spillehandicap trukket
|
||||||
|
-- fra med én gang, brutto-til-par vist løpende). Kun meningsfullt for
|
||||||
|
-- play_format='stroke' -- håndheves i app-laget.
|
||||||
|
--
|
||||||
|
-- `session.net_display_style`: samme, men for én økt i en org-
|
||||||
|
-- LAGturnering (singles/fourball, scoring_mode='stroke'). Lagturneringer
|
||||||
|
-- har scoring_method=NULL på selve turneringen (ADR-011s to-lags-modell
|
||||||
|
-- har ikke det begrepet) -- derfor øktnivå, ikke turneringnivå, se
|
||||||
|
-- ADR-106-tillegget for begrunnelsen (ingen eksisterende innstillings-UI
|
||||||
|
-- på turneringnivå for lagformatet, økt-editoren fantes allerede).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round
|
||||||
|
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
|
||||||
|
CHECK (net_display_style IN ('standard', 'hunt'));
|
||||||
|
|
||||||
|
ALTER TABLE session
|
||||||
|
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
|
||||||
|
CHECK (net_display_style IN ('standard', 'hunt'));
|
||||||
|
|
@ -10700,6 +10700,80 @@ etter utrulling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-107: "Jakter Jesper" del 2 -- frittstående runder og lagturnering-
|
||||||
|
økt-leaderboard (migrasjon 093, 2026-08-24)
|
||||||
|
|
||||||
|
**Kontekst:** ADR-106 leverte "Jakter Jesper" kun for org-individuelle
|
||||||
|
`stroke_net`-turneringer, med to punkter bevisst utenfor (se
|
||||||
|
FEATURE_BACKLOG.md): frittstående runder, og en kolonne på lagturnering-
|
||||||
|
øktens individuelle leaderboard. Bruker ba om å fullføre begge i denne
|
||||||
|
runden ("La oss fullføre Jakter Jesper").
|
||||||
|
|
||||||
|
**Beslutning A -- Del A (frittstående runder): ny kolonne
|
||||||
|
`round.net_display_style`, samme fail-loudly-mønster som ADR-106, kun
|
||||||
|
gyldig for `play_format='stroke'`.** `_load_round_out`s
|
||||||
|
`my_net_score_to_par` og `_build_leaderboard`s `net_score_to_par` bytter
|
||||||
|
fra `- total_strokes_received` (gradvis) til `- course_handicap_snapshot`
|
||||||
|
(helt) når hunt er aktiv -- ren swap på backend. Nøkkelforskjell fra
|
||||||
|
org-individuell: `round-leaderboard.tsx` regner netto-til-par selv,
|
||||||
|
klientsidig, fra rå `strokes_received` (ikke fra backendens felt) --
|
||||||
|
løsningen fikk derfor en ny parallell klientsidig `huntNettoToPar()`-
|
||||||
|
funksjon i tillegg til backend-swappen, ikke bare en backend-endring.
|
||||||
|
|
||||||
|
**Beslutning B -- Del B (lagturnering-økt): ny kolonne
|
||||||
|
`session.net_display_style`, PER ØKT, ikke per turnering.** Vurdert og
|
||||||
|
avvist: gjenbruk av `tournament.net_display_style` (allerede en kolonne)
|
||||||
|
-- men lagturneringer har `scoring_method=NULL` alltid, og
|
||||||
|
`tournament-detail.tsx` (lagformat) har ingen motpart til
|
||||||
|
`individual-tournament-detail.tsx` sin "Resultat og opsjoner"-fane;
|
||||||
|
`TournamentSettingsCard` importeres ikke der. En turneringnivå-bryter
|
||||||
|
ville krevd en helt ny innstillings-UI-seksjon; en øktnivå-kolonne
|
||||||
|
kunne derimot henge rett på den eksisterende `EditSessionForm`. Også mer
|
||||||
|
presist: en lagturnering kan ha noen singles/fourball-økter i hunt-modus
|
||||||
|
og andre ikke. Gyldig kun for `format IN ('singles','fourball') AND
|
||||||
|
scoring_mode='stroke'` (samme betingelse `fetch_individual_leaderboard`
|
||||||
|
allerede håndhevet ved lesing -- nå også håndhevet proaktivt ved lagring
|
||||||
|
i `update_session`). `net_score`/`net_to_par` i
|
||||||
|
`fetch_individual_leaderboard` bytter fra `- strokes_received_total`
|
||||||
|
til `- match_participant.playing_handicap` når hunt er aktiv, samme
|
||||||
|
"swap, ikke ny beregning"-filosofi. `total_points`/Stableford-formelen
|
||||||
|
er uendret og upåvirket i begge deler, som forventet (kun til-par-tallet
|
||||||
|
endrer presentasjon, poengberegning bryr seg aldri om hvor mye handicap
|
||||||
|
er "opptjent" på et gitt tidspunkt).
|
||||||
|
|
||||||
|
**Funn under scratch-verifisering: en tredje, tidligere uidentifisert
|
||||||
|
netto-beregning.** Planen dekket `my_net_score_to_par` (rounds.py) og
|
||||||
|
`round-leaderboard.tsx`s `nettoToPar`, men rundekortets "Så
|
||||||
|
langt"-oppsummeringswidget (`ScoreSoFar` i round-detail.tsx) regner sin
|
||||||
|
egen `nettoTotal` uavhengig, fra `strokes_received` per hull -- helt uten
|
||||||
|
hunt-bevissthet. Oppdaget live i scratch (viste feil `+15` i stedet for
|
||||||
|
korrekt `+8` for en 12-hcp-spiller med 20 brutto slag over 5 hull). Fikset
|
||||||
|
med samme `courseHandicap`/`netDisplayStyle`-props-mønster og re-
|
||||||
|
verifisert. Nøyaktig den typen glipp fullstack-scratch-disiplinen finnes
|
||||||
|
for å fange -- planen alene hadde ikke fanget den.
|
||||||
|
|
||||||
|
**Status:** 6 nye pytest-tester (3 for hver del, samme konvergens-bevis-
|
||||||
|
mønster som ADR-106: løpende tall skiller seg fra standard midt i
|
||||||
|
runden/økten, konvergerer eksakt ved 18 fullførte hull; validering begge
|
||||||
|
veier for begge deler) + full regresjonssuite, 204/204 grønt. `tsc
|
||||||
|
--noEmit` rent.
|
||||||
|
|
||||||
|
Full-stack scratch-verifisert (egen db + to one-off-containere, ekte
|
||||||
|
innlogging): en frittstående runde (hcp 12) -- rundekort OG leaderboard
|
||||||
|
viste -12 midt i runden under hunt (5 hull, alle par), konvergerte til
|
||||||
|
samme -12 ved 18 fullførte hull under standard (hånd-utregnet på
|
||||||
|
forhånd, matchet eksakt). En lagturnering-singles-økt, identisk oppsett
|
||||||
|
-- leaderboardet viste net_score=-8/net_to_par=-12 under hunt, -5 under
|
||||||
|
standard etter tilbakebytting (5 hull, stroke_index 1-5, alle mottar
|
||||||
|
slag). Bekreftet bryterne kun vises der de gir mening
|
||||||
|
(`EditRoundPanel` kun for `play_format='stroke'`, `EditSessionForm` kun
|
||||||
|
for singles/fourball+stroke). Mørk modus + smal mobilskjerm (375px)
|
||||||
|
bekreftet lesbart for begge deler, inkludert hunt-bannerets kontrast og
|
||||||
|
tekstbryting. Alle scratch-ressurser ryddet opp (containere, db, rolle,
|
||||||
|
images).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
64
CHANGELOG.md
64
CHANGELOG.md
|
|
@ -14744,3 +14744,67 @@ Neste steg:
|
||||||
eksisterende data endret). `docker compose build teecup_api
|
eksisterende data endret). `docker compose build teecup_api
|
||||||
teecup_frontend && up -d`, begge containere friske, rene logger.
|
teecup_frontend && up -d`, begge containere friske, rene logger.
|
||||||
`https://teecup.golf/health` 200 etter utrulling.
|
`https://teecup.golf/health` 200 etter utrulling.
|
||||||
|
|
||||||
|
167. **"Jakter Jesper" del 2: frittstående runder + lagturnering-økt-
|
||||||
|
leaderboard (ADR-107), 2026-08-24.** Bruker: "La oss fullføre Jakter
|
||||||
|
Jesper" -- fullførte begge oppfølgingspunktene som ADR-106 bevisst
|
||||||
|
lot ligge (se FEATURE_BACKLOG.md).
|
||||||
|
|
||||||
|
**Del A -- frittstående runder:** ny kolonne `round.net_display_style`
|
||||||
|
(migrasjon 093), kun gyldig for `play_format='stroke'`, samme
|
||||||
|
fail-loudly-mønster begge veier som ADR-106. `my_net_score_to_par`
|
||||||
|
(rounds.py) og leaderboardets `net_score_to_par` bytter fra gradvis
|
||||||
|
(`- total_strokes_received`) til helt (`- course_handicap_snapshot`)
|
||||||
|
når hunt er aktiv. Nøkkelforskjell fra org-individuell:
|
||||||
|
`round-leaderboard.tsx` regner netto-til-par klientsidig fra rå
|
||||||
|
`strokes_received`, ikke fra et backend-felt -- løsningen fikk derfor
|
||||||
|
en ny parallell `huntNettoToPar()`-funksjon der, i tillegg til
|
||||||
|
backend-swappen. `EditRoundPanel` fikk en ny bryter (samme
|
||||||
|
segmenterte-knapp-mønster som `flagMapVisible`), synlig kun for
|
||||||
|
`play_format==='stroke'`.
|
||||||
|
|
||||||
|
**Del B -- lagturnering-økt-leaderboard:** ny kolonne
|
||||||
|
`session.net_display_style` (samme migrasjon), PER ØKT ikke per
|
||||||
|
turnering -- vurdert og avvist: gjenbruk av
|
||||||
|
`tournament.net_display_style`, men lagturneringer har
|
||||||
|
`scoring_method=NULL` alltid og `tournament-detail.tsx` har ingen
|
||||||
|
motpart til `individual-tournament-detail.tsx` sin
|
||||||
|
"Resultat og opsjoner"-fane (`TournamentSettingsCard` importeres
|
||||||
|
ikke der). En øktnivå-kolonne kunne i stedet henge rett på den
|
||||||
|
allerede eksisterende `EditSessionForm` -- mindre nytt UI, og mer
|
||||||
|
presist (en lagturnering kan ha noen singles/fourball-økter i
|
||||||
|
hunt-modus og andre ikke). Gyldig for `format IN
|
||||||
|
('singles','fourball') AND scoring_mode='stroke'` (samme betingelse
|
||||||
|
`fetch_individual_leaderboard` allerede håndhevet ved lesing, nå også
|
||||||
|
håndhevet proaktivt ved lagring i `update_session`).
|
||||||
|
`net_score`/`net_to_par` i `fetch_individual_leaderboard` bytter fra
|
||||||
|
gradvis til `- match_participant.playing_handicap` når hunt er
|
||||||
|
aktiv. `total_points`/Stableford-formelen uendret i begge deler, som
|
||||||
|
forventet.
|
||||||
|
|
||||||
|
**Funn under scratch-verifisering:** rundekortets "Så langt"-widget
|
||||||
|
(`ScoreSoFar`, round-detail.tsx) hadde en TREDJE, uavhengig
|
||||||
|
netto-beregning (fra `strokes_received` per hull) som ikke var
|
||||||
|
hunt-bevisst -- ikke identifisert i planen på forhånd. Viste feil
|
||||||
|
`+15` i stedet for korrekt `+8` (12-hcp, 20 brutto slag over 5 hull)
|
||||||
|
inntil oppdaget live i scratch. Fikset med samme
|
||||||
|
`courseHandicap`/`netDisplayStyle`-props-mønster og re-verifisert
|
||||||
|
("Netto +8" korrekt etter fiks). Fanget nettopp fordi fullstack-
|
||||||
|
scratch-disiplinen ble fulgt i stedet for å stole på planen alene.
|
||||||
|
|
||||||
|
**Verifisert:** 6 nye pytest-tester (`test_round_net_hunt_
|
||||||
|
display.py`, `test_session_net_hunt_display.py`, 3 hver, samme
|
||||||
|
konvergens-bevis-mønster som ADR-106: løpende tall skiller seg fra
|
||||||
|
standard midt i runden/økten, konvergerer eksakt ved 18 fullførte
|
||||||
|
hull) + full regresjonssuite, 204/204 grønt. `tsc --noEmit` rent.
|
||||||
|
Full-stack scratch (egen db + to one-off-containere, ekte innlogging):
|
||||||
|
en frittstående runde (hcp 12, 5 hull par) -- rundekort OG leaderboard
|
||||||
|
viste -12 under hunt, konvergerte til samme -12 ved 18 fullførte hull
|
||||||
|
under standard, hånd-utregnet på forhånd og matchet eksakt. En
|
||||||
|
lagturnering-singles-økt, identisk oppsett -- leaderboardet viste
|
||||||
|
net_score=-8/net_to_par=-12 under hunt, -5 under standard etter
|
||||||
|
tilbakebytting (5 hull, stroke_index 1-5, alle mottar slag).
|
||||||
|
Bekreftet bryterne kun vises der de gir mening. Mørk modus + smal
|
||||||
|
mobilskjerm (375px) bekreftet lesbart for begge deler, inkludert
|
||||||
|
hunt-bannerets kontrast og tekstbryting. Alle scratch-ressurser
|
||||||
|
ryddet opp (containere, db, rolle, images).
|
||||||
|
|
|
||||||
|
|
@ -5048,7 +5048,7 @@ Uavklart, trenger en egen avklaringsrunde FØR bygging:
|
||||||
eksakt triggerlogikk, plassering) FØR design/bygging -- ingen
|
eksakt triggerlogikk, plassering) FØR design/bygging -- ingen
|
||||||
beslutninger tatt ennå, kun fanget opp her for å ikke gå tapt.
|
beslutninger tatt ennå, kun fanget opp her for å ikke gå tapt.
|
||||||
|
|
||||||
## Jakter Jesper (visningsmodus for netto-scoring) — ✅ ORG-INDIVIDUELL FERDIG OG LIVE 2026-08-23 (ADR-106), TO OPPFØLGINGER BEVISST UTENFOR DENNE RUNDEN
|
## Jakter Jesper (visningsmodus for netto-scoring) — ✅ HELT FERDIG OG LIVE 2026-08-24 (ADR-106 + ADR-107)
|
||||||
|
|
||||||
Bruker beskrev en turneringsform (start på -spillehandicap, hullscore
|
Bruker beskrev en turneringsform (start på -spillehandicap, hullscore
|
||||||
brutto mot par løpende, lavest sluttsum vinner, tiebreak på lavest
|
brutto mot par løpende, lavest sluttsum vinner, tiebreak på lavest
|
||||||
|
|
@ -5057,31 +5057,29 @@ som en VISNINGSMODUS på eksisterende `stroke_net`-scoring (sluttsummen er
|
||||||
matematisk identisk med netto-til-par), ikke en ny scoring_method/
|
matematisk identisk med netto-til-par), ikke en ny scoring_method/
|
||||||
turneringsform -- se ADR-106 for hele resonnementet.
|
turneringsform -- se ADR-106 for hele resonnementet.
|
||||||
|
|
||||||
**Ferdig, live:** ny `tournament.net_display_style` ('standard'/'hunt',
|
**Ferdig, live 2026-08-23 (ADR-106):** ny `tournament.net_display_style`
|
||||||
migrasjon 092) for org-individuelle turneringer, pluss en ny, generelt
|
('standard'/'hunt', migrasjon 092) for org-individuelle turneringer,
|
||||||
tilgjengelig tiebreak-verdi `lowest_playing_hcp`.
|
pluss en ny, generelt tilgjengelig tiebreak-verdi `lowest_playing_hcp`.
|
||||||
|
|
||||||
**Bevisst UTENFOR denne runden, bruker bekreftet sekvensiell bygging
|
**Ferdig, live 2026-08-24 (ADR-107) -- begge tidligere bevisst utsatte
|
||||||
("org-individuell først, deretter egne oppfølgingsrunder"):**
|
oppfølgere fullført i samme runde:**
|
||||||
|
|
||||||
1. **Frittstående runder** (`round.play_format`) -- samme
|
1. **Frittstående runder** (`round.net_display_style`, migrasjon 093).
|
||||||
"hunt"-visning for personlige/vennegjeng-runder med netto-scoring.
|
`my_net_score_to_par` (rounds.py) og leaderboardets `net_score_to_par`
|
||||||
`round-stats.tsx`/`round-leaderboard.tsx` sin netto-til-par-visning
|
bytter fra gradvis (`- total_strokes_received`) til helt (`-
|
||||||
(`my_net_score_to_par`, `rounds.py`) er det naturlige stedet å
|
course_handicap_snapshot`) når hunt er aktiv. `round-leaderboard.tsx`
|
||||||
speile `_attach_stroke_play_columns` sin `stroke_val()`-logikk --
|
fikk en egen klientsidig `huntNettoToPar()` (regner netto-til-par selv,
|
||||||
samme partial-vs-full-handicap-prinsipp, men et annet datagrunnlag
|
ulikt org-individuell). Underveis avdekket og fikset: rundekortets
|
||||||
(`round_hole`/`round_participant`, ikke `tournament_round_hole`/
|
"Så langt"-widget (`ScoreSoFar`, round-detail.tsx) hadde en tredje,
|
||||||
`tournament_round_participant`). Ikke designet i detalj ennå.
|
uavhengig netto-beregning som ikke var hunt-bevisst -- se ADR-107.
|
||||||
2. **Kolonne på lagturnering-øktens individuelle leaderboard**
|
2. **Kolonne på lagturnering-øktens individuelle leaderboard**
|
||||||
(`fetch_individual_leaderboard`, `app/routers/tournaments.py`,
|
(`session.net_display_style`, migrasjon 093, PER ØKT ikke per
|
||||||
singles/fourball-økter i en org-LAG-turnering). Denne visningen har
|
turnering -- se ADR-107 for begrunnelsen). Gyldig for
|
||||||
INGEN `scoring_method`-velger i dag (viser gross/netto/poeng side om
|
`format IN ('singles','fourball') AND scoring_mode='stroke'`.
|
||||||
side, fast oppsett) -- en hunt-kolonne ville være additiv der (samme
|
`fetch_individual_leaderboard` bytter `net_score`/`net_to_par` fra
|
||||||
prinsipp som `IndividualLeaderboardEntry` sitt allerede eksisterende
|
gradvis til `- match_participant.playing_handicap` når hunt er aktiv.
|
||||||
`net_to_par`-felt), IKKE en ny `scoring_method` for selve
|
|
||||||
lagturneringen (ADR-011s to-lags-modell har strukturelt ikke det
|
|
||||||
begrepet -- bekreftet i ADR-106). Ikke designet i detalj ennå.
|
|
||||||
|
|
||||||
**Neste steg:** ingen av de to er startet. Ta fatt på dem som egne,
|
**Verifisert:** 6 nye pytest-tester (konvergens-bevis-mønster, begge
|
||||||
avgrensede runder når/hvis bruker prioriterer dem -- ikke antatt
|
deler) + full regresjon 204/204 grønt, `tsc --noEmit` rent, full-stack
|
||||||
hastende, kun fanget opp her for å ikke gå tapt.
|
scratch (desktop+mobil, lys+mørk). Se ADR-107 og CHANGELOG.md #167 for
|
||||||
|
fullstendig detalj.
|
||||||
|
|
|
||||||
|
|
@ -1477,6 +1477,10 @@ class RoundOut(BaseModel):
|
||||||
allowance_override: dict | None
|
allowance_override: dict | None
|
||||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
flag_map_visible: bool
|
flag_map_visible: bool
|
||||||
|
# "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
|
||||||
|
# KUN meningsfullt for play_format='stroke'. Se my_net_score_to_par
|
||||||
|
# under for hvordan 'hunt' faktisk endrer beregningen.
|
||||||
|
net_display_style: str
|
||||||
|
|
||||||
|
|
||||||
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
|
|
@ -1486,7 +1490,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||||
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
|
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
|
||||||
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
|
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
|
||||||
allowance_override::text AS allowance_override, flag_map_visible
|
allowance_override::text AS allowance_override, flag_map_visible, net_display_style
|
||||||
FROM round WHERE id = $1
|
FROM round WHERE id = $1
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -1557,6 +1561,17 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
total_strokes_received = sum(
|
total_strokes_received = sum(
|
||||||
strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None
|
strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None
|
||||||
)
|
)
|
||||||
|
# "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun
|
||||||
|
# gyldig for play_format='stroke' (håndhevet ved lagring i
|
||||||
|
# RoundUpdate/update_round, så en stableford-runde ser aldri
|
||||||
|
# net_display_style='hunt' her). Trekker fra HELE spille-
|
||||||
|
# handicapet med én gang i stedet for kun de slagene som er
|
||||||
|
# "tjent inn" for de spilte hullene -- konvergerer til
|
||||||
|
# NØYAKTIG samme tall som standard-visningen ved 18 fullførte
|
||||||
|
# hull (samme bevis som org-individuell, se ADR-106).
|
||||||
|
if round_row["net_display_style"] == "hunt":
|
||||||
|
my_net_score_to_par = (my_score_to_par or 0) - target_row["course_handicap_snapshot"]
|
||||||
|
else:
|
||||||
my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received
|
my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received
|
||||||
|
|
||||||
# Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN.
|
# Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN.
|
||||||
|
|
@ -1612,6 +1627,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
flight_group_id=round_row["flight_group_id"],
|
flight_group_id=round_row["flight_group_id"],
|
||||||
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
||||||
flag_map_visible=round_row["flag_map_visible"],
|
flag_map_visible=round_row["flag_map_visible"],
|
||||||
|
net_display_style=round_row["net_display_style"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2755,6 +2771,12 @@ class RoundUpdate(BaseModel):
|
||||||
# (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw);
|
# (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw);
|
||||||
# None = ikke rør, samme konvensjon som resten av modellen her.
|
# None = ikke rør, samme konvensjon som resten av modellen her.
|
||||||
flag_map_visible: bool | None = None
|
flag_map_visible: bool | None = None
|
||||||
|
# "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
|
||||||
|
# eier-only, samme "None = ikke rør"-konvensjon. Kun gyldig for
|
||||||
|
# play_format='stroke' -- håndhevet i update_round (fail-loudly 400
|
||||||
|
# for andre formater, samme prinsipp som org-individuell sin
|
||||||
|
# scoring_method-sjekk).
|
||||||
|
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||||
|
|
@ -2787,7 +2809,7 @@ async def update_round(
|
||||||
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)
|
||||||
round_row = await conn.fetchrow(
|
round_row = await conn.fetchrow(
|
||||||
"SELECT started_at, completed_at, play_format FROM round WHERE id = $1", round_id
|
"SELECT started_at, completed_at, play_format, net_display_style FROM round WHERE id = $1", round_id
|
||||||
)
|
)
|
||||||
is_completed = round_row["completed_at"] is not None
|
is_completed = round_row["completed_at"] is not None
|
||||||
|
|
||||||
|
|
@ -2812,6 +2834,23 @@ async def update_round(
|
||||||
):
|
):
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")
|
raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")
|
||||||
|
|
||||||
|
# "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som
|
||||||
|
# org-individuell sin scoring_method-sjekk (ARCHITECTURE_DECISIONS.md
|
||||||
|
# ADR-106): 'hunt' gir ingen mening utenfor play_format='stroke'.
|
||||||
|
# Sjekker den EFFEKTIVE tilstanden ETTER denne PATCH-en (dekker BEGGE
|
||||||
|
# retninger -- satt til 'hunt' på feil format, ELLER play_format
|
||||||
|
# byttet bort fra 'stroke' mens 'hunt' henger igjen fra før).
|
||||||
|
if body.net_display_style is not None or body.play_format is not None:
|
||||||
|
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
|
||||||
|
effective_style = (
|
||||||
|
body.net_display_style if body.net_display_style is not None else round_row["net_display_style"]
|
||||||
|
)
|
||||||
|
if effective_style == "hunt" and effective_play_format != "stroke":
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Jakter Jesper-visningen krever play_format='stroke'.",
|
||||||
|
)
|
||||||
|
|
||||||
resolved: _ResolvedCourse | None = None
|
resolved: _ResolvedCourse | None = None
|
||||||
participants = None
|
participants = None
|
||||||
if body.course_source is not None:
|
if body.course_source is not None:
|
||||||
|
|
@ -2878,6 +2917,10 @@ async def update_round(
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible
|
"UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible
|
||||||
)
|
)
|
||||||
|
if body.net_display_style is not None:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE round SET net_display_style = $2 WHERE id = $1", round_id, body.net_display_style
|
||||||
|
)
|
||||||
if body.flight_group_id is not None:
|
if body.flight_group_id is not None:
|
||||||
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
|
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
|
||||||
if body.visible_categories is not None:
|
if body.visible_categories is not None:
|
||||||
|
|
@ -5245,19 +5288,30 @@ class LeaderboardEntryOut(BaseModel):
|
||||||
# allerede ble hentet for å beregne total_score/net_score_to_par over,
|
# allerede ble hentet for å beregne total_score/net_score_to_par over,
|
||||||
# bare også eksponert rått slik at leaderboardet kan vise dem per hull.
|
# bare også eksponert rått slik at leaderboardet kan vise dem per hull.
|
||||||
holes: list[LeaderboardHoleOut]
|
holes: list[LeaderboardHoleOut]
|
||||||
|
# "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- rå course
|
||||||
|
# handicap eksponert slik at frontend (som allerede regner netto-til-
|
||||||
|
# par selv, klientsidig, fra strokes_received per hull) kan regne ut
|
||||||
|
# hunt-visningens tall selv (gross-til-par minus HELE handicapet) uten
|
||||||
|
# en egen backend-utregningsvei. Samme None-betingelse som net_score_
|
||||||
|
# to_par over.
|
||||||
|
course_handicap: int | None
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardOut(BaseModel):
|
class LeaderboardOut(BaseModel):
|
||||||
holes_planned: int
|
holes_planned: int
|
||||||
completed: bool
|
completed: bool
|
||||||
entries: list[LeaderboardEntryOut]
|
entries: list[LeaderboardEntryOut]
|
||||||
|
# "Jakter Jesper" (migrasjon 093) -- hvilken visningsmodus runden er
|
||||||
|
# satt til. Frontend velger nettoToPar vs. huntNettoToPar basert på
|
||||||
|
# denne, se round-leaderboard.tsx.
|
||||||
|
net_display_style: str
|
||||||
|
|
||||||
|
|
||||||
async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||||
/public/rounds-varianten (ADR-036 fase 2)."""
|
/public/rounds-varianten (ADR-036 fase 2)."""
|
||||||
round_row = await conn.fetchrow(
|
round_row = await conn.fetchrow(
|
||||||
"SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
|
"SELECT holes_planned, completed_at, net_display_style FROM round WHERE id = $1", round_id
|
||||||
)
|
)
|
||||||
participant_rows = await conn.fetch(
|
participant_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
|
|
@ -5297,7 +5351,20 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
total_points = None
|
total_points = None
|
||||||
if total_score is not None and strokes_received_by_hole:
|
if total_score is not None and strokes_received_by_hole:
|
||||||
strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes)
|
strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes)
|
||||||
net_score_to_par = score_to_par - strokes_received_total
|
# "Jakter Jesper" (migrasjon 093) -- kun netto-til-par-tallet
|
||||||
|
# bytter til hunt-semantikk (helt handicap trukket fra med én
|
||||||
|
# gang). total_points (Stableford) er UBERØRT uansett -- poeng
|
||||||
|
# tildeles per hull ut fra DET hullets netto, ingen "hunt"-
|
||||||
|
# ekvivalent gir mening for en poengsum (ikke en til-par-
|
||||||
|
# størrelse). `RoundLeaderboardMini` (valueFor/rankEntries)
|
||||||
|
# leser dette feltet direkte -- får hunt-tallet "gratis" her,
|
||||||
|
# samme "gjenbruk feltet, ikke egen visningsvei"-mønster som
|
||||||
|
# org-individuell sine offentlige/utskrift-sider (ADR-106).
|
||||||
|
net_score_to_par = (
|
||||||
|
score_to_par - p["course_handicap_snapshot"]
|
||||||
|
if round_row["net_display_style"] == "hunt"
|
||||||
|
else score_to_par - strokes_received_total
|
||||||
|
)
|
||||||
# Samme stableford-formel som round-scorecard.tsx sin
|
# Samme stableford-formel som round-scorecard.tsx sin
|
||||||
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
|
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
|
||||||
# gulvet på 0.
|
# gulvet på 0.
|
||||||
|
|
@ -5320,6 +5387,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
score_to_par=score_to_par,
|
score_to_par=score_to_par,
|
||||||
net_score_to_par=net_score_to_par,
|
net_score_to_par=net_score_to_par,
|
||||||
total_points=total_points,
|
total_points=total_points,
|
||||||
|
course_handicap=p["course_handicap_snapshot"],
|
||||||
holes=[
|
holes=[
|
||||||
LeaderboardHoleOut(
|
LeaderboardHoleOut(
|
||||||
hole_number=h["hole_number"],
|
hole_number=h["hole_number"],
|
||||||
|
|
@ -5341,6 +5409,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
holes_planned=round_row["holes_planned"],
|
holes_planned=round_row["holes_planned"],
|
||||||
completed=round_row["completed_at"] is not None,
|
completed=round_row["completed_at"] is not None,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
|
net_display_style=round_row["net_display_style"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -962,6 +962,14 @@ class SessionOut(BaseModel):
|
||||||
shamble_best_n: int | None
|
shamble_best_n: int | None
|
||||||
locked_team_ids: list[str]
|
locked_team_ids: list[str]
|
||||||
revealed: bool
|
revealed: bool
|
||||||
|
# "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- KUN
|
||||||
|
# meningsfullt for format IN ('singles','fourball') OG
|
||||||
|
# scoring_mode='stroke' (samme betingelse fetch_individual_leaderboard
|
||||||
|
# allerede håndhever ved lesing). Øktnivå, ikke turneringnivå -- se
|
||||||
|
# ADR-106-tillegget for hvorfor (lagturneringer har ikke
|
||||||
|
# scoring_method på selve turneringen, og har ingen tilsvarende
|
||||||
|
# innstillings-UI på turneringnivå slik individuelle turneringer har).
|
||||||
|
net_display_style: str
|
||||||
|
|
||||||
|
|
||||||
def _session_out(row, locked: set[str]) -> SessionOut:
|
def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
|
|
@ -982,6 +990,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
shamble_best_n=row["shamble_best_n"],
|
shamble_best_n=row["shamble_best_n"],
|
||||||
locked_team_ids=sorted(locked),
|
locked_team_ids=sorted(locked),
|
||||||
revealed=len(locked) >= 2,
|
revealed=len(locked) >= 2,
|
||||||
|
net_display_style=row["net_display_style"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -994,7 +1003,8 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
|
||||||
|
net_display_style
|
||||||
FROM session
|
FROM session
|
||||||
WHERE tournament_id = $1
|
WHERE tournament_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -1056,7 +1066,8 @@ async def create_session(
|
||||||
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
|
||||||
|
net_display_style
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -1093,6 +1104,11 @@ class SessionUpdate(BaseModel):
|
||||||
points_per_match: float | None = None
|
points_per_match: float | None = None
|
||||||
allowance_override: dict | None = None
|
allowance_override: dict | None = None
|
||||||
course_id: str | None = None
|
course_id: str | None = None
|
||||||
|
# "Jakter Jesper"-visningsmodus (migrasjon 093) -- se SessionOut sin
|
||||||
|
# kommentar. Gyldigheten mot format/scoring_mode håndheves i
|
||||||
|
# update_session under (denne modellen kjenner ikke øktens gjeldende
|
||||||
|
# format/scoring_mode).
|
||||||
|
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
|
||||||
|
|
||||||
|
|
||||||
async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
|
async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
|
||||||
|
|
@ -1189,12 +1205,26 @@ async def update_session(
|
||||||
|
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"SELECT format, course_id::text AS course_id FROM session WHERE id = $1",
|
"SELECT format, scoring_mode, course_id::text AS course_id FROM session WHERE id = $1",
|
||||||
session_id,
|
session_id,
|
||||||
)
|
)
|
||||||
if session is None:
|
if session is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
||||||
|
|
||||||
|
# "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som
|
||||||
|
# org-individuell (ADR-106). format/scoring_mode kan ALDRI selv
|
||||||
|
# endres i samme kall (SessionUpdate ekskluderer dem bevisst, se
|
||||||
|
# modellens docstring), så det er alltid ØKTENS LAGREDE verdi som
|
||||||
|
# avgjør -- ingen "effektiv verdi etter denne PATCH-en"-vurdering
|
||||||
|
# nødvendig her, ulikt tournament/round-varianten.
|
||||||
|
if updates.get("net_display_style") == "hunt" and not (
|
||||||
|
session["format"] in ("singles", "fourball") and session["scoring_mode"] == "stroke"
|
||||||
|
):
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Jakter Jesper-visningen krever singles/fourball med brutto-/nettoslagspill.",
|
||||||
|
)
|
||||||
|
|
||||||
new_course_id = updates.pop("course_id", None)
|
new_course_id = updates.pop("course_id", None)
|
||||||
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
|
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
|
||||||
|
|
||||||
|
|
@ -1233,7 +1263,8 @@ async def update_session(
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
|
||||||
|
net_display_style
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -1903,6 +1934,9 @@ class IndividualLeaderboard(BaseModel):
|
||||||
session_id: str
|
session_id: str
|
||||||
format: str
|
format: str
|
||||||
entries: list[IndividualLeaderboardEntry]
|
entries: list[IndividualLeaderboardEntry]
|
||||||
|
# "Jakter Jesper" (migrasjon 093) -- frontend viser en forklarende
|
||||||
|
# banner og vet dermed hvorfor "Netto"-tallene ser dype/uvante ut.
|
||||||
|
net_display_style: str
|
||||||
|
|
||||||
|
|
||||||
async def fetch_individual_leaderboard(
|
async def fetch_individual_leaderboard(
|
||||||
|
|
@ -1911,7 +1945,8 @@ async def fetch_individual_leaderboard(
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
|
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
|
||||||
hole_config::text AS hole_config, course_id::text AS course_id
|
hole_config::text AS hole_config, course_id::text AS course_id,
|
||||||
|
net_display_style
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -1980,6 +2015,15 @@ async def fetch_individual_leaderboard(
|
||||||
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
|
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
|
||||||
strokes_by_hole = dict(zip(played, strokes_list))
|
strokes_by_hole = dict(zip(played, strokes_list))
|
||||||
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
|
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
|
||||||
|
# "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun
|
||||||
|
# net_score/net_to_par bytter til hunt-semantikk (helt
|
||||||
|
# spillehandicap trukket fra med én gang). total_points
|
||||||
|
# (Stableford) er UBERØRT -- poeng tildeles per hull ut fra
|
||||||
|
# DET hullets netto, ingen "hunt"-ekvivalent for en poengsum.
|
||||||
|
if session["net_display_style"] == "hunt":
|
||||||
|
net_score = gross_score - r["playing_handicap"]
|
||||||
|
net_to_par = gross_to_par - r["playing_handicap"]
|
||||||
|
else:
|
||||||
net_score = gross_score - strokes_received_total
|
net_score = gross_score - strokes_received_total
|
||||||
net_to_par = gross_to_par - strokes_received_total
|
net_to_par = gross_to_par - strokes_received_total
|
||||||
# Samme stableford-formel som round-leaderboardets
|
# Samme stableford-formel som round-leaderboardets
|
||||||
|
|
@ -2011,7 +2055,10 @@ async def fetch_individual_leaderboard(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
|
return IndividualLeaderboard(
|
||||||
|
session_id=session_id, format=session["format"], entries=entries,
|
||||||
|
net_display_style=session["net_display_style"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,9 @@ type ApiRound = {
|
||||||
flight_group_id: string | null
|
flight_group_id: string | null
|
||||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
flag_map_visible: boolean
|
flag_map_visible: boolean
|
||||||
|
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
|
||||||
|
// kun relevant for play_format='stroke'.
|
||||||
|
net_display_style: "standard" | "hunt"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
|
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
|
||||||
|
|
@ -1717,7 +1720,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
løpende til-par, så dette er nå rendyrket "se mer detaljer"
|
løpende til-par, så dette er nå rendyrket "se mer detaljer"
|
||||||
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
||||||
score-registreringen. */}
|
score-registreringen. */}
|
||||||
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
{!completed && (
|
||||||
|
<ScoreSoFar
|
||||||
|
holes={apiHoles}
|
||||||
|
order={holeOrder}
|
||||||
|
courseHandicap={activePlayer.courseHandicap}
|
||||||
|
netDisplayStyle={round.net_display_style}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -2488,7 +2498,20 @@ function formatSignedAvg(n: number): string {
|
||||||
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
|
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
|
function ScoreSoFar({
|
||||||
|
holes,
|
||||||
|
order,
|
||||||
|
courseHandicap,
|
||||||
|
netDisplayStyle = "standard",
|
||||||
|
}: {
|
||||||
|
holes: ApiHole[] | undefined
|
||||||
|
order: number[]
|
||||||
|
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- brukt kun når
|
||||||
|
// netDisplayStyle='hunt', for å regne ut nettoTotal uten gradvis
|
||||||
|
// strokes_received-allokering (se under).
|
||||||
|
courseHandicap?: number | null
|
||||||
|
netDisplayStyle?: "standard" | "hunt"
|
||||||
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
if (!holes || holes.length === 0) return null
|
if (!holes || holes.length === 0) return null
|
||||||
|
|
||||||
|
|
@ -2508,8 +2531,14 @@ function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: num
|
||||||
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
|
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
|
||||||
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
||||||
const hasNetto = withNetto.length > 0
|
const hasNetto = withNetto.length > 0
|
||||||
|
// "Jakter Jesper" (migrasjon 093) -- trekker fra HELE course handicap med
|
||||||
|
// én gang i stedet for gradvis strokes_received-sum. Faller tilbake til
|
||||||
|
// standard hvis courseHandicap uventet mangler (bør ikke skje -- samme
|
||||||
|
// deltaker har jo strokes_received satt, som krever en beregnet HCP).
|
||||||
const nettoTotal = hasNetto
|
const nettoTotal = hasNetto
|
||||||
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
|
? netDisplayStyle === "hunt" && courseHandicap !== null && courseHandicap !== undefined
|
||||||
|
? totalStrokes - courseHandicap
|
||||||
|
: withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
|
||||||
: null
|
: null
|
||||||
const stablefordTotal = hasNetto
|
const stablefordTotal = hasNetto
|
||||||
? withNetto.reduce(
|
? withNetto.reduce(
|
||||||
|
|
@ -4543,6 +4572,9 @@ function EditRoundPanel({
|
||||||
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
||||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible)
|
const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible)
|
||||||
|
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
|
||||||
|
// kun relevant for play_format='stroke'.
|
||||||
|
const [netDisplayStyle, setNetDisplayStyle] = useState(round.net_display_style)
|
||||||
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)
|
||||||
|
|
@ -4559,7 +4591,8 @@ function EditRoundPanel({
|
||||||
playFormat !== currentEditablePlayFormat ||
|
playFormat !== currentEditablePlayFormat ||
|
||||||
visibilityMode !== round.visibility_mode ||
|
visibilityMode !== round.visibility_mode ||
|
||||||
(visibilityMode === "friends" && !sameCategories) ||
|
(visibilityMode === "friends" && !sameCategories) ||
|
||||||
flagMapVisible !== round.flag_map_visible
|
flagMapVisible !== round.flag_map_visible ||
|
||||||
|
netDisplayStyle !== round.net_display_style
|
||||||
|
|
||||||
async function saveMetadata() {
|
async function saveMetadata() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -4580,6 +4613,7 @@ function EditRoundPanel({
|
||||||
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
||||||
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
||||||
if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible
|
if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible
|
||||||
|
if (netDisplayStyle !== round.net_display_style) body.net_display_style = netDisplayStyle
|
||||||
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)
|
||||||
|
|
@ -4770,6 +4804,42 @@ function EditRoundPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
|
||||||
|
kun relevant for slagspill. Sluttresultatet blir identisk uansett,
|
||||||
|
bryteren endrer kun hvordan netto-tallet vises LØPENDE. */}
|
||||||
|
{playFormat === "stroke" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Visningsmodus for netto-score</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: "standard" as const, label: "Standard" },
|
||||||
|
{ value: "hunt" as const, label: "Jakter Jesper" },
|
||||||
|
]
|
||||||
|
).map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNetDisplayStyle(opt.value)}
|
||||||
|
aria-pressed={netDisplayStyle === opt.value}
|
||||||
|
className={cn(
|
||||||
|
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||||||
|
netDisplayStyle === opt.value
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Jakter Jesper: alle starter på minus spillehandicapet sitt, og hullscore vises brutto mot par
|
||||||
|
løpende -- i stedet for at slag "tjenes inn" gradvis gjennom runden. Standard som standard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isCompleted && (
|
{!isCompleted && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Trophy } from "lucide-react"
|
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Target, Trophy } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||||
import { MatchScorecardGrid } from "@/components/round-scorecard"
|
import { MatchScorecardGrid } from "@/components/round-scorecard"
|
||||||
|
|
@ -49,12 +49,16 @@ type ApiLeaderboardEntry = {
|
||||||
net_score_to_par: number | null
|
net_score_to_par: number | null
|
||||||
total_points: number | null
|
total_points: number | null
|
||||||
holes: ApiLeaderboardHole[]
|
holes: ApiLeaderboardHole[]
|
||||||
|
// "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av
|
||||||
|
// huntNettoToPar() til å regne ut hunt-visningens tall klientsidig.
|
||||||
|
course_handicap: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiLeaderboard = {
|
type ApiLeaderboard = {
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
completed: boolean
|
completed: boolean
|
||||||
entries: ApiLeaderboardEntry[]
|
entries: ApiLeaderboardEntry[]
|
||||||
|
net_display_style: "standard" | "hunt"
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiRoundParticipant = {
|
type ApiRoundParticipant = {
|
||||||
|
|
@ -350,6 +354,10 @@ type IndividualPlayer = {
|
||||||
finished?: boolean
|
finished?: boolean
|
||||||
gross: (number | null)[]
|
gross: (number | null)[]
|
||||||
holePoints?: (number | null)[]
|
holePoints?: (number | null)[]
|
||||||
|
// "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av
|
||||||
|
// huntNettoToPar() (i stedet for strokesReceived, som er gradvis/
|
||||||
|
// partial-allokert). Samme null-betydning som hcpIndex/strokesReceived.
|
||||||
|
courseHandicap: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasHcp(p: IndividualPlayer): boolean {
|
function hasHcp(p: IndividualPlayer): boolean {
|
||||||
|
|
@ -385,6 +393,19 @@ function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
|
||||||
return diff
|
return diff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- trekker fra HELE
|
||||||
|
// spillehandicapet med én gang i stedet for kun de slagene som er "tjent
|
||||||
|
// inn" for de spilte hullene (nettoToPar over). Konvergerer til NØYAKTIG
|
||||||
|
// samme tall som nettoToPar når alle 18 hull er spilt.
|
||||||
|
function huntNettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
|
||||||
|
if (p.courseHandicap === null) return null
|
||||||
|
let diff = 0
|
||||||
|
p.gross.forEach((g, i) => {
|
||||||
|
if (g !== null) diff += g - holes[i].par
|
||||||
|
})
|
||||||
|
return diff - p.courseHandicap
|
||||||
|
}
|
||||||
|
|
||||||
function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null {
|
function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null {
|
||||||
if (!hasHcp(p)) return null
|
if (!hasHcp(p)) return null
|
||||||
let pts = 0
|
let pts = 0
|
||||||
|
|
@ -404,6 +425,7 @@ function evalMetric(
|
||||||
metric: MetricKey,
|
metric: MetricKey,
|
||||||
pointsUnit: string,
|
pointsUnit: string,
|
||||||
holes: Hole[],
|
holes: Hole[],
|
||||||
|
netDisplayStyle: "standard" | "hunt" = "standard",
|
||||||
): {
|
): {
|
||||||
rankable: boolean
|
rankable: boolean
|
||||||
sortVal: number
|
sortVal: number
|
||||||
|
|
@ -416,7 +438,7 @@ function evalMetric(
|
||||||
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||||||
}
|
}
|
||||||
if (metric === "netto") {
|
if (metric === "netto") {
|
||||||
const v = nettoToPar(p, holes)
|
const v = netDisplayStyle === "hunt" ? huntNettoToPar(p, holes) : nettoToPar(p, holes)
|
||||||
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
|
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
|
||||||
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||||||
}
|
}
|
||||||
|
|
@ -444,6 +466,7 @@ function IndividualBoard({
|
||||||
pointsUnit = "p",
|
pointsUnit = "p",
|
||||||
holes,
|
holes,
|
||||||
commentsHref,
|
commentsHref,
|
||||||
|
netDisplayStyle = "standard",
|
||||||
}: {
|
}: {
|
||||||
players: IndividualPlayer[]
|
players: IndividualPlayer[]
|
||||||
metrics: MetricKey[]
|
metrics: MetricKey[]
|
||||||
|
|
@ -451,11 +474,13 @@ function IndividualBoard({
|
||||||
pointsUnit?: string
|
pointsUnit?: string
|
||||||
holes: Hole[]
|
holes: Hole[]
|
||||||
commentsHref: string
|
commentsHref: string
|
||||||
|
// "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style.
|
||||||
|
netDisplayStyle?: "standard" | "hunt"
|
||||||
}) {
|
}) {
|
||||||
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
||||||
const [openId, setOpenId] = useState<string | null>(null)
|
const [openId, setOpenId] = useState<string | null>(null)
|
||||||
|
|
||||||
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes) }))
|
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes, netDisplayStyle) }))
|
||||||
const rankable = evaluated.filter((e) => e.rankable)
|
const rankable = evaluated.filter((e) => e.rankable)
|
||||||
const unranked = evaluated.filter((e) => !e.rankable)
|
const unranked = evaluated.filter((e) => !e.rankable)
|
||||||
rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal))
|
rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal))
|
||||||
|
|
@ -1556,6 +1581,7 @@ function buildIndividualPlayers(
|
||||||
finished: false,
|
finished: false,
|
||||||
gross,
|
gross,
|
||||||
holePoints,
|
holePoints,
|
||||||
|
courseHandicap: e.course_handicap,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1802,6 +1828,14 @@ export function RoundLeaderboard({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
||||||
|
{leaderboard.net_display_style === "hunt" && (
|
||||||
|
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
|
||||||
|
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||||
|
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
|
||||||
|
Jakter Jesper: alle starter på minus spillehandicapet sitt -- lavest sluttsum vinner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<IndividualBoard
|
<IndividualBoard
|
||||||
players={players}
|
players={players}
|
||||||
metrics={config.metrics}
|
metrics={config.metrics}
|
||||||
|
|
@ -1809,6 +1843,7 @@ export function RoundLeaderboard({
|
||||||
pointsUnit={config.pointsUnit}
|
pointsUnit={config.pointsUnit}
|
||||||
holes={holes}
|
holes={holes}
|
||||||
commentsHref={commentsHref}
|
commentsHref={commentsHref}
|
||||||
|
netDisplayStyle={leaderboard.net_display_style}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Trophy } from "lucide-react"
|
import { ArrowLeft, Target, Trophy } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type ApiEntry = {
|
type ApiEntry = {
|
||||||
|
|
@ -37,6 +37,11 @@ type ApiLeaderboard = {
|
||||||
session_id: string
|
session_id: string
|
||||||
format: string
|
format: string
|
||||||
entries: ApiEntry[]
|
entries: ApiEntry[]
|
||||||
|
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- backend har
|
||||||
|
// allerede byttet net_score/net_to_par til hunt-semantikk når aktiv,
|
||||||
|
// ingen klientsidig utregning nødvendig her (ulikt round-leaderboard.tsx,
|
||||||
|
// som regner netto selv). Kun brukt til å vise en forklarende banner.
|
||||||
|
net_display_style: "standard" | "hunt"
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORMAT_LABELS: Record<string, string> = {
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
|
|
@ -252,6 +257,15 @@ export function SessionIndividualLeaderboard({
|
||||||
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
|
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{data.net_display_style === "hunt" && (
|
||||||
|
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
|
||||||
|
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||||
|
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
|
||||||
|
Jakter Jesper: alle starter på minus spillehandicapet sitt -- lavest sluttsum vinner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.entries.length === 0 ? (
|
{data.entries.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
|
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@ type ApiSession = {
|
||||||
start_mode: "consecutive" | "shotgun"
|
start_mode: "consecutive" | "shotgun"
|
||||||
locked_team_ids: string[]
|
locked_team_ids: string[]
|
||||||
revealed: boolean
|
revealed: boolean
|
||||||
|
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- kun
|
||||||
|
// meningsfullt for format IN ('singles','fourball') OG
|
||||||
|
// scoring_mode='stroke'.
|
||||||
|
net_display_style: "standard" | "hunt"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Norwegian display labels ----------------------------------------------
|
// --- Norwegian display labels ----------------------------------------------
|
||||||
|
|
@ -878,10 +882,15 @@ function EditSessionForm({
|
||||||
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
||||||
const [courseId, setCourseId] = useState(session.course_id)
|
const [courseId, setCourseId] = useState(session.course_id)
|
||||||
const [courseName, setCourseName] = useState(course?.name ?? "")
|
const [courseName, setCourseName] = useState(course?.name ?? "")
|
||||||
|
const [netDisplayStyle, setNetDisplayStyle] = useState(session.net_display_style)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const hasTime = scheduledAt.trim() !== ""
|
const hasTime = scheduledAt.trim() !== ""
|
||||||
const uid = session.id
|
const uid = session.id
|
||||||
|
// "Jakter Jesper" (migrasjon 093) -- samme betingelse
|
||||||
|
// fetch_individual_leaderboard håndhever ved lesing (tournaments.py).
|
||||||
|
const supportsNetDisplayStyle =
|
||||||
|
(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke"
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -899,6 +908,9 @@ function EditSessionForm({
|
||||||
tee_interval_minutes:
|
tee_interval_minutes:
|
||||||
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
|
...(supportsNetDisplayStyle && netDisplayStyle !== session.net_display_style
|
||||||
|
? { net_display_style: netDisplayStyle }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -1047,6 +1059,37 @@ function EditSessionForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{supportsNetDisplayStyle && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Visningsmodus for netto-score</Label>
|
||||||
|
<div role="radiogroup" aria-label="Visningsmodus for netto-score" className="inline-flex w-fit rounded-2xl border border-border bg-background p-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: "standard" as const, label: "Standard" },
|
||||||
|
{ value: "hunt" as const, label: "Jakter Jesper" },
|
||||||
|
]
|
||||||
|
).map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={netDisplayStyle === opt.value}
|
||||||
|
variant={netDisplayStyle === opt.value ? "default" : "ghost"}
|
||||||
|
onClick={() => setNetDisplayStyle(opt.value)}
|
||||||
|
className="h-10 rounded-xl px-4 text-sm font-bold"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Jakter Jesper: alle starter på minus spillehandicapet sitt, og hullscore vises brutto mot
|
||||||
|
par løpende -- i stedet for at slag "tjenes inn" gradvis gjennom økten. Sluttresultatet
|
||||||
|
blir identisk uansett. Standard som standard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
||||||
{submitting ? "Lagrer…" : "Lagre"}
|
{submitting ? "Lagrer…" : "Lagre"}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
105
tests/test_round_net_hunt_display.py
Normal file
105
tests/test_round_net_hunt_display.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""
|
||||||
|
"Jakter Jesper"-visningsmodus for frittstående runder (migrasjon 093, del 2
|
||||||
|
av ADR-106). Speiler tests/test_net_hunt_display.py (org-individuell) sitt
|
||||||
|
mønster og bevis: løpende tall skiller seg fra standard midt i runden, men
|
||||||
|
konvergerer eksakt ved 18 fullførte hull.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
from app.routers.rounds import HoleUpdate, RoundUpdate, get_leaderboard, get_round, update_hole, update_round
|
||||||
|
|
||||||
|
from tests.conftest import create_hole, create_participant, create_round, create_user
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
async def _score(round_id, participant_id, user_id, scores: list[int]) -> None:
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
for n, gross in enumerate(scores, start=1):
|
||||||
|
await update_hole(
|
||||||
|
round_id, participant_id, n,
|
||||||
|
HoleUpdate(score=gross, expected_version=None),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_stroke_round(user_id, course_handicap=12):
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
round_id = await create_round(conn, user_id)
|
||||||
|
participant_id = await create_participant(
|
||||||
|
conn, round_id, user_id=user_id, is_owner=True, course_handicap_snapshot=course_handicap,
|
||||||
|
)
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_hole(conn, participant_id, hole_number=n, par=4, stroke_index=n)
|
||||||
|
await conn.execute("UPDATE round SET play_format = 'stroke' WHERE id = $1", round_id)
|
||||||
|
return round_id, participant_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_differs_mid_round_but_converges_at_completion(pool):
|
||||||
|
user_id = await create_user()
|
||||||
|
round_id, participant_id = await _setup_stroke_round(user_id, course_handicap=12)
|
||||||
|
|
||||||
|
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
|
||||||
|
await _score(round_id, participant_id, user_id, [4])
|
||||||
|
|
||||||
|
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
|
||||||
|
# Standard (default): netto = 4-1=3, til par = 3-4 = -1.
|
||||||
|
assert out.my_net_score_to_par == -1
|
||||||
|
|
||||||
|
await update_round(round_id, RoundUpdate(net_display_style="hunt"), user=CurrentUser(user_id=user_id))
|
||||||
|
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
|
||||||
|
# Hunt: gross(4) - FULLT course handicap(12) = -8, til par = -8-4 = -12.
|
||||||
|
assert out.my_net_score_to_par == -12
|
||||||
|
|
||||||
|
lb = await get_leaderboard(round_id, user=CurrentUser(user_id=user_id))
|
||||||
|
assert lb.net_display_style == "hunt"
|
||||||
|
assert lb.entries[0].net_score_to_par == -12
|
||||||
|
assert lb.entries[0].course_handicap == 12
|
||||||
|
|
||||||
|
# Fullfør runden med par på alle resterende hull -- flatt hele veien
|
||||||
|
# under hunt (alltid par -> gross-til-par alltid 0).
|
||||||
|
await _score(round_id, participant_id, user_id, [4] * 17)
|
||||||
|
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
|
||||||
|
assert out.my_net_score_to_par == -12
|
||||||
|
|
||||||
|
# Bytt tilbake til standard nå som runden er fullført -- KONVERGERER
|
||||||
|
# til nøyaktig samme tall (selve beviset på ekvivalensen).
|
||||||
|
await update_round(round_id, RoundUpdate(net_display_style="standard"), user=CurrentUser(user_id=user_id))
|
||||||
|
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
|
||||||
|
assert out.my_net_score_to_par == -12
|
||||||
|
|
||||||
|
lb = await get_leaderboard(round_id, user=CurrentUser(user_id=user_id))
|
||||||
|
assert lb.entries[0].net_score_to_par == -12
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_rejected_for_non_stroke_play_format(pool):
|
||||||
|
user_id = await create_user()
|
||||||
|
round_id, participant_id = await _setup_stroke_round(user_id)
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
await conn.execute("UPDATE round SET play_format = 'stableford' WHERE id = $1", round_id)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_round(round_id, RoundUpdate(net_display_style="hunt"), user=CurrentUser(user_id=user_id))
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_switching_away_from_stroke_rejected_while_hunt_active(pool):
|
||||||
|
user_id = await create_user()
|
||||||
|
round_id, participant_id = await _setup_stroke_round(user_id)
|
||||||
|
await update_round(round_id, RoundUpdate(net_display_style="hunt"), user=CurrentUser(user_id=user_id))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_round(round_id, RoundUpdate(play_format="stableford"), user=CurrentUser(user_id=user_id))
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
# Men lov når begge felt endres sammen i samme kall.
|
||||||
|
out = await update_round(
|
||||||
|
round_id,
|
||||||
|
RoundUpdate(play_format="stableford", net_display_style="standard"),
|
||||||
|
user=CurrentUser(user_id=user_id),
|
||||||
|
)
|
||||||
|
assert out.play_format == "stableford"
|
||||||
|
assert out.net_display_style == "standard"
|
||||||
126
tests/test_session_net_hunt_display.py
Normal file
126
tests/test_session_net_hunt_display.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""
|
||||||
|
"Jakter Jesper"-visningsmodus for lagturnering-øktens individuelle
|
||||||
|
leaderboard (migrasjon 093, del 2 av ADR-106). Øktnivå, ikke turneringnivå
|
||||||
|
-- se ADR-106-tillegget for begrunnelsen. Speiler tests/test_net_hunt_
|
||||||
|
display.py sitt konvergens-bevis-mønster.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
from app.routers.scoring import HoleScoreCreate, submit_hole_score
|
||||||
|
from app.routers.tournaments import SessionUpdate, get_individual_leaderboard, update_session
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
add_membership,
|
||||||
|
create_course,
|
||||||
|
create_match,
|
||||||
|
create_match_participant,
|
||||||
|
create_org,
|
||||||
|
create_org_hole,
|
||||||
|
create_player,
|
||||||
|
create_session,
|
||||||
|
create_team,
|
||||||
|
create_team_roster,
|
||||||
|
create_tee,
|
||||||
|
create_tournament,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_singles_session(playing_handicap=12):
|
||||||
|
org_id = await create_org()
|
||||||
|
user_id = await create_user()
|
||||||
|
await add_membership(org_id, user_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
team_a = await create_team(org_id, tournament_id)
|
||||||
|
team_b = await create_team(org_id, tournament_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
|
||||||
|
tee_id = await create_tee(org_id, course_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id, format="singles", scoring_mode="stroke")
|
||||||
|
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||||
|
player_id = await create_player(org_id, user_id=user_id)
|
||||||
|
roster_id = await create_team_roster(org_id, team_a, player_id)
|
||||||
|
participant_id = await create_match_participant(org_id, match_id, "a", roster_id, tee_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE match_participant SET playing_handicap = $1 WHERE id = $2", playing_handicap, participant_id
|
||||||
|
)
|
||||||
|
return org_id, user_id, session_id, match_id, participant_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _score(match_id, participant_id, org_id, user_id, scores: list[int]) -> None:
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
for n, gross in enumerate(scores, start=1):
|
||||||
|
await submit_hole_score(
|
||||||
|
match_id,
|
||||||
|
HoleScoreCreate(
|
||||||
|
team_side="a", match_participant_id=participant_id, hole_number=n,
|
||||||
|
gross_strokes=gross, expected_version=None,
|
||||||
|
),
|
||||||
|
org_id,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_differs_mid_session_but_converges_at_completion(pool):
|
||||||
|
org_id, user_id, session_id, match_id, participant_id = await _setup_singles_session(playing_handicap=12)
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
|
||||||
|
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
|
||||||
|
await _score(match_id, participant_id, org_id, user_id, [4])
|
||||||
|
|
||||||
|
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
|
||||||
|
assert lb.net_display_style == "standard"
|
||||||
|
# Standard: netto = 4-1=3, til par = 3-4 = -1.
|
||||||
|
assert lb.entries[0].net_to_par == -1
|
||||||
|
|
||||||
|
await update_session(session_id, SessionUpdate(net_display_style="hunt"), organization_id=org_id)
|
||||||
|
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
|
||||||
|
assert lb.net_display_style == "hunt"
|
||||||
|
# Hunt: gross(4) - FULLT spillehandicap(12) = -8, til par = -8-4 = -12.
|
||||||
|
assert lb.entries[0].net_to_par == -12
|
||||||
|
assert lb.entries[0].net_score == -8
|
||||||
|
|
||||||
|
# Fullfør økten med par på alle resterende hull -- flatt hele veien
|
||||||
|
# under hunt (alltid par -> gross-til-par alltid 0).
|
||||||
|
await _score(match_id, participant_id, org_id, user_id, [4] * 17)
|
||||||
|
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
|
||||||
|
assert lb.entries[0].net_to_par == -12
|
||||||
|
|
||||||
|
# Bytt tilbake til standard nå som alle 18 hull er spilt -- KONVERGERER
|
||||||
|
# til nøyaktig samme tall (selve beviset på ekvivalensen).
|
||||||
|
await update_session(session_id, SessionUpdate(net_display_style="standard"), organization_id=org_id)
|
||||||
|
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
|
||||||
|
assert lb.entries[0].net_to_par == -12
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_rejected_for_non_singles_fourball_session(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
user_id = await create_user()
|
||||||
|
await add_membership(org_id, user_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id, format="foursome", scoring_mode="stroke")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_session(session_id, SessionUpdate(net_display_style="hunt"), organization_id=org_id)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_rejected_for_hole_result_scoring_mode(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
user_id = await create_user()
|
||||||
|
await add_membership(org_id, user_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id, format="singles", scoring_mode="hole_result")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_session(session_id, SessionUpdate(net_display_style="hunt"), organization_id=org_id)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
Loading…
Reference in a new issue