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)
|
||||
|
||||
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
|
||||
teecup_frontend && up -d`, begge containere friske, rene logger.
|
||||
`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
|
||||
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
|
||||
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/
|
||||
turneringsform -- se ADR-106 for hele resonnementet.
|
||||
|
||||
**Ferdig, live:** ny `tournament.net_display_style` ('standard'/'hunt',
|
||||
migrasjon 092) for org-individuelle turneringer, pluss en ny, generelt
|
||||
tilgjengelig tiebreak-verdi `lowest_playing_hcp`.
|
||||
**Ferdig, live 2026-08-23 (ADR-106):** ny `tournament.net_display_style`
|
||||
('standard'/'hunt', migrasjon 092) for org-individuelle turneringer,
|
||||
pluss en ny, generelt tilgjengelig tiebreak-verdi `lowest_playing_hcp`.
|
||||
|
||||
**Bevisst UTENFOR denne runden, bruker bekreftet sekvensiell bygging
|
||||
("org-individuell først, deretter egne oppfølgingsrunder"):**
|
||||
**Ferdig, live 2026-08-24 (ADR-107) -- begge tidligere bevisst utsatte
|
||||
oppfølgere fullført i samme runde:**
|
||||
|
||||
1. **Frittstående runder** (`round.play_format`) -- samme
|
||||
"hunt"-visning for personlige/vennegjeng-runder med netto-scoring.
|
||||
`round-stats.tsx`/`round-leaderboard.tsx` sin netto-til-par-visning
|
||||
(`my_net_score_to_par`, `rounds.py`) er det naturlige stedet å
|
||||
speile `_attach_stroke_play_columns` sin `stroke_val()`-logikk --
|
||||
samme partial-vs-full-handicap-prinsipp, men et annet datagrunnlag
|
||||
(`round_hole`/`round_participant`, ikke `tournament_round_hole`/
|
||||
`tournament_round_participant`). Ikke designet i detalj ennå.
|
||||
1. **Frittstående runder** (`round.net_display_style`, migrasjon 093).
|
||||
`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. `round-leaderboard.tsx`
|
||||
fikk en egen klientsidig `huntNettoToPar()` (regner netto-til-par selv,
|
||||
ulikt org-individuell). Underveis avdekket og fikset: rundekortets
|
||||
"Så langt"-widget (`ScoreSoFar`, round-detail.tsx) hadde en tredje,
|
||||
uavhengig netto-beregning som ikke var hunt-bevisst -- se ADR-107.
|
||||
2. **Kolonne på lagturnering-øktens individuelle leaderboard**
|
||||
(`fetch_individual_leaderboard`, `app/routers/tournaments.py`,
|
||||
singles/fourball-økter i en org-LAG-turnering). Denne visningen har
|
||||
INGEN `scoring_method`-velger i dag (viser gross/netto/poeng side om
|
||||
side, fast oppsett) -- en hunt-kolonne ville være additiv der (samme
|
||||
prinsipp som `IndividualLeaderboardEntry` sitt allerede eksisterende
|
||||
`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å.
|
||||
(`session.net_display_style`, migrasjon 093, PER ØKT ikke per
|
||||
turnering -- se ADR-107 for begrunnelsen). Gyldig for
|
||||
`format IN ('singles','fourball') AND scoring_mode='stroke'`.
|
||||
`fetch_individual_leaderboard` bytter `net_score`/`net_to_par` fra
|
||||
gradvis til `- match_participant.playing_handicap` når hunt er aktiv.
|
||||
|
||||
**Neste steg:** ingen av de to er startet. Ta fatt på dem som egne,
|
||||
avgrensede runder når/hvis bruker prioriterer dem -- ikke antatt
|
||||
hastende, kun fanget opp her for å ikke gå tapt.
|
||||
**Verifisert:** 6 nye pytest-tester (konvergens-bevis-mønster, begge
|
||||
deler) + full regresjon 204/204 grønt, `tsc --noEmit` rent, full-stack
|
||||
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
|
||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
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:
|
||||
|
|
@ -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,
|
||||
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,
|
||||
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
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -1557,6 +1561,17 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
total_strokes_received = sum(
|
||||
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
|
||||
|
||||
# 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"],
|
||||
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
||||
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);
|
||||
# None = ikke rør, samme konvensjon som resten av modellen her.
|
||||
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)
|
||||
|
|
@ -2787,7 +2809,7 @@ async def update_round(
|
|||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
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
|
||||
|
||||
|
|
@ -2812,6 +2834,23 @@ async def update_round(
|
|||
):
|
||||
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
|
||||
participants = None
|
||||
if body.course_source is not None:
|
||||
|
|
@ -2878,6 +2917,10 @@ async def update_round(
|
|||
await conn.execute(
|
||||
"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:
|
||||
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:
|
||||
|
|
@ -5245,19 +5288,30 @@ class LeaderboardEntryOut(BaseModel):
|
|||
# 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.
|
||||
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):
|
||||
holes_planned: int
|
||||
completed: bool
|
||||
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:
|
||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||
/public/rounds-varianten (ADR-036 fase 2)."""
|
||||
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(
|
||||
"""
|
||||
|
|
@ -5297,7 +5351,20 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
|||
total_points = None
|
||||
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)
|
||||
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
|
||||
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
|
||||
# gulvet på 0.
|
||||
|
|
@ -5320,6 +5387,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
|||
score_to_par=score_to_par,
|
||||
net_score_to_par=net_score_to_par,
|
||||
total_points=total_points,
|
||||
course_handicap=p["course_handicap_snapshot"],
|
||||
holes=[
|
||||
LeaderboardHoleOut(
|
||||
hole_number=h["hole_number"],
|
||||
|
|
@ -5341,6 +5409,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
|||
holes_planned=round_row["holes_planned"],
|
||||
completed=round_row["completed_at"] is not None,
|
||||
entries=entries,
|
||||
net_display_style=round_row["net_display_style"],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -962,6 +962,14 @@ class SessionOut(BaseModel):
|
|||
shamble_best_n: int | None
|
||||
locked_team_ids: list[str]
|
||||
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:
|
||||
|
|
@ -982,6 +990,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
|||
shamble_best_n=row["shamble_best_n"],
|
||||
locked_team_ids=sorted(locked),
|
||||
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,
|
||||
course_id::text, points_per_match::float AS points_per_match,
|
||||
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 tournament_id = $1
|
||||
ORDER BY sequence
|
||||
|
|
@ -1056,7 +1066,8 @@ async def create_session(
|
|||
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||
course_id::text, points_per_match::float AS points_per_match,
|
||||
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,
|
||||
tournament_id,
|
||||
|
|
@ -1093,6 +1104,11 @@ class SessionUpdate(BaseModel):
|
|||
points_per_match: float | None = None
|
||||
allowance_override: dict | 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:
|
||||
|
|
@ -1189,12 +1205,26 @@ async def update_session(
|
|||
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
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,
|
||||
)
|
||||
if session is None:
|
||||
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)
|
||||
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,
|
||||
course_id::text, points_per_match::float AS points_per_match,
|
||||
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
|
||||
""",
|
||||
session_id,
|
||||
|
|
@ -1903,6 +1934,9 @@ class IndividualLeaderboard(BaseModel):
|
|||
session_id: str
|
||||
format: str
|
||||
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(
|
||||
|
|
@ -1911,7 +1945,8 @@ async def fetch_individual_leaderboard(
|
|||
session = await conn.fetchrow(
|
||||
"""
|
||||
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
|
||||
""",
|
||||
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_by_hole = dict(zip(played, strokes_list))
|
||||
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_to_par = gross_to_par - strokes_received_total
|
||||
# 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(
|
||||
|
|
|
|||
|
|
@ -259,6 +259,9 @@ type ApiRound = {
|
|||
flight_group_id: string | null
|
||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
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
|
||||
|
|
@ -1717,7 +1720,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
løpende til-par, så dette er nå rendyrket "se mer detaljer"
|
||||
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
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).
|
||||
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
||||
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
|
||||
? 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
|
||||
const stablefordTotal = hasNetto
|
||||
? withNetto.reduce(
|
||||
|
|
@ -4543,6 +4572,9 @@ function EditRoundPanel({
|
|||
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
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 [showChangeCourse, setShowChangeCourse] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -4559,7 +4591,8 @@ function EditRoundPanel({
|
|||
playFormat !== currentEditablePlayFormat ||
|
||||
visibilityMode !== round.visibility_mode ||
|
||||
(visibilityMode === "friends" && !sameCategories) ||
|
||||
flagMapVisible !== round.flag_map_visible
|
||||
flagMapVisible !== round.flag_map_visible ||
|
||||
netDisplayStyle !== round.net_display_style
|
||||
|
||||
async function saveMetadata() {
|
||||
setSaving(true)
|
||||
|
|
@ -4580,6 +4613,7 @@ function EditRoundPanel({
|
|||
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
||||
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
||||
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)
|
||||
if (!result.ok) setError(result.message)
|
||||
setSaving(false)
|
||||
|
|
@ -4770,6 +4804,42 @@ function EditRoundPanel({
|
|||
</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 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
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 { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||
import { MatchScorecardGrid } from "@/components/round-scorecard"
|
||||
|
|
@ -49,12 +49,16 @@ type ApiLeaderboardEntry = {
|
|||
net_score_to_par: number | null
|
||||
total_points: number | null
|
||||
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 = {
|
||||
holes_planned: number
|
||||
completed: boolean
|
||||
entries: ApiLeaderboardEntry[]
|
||||
net_display_style: "standard" | "hunt"
|
||||
}
|
||||
|
||||
type ApiRoundParticipant = {
|
||||
|
|
@ -350,6 +354,10 @@ type IndividualPlayer = {
|
|||
finished?: boolean
|
||||
gross: (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 {
|
||||
|
|
@ -385,6 +393,19 @@ function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
|
|||
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 {
|
||||
if (!hasHcp(p)) return null
|
||||
let pts = 0
|
||||
|
|
@ -404,6 +425,7 @@ function evalMetric(
|
|||
metric: MetricKey,
|
||||
pointsUnit: string,
|
||||
holes: Hole[],
|
||||
netDisplayStyle: "standard" | "hunt" = "standard",
|
||||
): {
|
||||
rankable: boolean
|
||||
sortVal: number
|
||||
|
|
@ -416,7 +438,7 @@ function evalMetric(
|
|||
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||||
}
|
||||
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 /> }
|
||||
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||||
}
|
||||
|
|
@ -444,6 +466,7 @@ function IndividualBoard({
|
|||
pointsUnit = "p",
|
||||
holes,
|
||||
commentsHref,
|
||||
netDisplayStyle = "standard",
|
||||
}: {
|
||||
players: IndividualPlayer[]
|
||||
metrics: MetricKey[]
|
||||
|
|
@ -451,11 +474,13 @@ function IndividualBoard({
|
|||
pointsUnit?: string
|
||||
holes: Hole[]
|
||||
commentsHref: string
|
||||
// "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style.
|
||||
netDisplayStyle?: "standard" | "hunt"
|
||||
}) {
|
||||
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
||||
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 unranked = evaluated.filter((e) => !e.rankable)
|
||||
rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal))
|
||||
|
|
@ -1556,6 +1581,7 @@ function buildIndividualPlayers(
|
|||
finished: false,
|
||||
gross,
|
||||
holePoints,
|
||||
courseHandicap: e.course_handicap,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1802,6 +1828,14 @@ export function RoundLeaderboard({
|
|||
return (
|
||||
<>
|
||||
<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
|
||||
players={players}
|
||||
metrics={config.metrics}
|
||||
|
|
@ -1809,6 +1843,7 @@ export function RoundLeaderboard({
|
|||
pointsUnit={config.pointsUnit}
|
||||
holes={holes}
|
||||
commentsHref={commentsHref}
|
||||
netDisplayStyle={leaderboard.net_display_style}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Trophy } from "lucide-react"
|
||||
import { ArrowLeft, Target, Trophy } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ApiEntry = {
|
||||
|
|
@ -37,6 +37,11 @@ type ApiLeaderboard = {
|
|||
session_id: string
|
||||
format: string
|
||||
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> = {
|
||||
|
|
@ -252,6 +257,15 @@ export function SessionIndividualLeaderboard({
|
|||
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
|
||||
</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 ? (
|
||||
<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.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@ type ApiSession = {
|
|||
start_mode: "consecutive" | "shotgun"
|
||||
locked_team_ids: string[]
|
||||
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 ----------------------------------------------
|
||||
|
|
@ -878,10 +882,15 @@ function EditSessionForm({
|
|||
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
||||
const [courseId, setCourseId] = useState(session.course_id)
|
||||
const [courseName, setCourseName] = useState(course?.name ?? "")
|
||||
const [netDisplayStyle, setNetDisplayStyle] = useState(session.net_display_style)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const hasTime = scheduledAt.trim() !== ""
|
||||
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) {
|
||||
e.preventDefault()
|
||||
|
|
@ -899,6 +908,9 @@ function EditSessionForm({
|
|||
tee_interval_minutes:
|
||||
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
||||
course_id: courseId,
|
||||
...(supportsNetDisplayStyle && netDisplayStyle !== session.net_display_style
|
||||
? { net_display_style: netDisplayStyle }
|
||||
: {}),
|
||||
})
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
|
@ -1047,6 +1059,37 @@ function EditSessionForm({
|
|||
</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">
|
||||
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
||||
{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