Hastecommit
All checks were successful
Backend-tester / test (push) Successful in 46s

This commit is contained in:
Erol Haagenrud 2026-08-24 09:55:04 +02:00
parent 955e320663
commit bcb712810f
13 changed files with 720 additions and 47 deletions

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

View file

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

View file

@ -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).

View file

@ -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.

View file

@ -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,7 +1561,18 @@ 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
)
my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received
# "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.
# Gjenbruker _build_format_result uendret (samme sannhetskilde som selve
@ -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"],
)

View file

@ -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,8 +2015,17 @@ 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)
net_score = gross_score - strokes_received_total
net_to_par = gross_to_par - strokes_received_total
# "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
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
# per slag, gulvet på 0.
@ -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(

View file

@ -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, dette er 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 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">

View file

@ -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 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}
/>
</>
)

View file

@ -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 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.

View file

@ -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 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

View 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"

View 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