Legg til Jakter Jesper som visningsmodus for netto-scoring (ADR-106)
Bruker beskrev en turneringsform (start på -spillehandicap, brutto mot
par løpende, lavest sluttsum vinner) og spurte om dette egentlig var en
presentasjonsform fremfor en ny turneringsform. Riktig observasjon:
sluttsummen er matematisk identisk med stroke_net sitt netto-til-par.
Ny tournament.net_display_style ('standard'/'hunt', migrasjon 092), kun
gyldig sammen med scoring_method='stroke_net'. _attach_stroke_play_columns
fikk én stroke_val()-hjelper som bytter net_total (gradvis slagindeks-
basert) med gross_total - playing_handicap (hele handicapet trukket fra
med én gang) når hunt er aktiv -- ingen ny motorfunksjon. Ny, generelt
tilgjengelig tiebreak-verdi lowest_playing_hcp.
5 nye pytest-tester + full regresjon (198/198), tsc rent, full-stack
scratch-verifisert (hånd-utregnede tall matchet eksakt, offentlig
følg-live + utskrift arvet visningen uten kodeendring der).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c8bfe96eb6
commit
7614c4b83a
8 changed files with 584 additions and 10 deletions
36
092_net_hunt_display.sql
Normal file
36
092_net_hunt_display.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 092
|
||||||
|
-- Visningsmodus for netto-scoring ("Jakter Jesper"): start på
|
||||||
|
-- -spillehandicap, brutto-mot-par løpende -- IKKE en ny scoring_method,
|
||||||
|
-- se ADR (neste ledige nummer) for hele resonnementet. Pluss en ny,
|
||||||
|
-- generelt tilgjengelig tiebreak-verdi (lavest spillehandicap).
|
||||||
|
-- =====================================================================
|
||||||
|
-- `net_display_style`: 'standard' (uendret oppførsel -- dagens
|
||||||
|
-- gradvise, slagindeks-baserte netto-visning, default, ingen regresjon
|
||||||
|
-- for eksisterende turneringer) eller 'hunt' (hele spillehandicapet
|
||||||
|
-- trukket fra med én gang, brutto-til-par-differansen vist løpende).
|
||||||
|
-- Kun meningsfullt for scoring_method='stroke_net' -- håndheves i
|
||||||
|
-- app-laget (samme presedens som _SCORING_METHODS_REQUIRING_HANDICAP),
|
||||||
|
-- ikke en tverr-kolonne CHECK her.
|
||||||
|
--
|
||||||
|
-- `lowest_playing_hcp`: ny tiebreak-verdi (utvider migrasjon 085/086
|
||||||
|
-- sitt mønster) -- spilleren med lavest SPILLEHANDICAP (tee-spesifikt
|
||||||
|
-- tall, IKKE WHS-indeksen `lowest_hcp` bruker) i turneringens siste
|
||||||
|
-- runde vinner uavgjortheten. Generelt tilgjengelig for alle
|
||||||
|
-- stroke-play-turneringer, ikke bare hunt-visningen -- tiebreak er
|
||||||
|
-- allerede uavhengig av scoring_method.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE tournament
|
||||||
|
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
|
||||||
|
CHECK (net_display_style IN ('standard', 'hunt'));
|
||||||
|
|
||||||
|
ALTER TABLE tournament
|
||||||
|
DROP CONSTRAINT tournament_tiebreak_winner_method_check,
|
||||||
|
ADD CONSTRAINT tournament_tiebreak_winner_method_check
|
||||||
|
CHECK (tiebreak_winner_method IN ('none', 'countback', 'lowest_hcp', 'playoff', 'lowest_playing_hcp')),
|
||||||
|
DROP CONSTRAINT tournament_tiebreak_field_method_check,
|
||||||
|
ADD CONSTRAINT tournament_tiebreak_field_method_check
|
||||||
|
CHECK (tiebreak_field_method IN ('none', 'countback', 'lowest_hcp', 'lowest_playing_hcp'));
|
||||||
|
|
@ -10616,6 +10616,89 @@ Alle scratch-ressurser (db, rolle, containere, images) ryddet opp.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-106: "Jakter Jesper" er en visningsmodus for netto-scoring, ikke en ny turneringsform (migrasjon 092, 2026-08-23)
|
||||||
|
|
||||||
|
**Kontekst:** bruker beskrev en turneringsform: start på -spillehandicap,
|
||||||
|
hullscore måles brutto mot par gjennom runden, lavest sluttsum vinner,
|
||||||
|
tiebreak på lavest spillehandicap ved likt resultat. "Kun interessant
|
||||||
|
dersom man har et leaderboard å forholde seg til."
|
||||||
|
|
||||||
|
**Beslutning A -- IKKE en ny scoring_method, en visningsmodus på
|
||||||
|
eksisterende `stroke_net`.** Sluttsummen er matematisk identisk med
|
||||||
|
`stroke_net` sitt allerede eksisterende netto-til-par
|
||||||
|
(`net_total - par_played`): `-handicap + Σ(brutto-par) = (brutto-
|
||||||
|
handicap)-par = netto-til-par`. Det eneste som faktisk er nytt er
|
||||||
|
hvordan tallet vises LØPENDE midt i runden -- dagens netto "tjener inn"
|
||||||
|
handicap-slag gradvis per slagindeks (`allocate_over_played_holes`,
|
||||||
|
partial allocation basert på hvilke hull som faktisk er spilt), mens
|
||||||
|
Jakter Jesper trekker fra HELE spillehandicapet med én gang og viser
|
||||||
|
brutto-til-par-differansen løpende. Vurdert og avvist som egen
|
||||||
|
scoring_method (ville krevd berøring av ~15 filer -- CHECK-constraint,
|
||||||
|
to Pydantic-regex-steder, dispatch-gren, cut-gate, frontend-labelkart --
|
||||||
|
for null reell ny beregningslogikk).
|
||||||
|
|
||||||
|
**Beslutning B -- ny kolonne `tournament.net_display_style`
|
||||||
|
('standard'/'hunt', default 'standard'), KUN gyldig sammen med
|
||||||
|
scoring_method='stroke_net'** (fail-loudly 400 begge veier: kan ikke
|
||||||
|
settes til 'hunt' på en annen metode, OG kan ikke bytte bort fra
|
||||||
|
stroke_net mens 'hunt' står -- se `update_tournament` i
|
||||||
|
`app/routers/tournaments.py`). `_attach_stroke_play_columns`
|
||||||
|
(`individual_tournaments.py`) fikk en `stroke_val(row)`-hjelper som
|
||||||
|
bytter ut `row["net_total"]` med `row["gross_total"] -
|
||||||
|
row["playing_handicap"]` når hunt er aktiv -- ett sted, ingen duplisert
|
||||||
|
beregningslogikk. `playing_handicap` hentes nå per (runde, deltaker) i
|
||||||
|
samme spørring (ny JOIN mot `tournament_round_participant`) siden den
|
||||||
|
kan variere per runde/tee i en flerrunde-turnering.
|
||||||
|
|
||||||
|
**Beslutning C -- ny tiebreak-verdi `lowest_playing_hcp`, GENERELT
|
||||||
|
tilgjengelig (ikke bundet til hunt-visningen).** Bruker presiserte
|
||||||
|
"spillehandicap" (det tee-spesifikke tallet som faktisk ga
|
||||||
|
startverdien), ikke WHS-indeksen den eksisterende `lowest_hcp`-
|
||||||
|
tiebreaken bruker (`tournament_participant.handicap_index_snapshot`).
|
||||||
|
Ny `_lowest_playing_hcp_sort_keys` leser
|
||||||
|
`tournament_round_participant.playing_handicap` for turneringens
|
||||||
|
ABSOLUTT SISTE runde (samme presedens som `_countback_sort_keys`).
|
||||||
|
Tiebreak forblir organisator-valgfritt som i dag (bruker bekreftet
|
||||||
|
eksplisitt: IKKE tvunget på for formatet, samme fleksible to-
|
||||||
|
uavhengige-innstillinger-mønster som `countback`/`lowest_hcp`/
|
||||||
|
`playoff`).
|
||||||
|
|
||||||
|
**Beslutning D -- omfang: kun org-individuelle turneringer denne
|
||||||
|
runden.** Bruker bekreftet sekvensiell bygging -- frittstående runder
|
||||||
|
(`round.play_format`) og en kolonne på lagturnering-øktens individuelle
|
||||||
|
leaderboard (`fetch_individual_leaderboard`) er bevisst UTENFOR,
|
||||||
|
egne senere oppfølgingsrunder. Org-LAG-turneringer (Ryder Cup-
|
||||||
|
modellen) har strukturelt ikke noe `scoring_method`-begrep i det hele
|
||||||
|
tatt (ADR-011) -- kan aldri hoste denne visningsmodusen direkte, kun
|
||||||
|
potensielt som en tilleggskolonne på den separate økt-leaderboard-
|
||||||
|
mekanismen.
|
||||||
|
|
||||||
|
**Status:** 5 nye pytest-tester (løpende tall skiller seg fra standard
|
||||||
|
midt i runden men konvergerer nøyaktig ved 18 fullførte hull -- selve
|
||||||
|
beviset på ekvivalensen; validering begge veier; `lowest_playing_hcp`
|
||||||
|
løser en reell uavgjort; manglende spillehandicap sorteres sist) + full
|
||||||
|
regresjonssuite, 198/198 grønt. `tsc --noEmit` rent.
|
||||||
|
|
||||||
|
Full-stack scratch-verifisert (egen db + to one-off-containere, ekte
|
||||||
|
innlogging inkl. et ekte 2FA-e-post-oppsett for org-eier-tilgang): en
|
||||||
|
stroke_net-turnering med to spillere (spillehandicap 12/4), hunt PÅ --
|
||||||
|
leaderboardet viste -12/-2 (hånd-utregnet på forhånd, matchet eksakt).
|
||||||
|
Slått AV live -- tallene endret seg umiddelbart til standard-visningens
|
||||||
|
-5/-2 (samme underliggende data, kun presentasjonen endret), ingen
|
||||||
|
sideoppfriskning nødvendig. Bekreftet at BÅDE den offentlige "Følg
|
||||||
|
live"-siden OG utskrifts-resultatlisten viste hunt-tallene korrekt UTEN
|
||||||
|
noen kodeendring der -- beviser at de kun konsumerer `total_label`/
|
||||||
|
`position` som opake strenger, akkurat som antatt i planen. Mørk modus
|
||||||
|
bekreftet lesbart. Alle scratch-ressurser ryddet opp.
|
||||||
|
|
||||||
|
**Ikke rullet ut ennå** -- venter på brukerens bekreftelse før migrasjon
|
||||||
|
092 kjøres mot ekte `teecup_db` (additiv, ingen eksisterende data
|
||||||
|
berøres -- default 'standard'/uendret tiebreak-oppførsel for alle
|
||||||
|
eksisterende turneringer, men skal likevel bekreftes eksplisitt per
|
||||||
|
standard sikkerhetsregel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -14703,3 +14703,41 @@ Neste steg:
|
||||||
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt).
|
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt).
|
||||||
Begge containere friske, rene logger. `https://teecup.golf/health`
|
Begge containere friske, rene logger. `https://teecup.golf/health`
|
||||||
200 etter utrulling.
|
200 etter utrulling.
|
||||||
|
|
||||||
|
166. **"Jakter Jesper" bygget som visningsmodus for netto-scoring, ikke
|
||||||
|
en ny turneringsform (ADR-106), 2026-08-23.** Bruker beskrev en
|
||||||
|
turneringsform (start på -spillehandicap, brutto mot par løpende,
|
||||||
|
lavest sluttsum vinner, tiebreak på lavest spillehandicap) og spurte
|
||||||
|
om det egentlig var en ny presentasjonsform fremfor en ny
|
||||||
|
turneringsform -- riktig observasjon: sluttsummen er matematisk
|
||||||
|
identisk med `stroke_net` sitt netto-til-par.
|
||||||
|
|
||||||
|
**Ny kolonne `tournament.net_display_style`** ('standard'/'hunt',
|
||||||
|
migrasjon 092), kun gyldig sammen med `scoring_method='stroke_net'`
|
||||||
|
(fail-loudly 400 begge veier). `_attach_stroke_play_columns` fikk én
|
||||||
|
`stroke_val()`-hjelper som bytter `net_total` (gradvis slagindeks-
|
||||||
|
basert) med `gross_total - playing_handicap` (hele handicapet trukket
|
||||||
|
fra med én gang) når hunt er aktiv -- ingen ny motorfunksjon, ingen
|
||||||
|
duplisert beregning. Ny, GENERELT tilgjengelig tiebreak-verdi
|
||||||
|
`lowest_playing_hcp` (spillehandicap, ikke WHS-indeksen `lowest_hcp`
|
||||||
|
bruker) -- organisator-valgfri som de andre, ikke tvunget på for
|
||||||
|
formatet (brukerbekreftet). `LeaderboardRow`-kontrakten (ADR-101)
|
||||||
|
RØRT IKKE -- alle felt var allerede opake, ferdig-formaterte strenger.
|
||||||
|
|
||||||
|
**Verifisert:** 5 nye pytest-tester (løpende tall skiller seg fra
|
||||||
|
standard midt i runden, konvergerer eksakt ved 18 fullførte hull;
|
||||||
|
validering begge veier; `lowest_playing_hcp` løser en reell
|
||||||
|
uavgjort; manglende spillehandicap sorteres sist) + full
|
||||||
|
regresjonssuite, 198/198 grønt. `tsc --noEmit` rent. Full-stack
|
||||||
|
scratch (egen db + to one-off-containere, ekte innlogging inkl. et
|
||||||
|
ekte 2FA-e-post-oppsett): stroke_net-turnering med to spillere
|
||||||
|
(spillehandicap 12/4), hunt PÅ -- leaderboardet viste -12/-2,
|
||||||
|
hånd-utregnet på forhånd og matchet eksakt. Slått av live -- tallene
|
||||||
|
endret seg umiddelbart til standard-visningens -5/-2 uten
|
||||||
|
sideoppfriskning. Offentlig "Følg live"-side OG utskrifts-
|
||||||
|
resultatlisten viste hunt-tallene korrekt UTEN kodeendring der,
|
||||||
|
bekrefter at de kun konsumerer opake strenger. Mørk modus bekreftet
|
||||||
|
lesbart. Alle scratch-ressurser ryddet opp.
|
||||||
|
|
||||||
|
**Ikke rullet ut ennå** -- venter på brukerens bekreftelse før
|
||||||
|
migrasjon 092 kjøres mot ekte `teecup_db`.
|
||||||
|
|
|
||||||
|
|
@ -1785,7 +1785,7 @@ async def _compute_individual_standings(
|
||||||
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
||||||
som faktisk er meningsfullt å lese videre."""
|
som faktisk er meningsfullt å lese videre."""
|
||||||
tournament = await conn.fetchrow(
|
tournament = await conn.fetchrow(
|
||||||
"SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method "
|
"SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method, net_display_style "
|
||||||
"FROM tournament WHERE id = $1",
|
"FROM tournament WHERE id = $1",
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
|
|
@ -1842,6 +1842,7 @@ async def _compute_individual_standings(
|
||||||
max_sequence=max_sequence,
|
max_sequence=max_sequence,
|
||||||
tiebreak_winner_method=tournament["tiebreak_winner_method"],
|
tiebreak_winner_method=tournament["tiebreak_winner_method"],
|
||||||
tiebreak_field_method=tournament["tiebreak_field_method"],
|
tiebreak_field_method=tournament["tiebreak_field_method"],
|
||||||
|
net_display_style=tournament["net_display_style"],
|
||||||
)
|
)
|
||||||
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
|
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
|
||||||
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
|
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
|
||||||
|
|
@ -2182,6 +2183,31 @@ async def _lowest_hcp_sort_keys(conn, participant_ids: list[str]) -> dict[str, t
|
||||||
return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows}
|
return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _lowest_playing_hcp_sort_keys(conn, tournament_id: str, participant_ids: list[str]) -> dict[str, tuple]:
|
||||||
|
"""Tiebreak 'lowest_playing_hcp' (migrasjon 092) -- laveste
|
||||||
|
SPILLEHANDICAP (tee-spesifikt tall, IKKE WHS-indeksen `lowest_hcp`
|
||||||
|
bruker) vinner. Leser turneringens ABSOLUTT SISTE runde (samme
|
||||||
|
presedens som `_countback_sort_keys` -- en reell uavgjort forutsetter
|
||||||
|
normalt likt antall runder spilt). Manglende verdi (runde ikke spilt
|
||||||
|
av denne deltakeren, eller handicap ikke beregnet ennå) sorteres
|
||||||
|
SIST, samme mønster som `_lowest_hcp_sort_keys`."""
|
||||||
|
last_round = await conn.fetchrow(
|
||||||
|
"SELECT id FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence DESC LIMIT 1",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if last_round is None:
|
||||||
|
return {pid: (float("inf"),) for pid in participant_ids}
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT tournament_participant_id::text AS id, playing_handicap::float AS hcp "
|
||||||
|
"FROM tournament_round_participant "
|
||||||
|
"WHERE tournament_round_id = $1 AND tournament_participant_id = ANY($2::uuid[])",
|
||||||
|
last_round["id"],
|
||||||
|
participant_ids,
|
||||||
|
)
|
||||||
|
hcp_by_id = {r["id"]: r["hcp"] for r in rows}
|
||||||
|
return {pid: (hcp_by_id.get(pid) if hcp_by_id.get(pid) is not None else float("inf"),) for pid in participant_ids}
|
||||||
|
|
||||||
|
|
||||||
async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]:
|
async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]:
|
||||||
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
|
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
|
||||||
cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens
|
cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens
|
||||||
|
|
@ -2292,6 +2318,7 @@ async def _attach_stroke_play_columns(
|
||||||
max_sequence: int | None = None,
|
max_sequence: int | None = None,
|
||||||
tiebreak_winner_method: str = "none",
|
tiebreak_winner_method: str = "none",
|
||||||
tiebreak_field_method: str = "none",
|
tiebreak_field_method: str = "none",
|
||||||
|
net_display_style: str = "standard",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
||||||
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
|
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
|
||||||
|
|
@ -2312,7 +2339,21 @@ async def _attach_stroke_play_columns(
|
||||||
aldri max_sequence, så tiebreak gjelder der -- inkludert midt i
|
aldri max_sequence, så tiebreak gjelder der -- inkludert midt i
|
||||||
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
|
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
|
||||||
beste vi vet akkurat nå"-filosofi som resten av leaderboardet).
|
beste vi vet akkurat nå"-filosofi som resten av leaderboardet).
|
||||||
|
|
||||||
|
`net_display_style` (migrasjon 092, 2026-08-23) -- 'hunt' er IKKE en
|
||||||
|
ny scoring-motor, kun en annen presentasjon av samme underliggende
|
||||||
|
data for `stroke_net`-turneringer ("Jakter Jesper", se ADR): i
|
||||||
|
stedet for å "tjene inn" handicap-slag gradvis per slagindeks
|
||||||
|
(dagens `net_total`, partial allocation via
|
||||||
|
`allocate_over_played_holes`), trekkes HELE spillehandicapet fra med
|
||||||
|
én gang og brutto-til-par-differansen vises løpende -- dramatisk
|
||||||
|
forskjellig tall MIDT i en runde, men konvergerer til NØYAKTIG samme
|
||||||
|
sluttall som standard-visningen ved 18 fullførte hull (siden summen
|
||||||
|
av per-hull-tildelte slag over alle 18 hull uansett er lik hele
|
||||||
|
spillehandicapet). Kun virksom når `method == "stroke_net"` --
|
||||||
|
stableford/gross har ingen netto-til-par-visning å endre på.
|
||||||
"""
|
"""
|
||||||
|
hunt_active = net_display_style == "hunt" and method == "stroke_net"
|
||||||
rounds_meta = await conn.fetch(
|
rounds_meta = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole
|
SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole
|
||||||
|
|
@ -2338,9 +2379,12 @@ async def _attach_stroke_play_columns(
|
||||||
SELECT tr.sequence, tr.id::text AS tournament_round_id,
|
SELECT tr.sequence, tr.id::text AS tournament_round_id,
|
||||||
trs.tournament_participant_id::text AS tournament_participant_id,
|
trs.tournament_participant_id::text AS tournament_participant_id,
|
||||||
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
||||||
|
trp.playing_handicap,
|
||||||
COALESCE(pp.par_played, 0)::int AS par_played
|
COALESCE(pp.par_played, 0)::int AS par_played
|
||||||
FROM tournament_round tr
|
FROM tournament_round tr
|
||||||
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
|
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
|
||||||
|
LEFT JOIN tournament_round_participant trp
|
||||||
|
ON trp.tournament_round_id = tr.id AND trp.tournament_participant_id = trs.tournament_participant_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT SUM(h.par) AS par_played
|
SELECT SUM(h.par) AS par_played
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
|
|
@ -2373,6 +2417,18 @@ async def _attach_stroke_play_columns(
|
||||||
"net_total" if method == "stroke_net" else "gross_total"
|
"net_total" if method == "stroke_net" else "gross_total"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def stroke_val(row) -> int | None:
|
||||||
|
"""Netto/brutto-tallet for én runde-rad -- `hunt_active` bytter
|
||||||
|
ut den gradvise `net_total` med "trekk fra HELE spillehandicapet
|
||||||
|
med én gang" (se `net_display_style`-docstringen over). `None`
|
||||||
|
hvis spillehandicap ikke er beregnet ennå for raden (samme
|
||||||
|
nøytrale "ingen data"-håndtering som `value_col` allerede har)."""
|
||||||
|
if not hunt_active:
|
||||||
|
return row[value_col]
|
||||||
|
if row["playing_handicap"] is None or row["gross_total"] is None:
|
||||||
|
return None
|
||||||
|
return row["gross_total"] - row["playing_handicap"]
|
||||||
|
|
||||||
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
|
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
|
||||||
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
|
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
|
||||||
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
|
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
|
||||||
|
|
@ -2387,6 +2443,7 @@ async def _attach_stroke_play_columns(
|
||||||
rounds_out: list[RoundCellOut] = []
|
rounds_out: list[RoundCellOut] = []
|
||||||
total_par_played = 0
|
total_par_played = 0
|
||||||
total_holes_played = 0
|
total_holes_played = 0
|
||||||
|
total_stroke_val = 0
|
||||||
for seq, row in sorted(pmap.items()):
|
for seq, row in sorted(pmap.items()):
|
||||||
if row["holes_played"] == 0:
|
if row["holes_played"] == 0:
|
||||||
continue
|
continue
|
||||||
|
|
@ -2404,9 +2461,10 @@ async def _attach_stroke_play_columns(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
val = row[value_col]
|
val = stroke_val(row)
|
||||||
if val is None:
|
if val is None:
|
||||||
continue
|
continue
|
||||||
|
total_stroke_val += val
|
||||||
rounds_out.append(
|
rounds_out.append(
|
||||||
RoundCellOut(
|
RoundCellOut(
|
||||||
round_number=seq,
|
round_number=seq,
|
||||||
|
|
@ -2425,7 +2483,7 @@ async def _attach_stroke_play_columns(
|
||||||
if method == "stableford":
|
if method == "stableford":
|
||||||
e.today_label = f"{today_row['stableford_points'] or 0} p"
|
e.today_label = f"{today_row['stableford_points'] or 0} p"
|
||||||
else:
|
else:
|
||||||
val = today_row[value_col]
|
val = stroke_val(today_row)
|
||||||
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
|
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
|
||||||
else:
|
else:
|
||||||
e.thru_label = "-"
|
e.thru_label = "-"
|
||||||
|
|
@ -2449,6 +2507,12 @@ async def _attach_stroke_play_columns(
|
||||||
# runde spilt ennå (total_holes_played == 0) -> total_label forblir
|
# runde spilt ennå (total_holes_played == 0) -> total_label forblir
|
||||||
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
|
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
|
||||||
# jevn/poengløs score i stedet for "ikke startet").
|
# jevn/poengløs score i stedet for "ikke startet").
|
||||||
|
#
|
||||||
|
# `hunt_active`: TOTAL kan IKKE gjenbruke `e.net_total` direkte --
|
||||||
|
# `playing_handicap` kan variere per runde (ulik tee), så
|
||||||
|
# hunt-summen må bygges runde for runde (`total_stroke_val`,
|
||||||
|
# akkumulert i løkken over) i stedet for kalleren sin ferdig-
|
||||||
|
# summerte `net_total`-kolonne.
|
||||||
started_any = total_holes_played > 0
|
started_any = total_holes_played > 0
|
||||||
if method == "stableford":
|
if method == "stableford":
|
||||||
e.total_label = f"{e.stableford_total or 0} p" if started_any else None
|
e.total_label = f"{e.stableford_total or 0} p" if started_any else None
|
||||||
|
|
@ -2457,7 +2521,7 @@ async def _attach_stroke_play_columns(
|
||||||
else -(e.stableford_total - 2 * total_holes_played)
|
else -(e.stableford_total - 2 * total_holes_played)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
total_val = e.net_total if method == "stroke_net" else e.gross_total
|
total_val = total_stroke_val if hunt_active else (e.net_total if method == "stroke_net" else e.gross_total)
|
||||||
e.total_label = (
|
e.total_label = (
|
||||||
_to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None
|
_to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None
|
||||||
)
|
)
|
||||||
|
|
@ -2497,10 +2561,12 @@ async def _attach_stroke_play_columns(
|
||||||
# gruppe faller gjennom til else-grenen under akkurat som 'none':
|
# gruppe faller gjennom til else-grenen under akkurat som 'none':
|
||||||
# delt plassering, ingen automatisk oppløsning. Forskjellen er
|
# delt plassering, ingen automatisk oppløsning. Forskjellen er
|
||||||
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
|
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
|
||||||
if tiebreak_method in ("countback", "lowest_hcp"):
|
if tiebreak_method in ("countback", "lowest_hcp", "lowest_playing_hcp"):
|
||||||
group_ids = [entries[k].tournament_participant_id for k in range(i, j)]
|
group_ids = [entries[k].tournament_participant_id for k in range(i, j)]
|
||||||
if tiebreak_method == "countback":
|
if tiebreak_method == "countback":
|
||||||
sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids)
|
sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids)
|
||||||
|
elif tiebreak_method == "lowest_playing_hcp":
|
||||||
|
sort_keys = await _lowest_playing_hcp_sort_keys(conn, tournament_id, group_ids)
|
||||||
else:
|
else:
|
||||||
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
|
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
|
||||||
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)
|
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ _TOURNAMENT_COLUMNS = """
|
||||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||||
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
|
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
|
||||||
cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method,
|
cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method,
|
||||||
tiebreak_playoff_rule
|
tiebreak_playoff_rule, net_display_style
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
||||||
|
|
@ -118,6 +118,14 @@ class Tournament(BaseModel):
|
||||||
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
|
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
|
||||||
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||||
tiebreak_playoff_rule: str | None
|
tiebreak_playoff_rule: str | None
|
||||||
|
# Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
|
||||||
|
# Jesper": IKKE en ny scoring_method (sluttallet er matematisk identisk
|
||||||
|
# med stroke_net sitt netto-til-par), kun en annen presentasjon av
|
||||||
|
# LØPENDE tall midt i runden -- se individual_tournaments.py sin
|
||||||
|
# _attach_stroke_play_columns. KUN gyldig sammen med
|
||||||
|
# scoring_method='stroke_net', håndhevet i update_tournament/
|
||||||
|
# create_tournament under.
|
||||||
|
net_display_style: str
|
||||||
|
|
||||||
|
|
||||||
def _tournament_from_row(row) -> Tournament:
|
def _tournament_from_row(row) -> Tournament:
|
||||||
|
|
@ -151,7 +159,8 @@ async def list_tournaments(
|
||||||
t.registration_requires_approval, t.hero_image_key,
|
t.registration_requires_approval, t.hero_image_key,
|
||||||
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
||||||
t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at,
|
t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at,
|
||||||
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
|
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule,
|
||||||
|
t.net_display_style
|
||||||
FROM tournament t
|
FROM tournament t
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT tournament_id,
|
SELECT tournament_id,
|
||||||
|
|
@ -247,9 +256,16 @@ class TournamentUpdate(BaseModel):
|
||||||
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
|
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
|
||||||
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
|
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
|
||||||
# for vinner-metoden, ikke felt-metoden.
|
# for vinner-metoden, ikke felt-metoden.
|
||||||
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$")
|
tiebreak_winner_method: str | None = Field(
|
||||||
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
|
default=None, pattern="^(none|countback|lowest_hcp|playoff|lowest_playing_hcp)$"
|
||||||
|
)
|
||||||
|
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|lowest_playing_hcp)$")
|
||||||
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
|
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
|
||||||
|
# Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- se
|
||||||
|
# Tournament sin kommentar. Gyldigheten mot scoring_method='stroke_net'
|
||||||
|
# håndheves i update_tournament under (ikke her -- denne modellen kjenner
|
||||||
|
# ikke turneringens gjeldende scoring_method).
|
||||||
|
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
|
||||||
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||||
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||||
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||||
|
|
@ -282,6 +298,25 @@ async def update_tournament(
|
||||||
values.append(tournament_id)
|
values.append(tournament_id)
|
||||||
|
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
# Fail-loudly (migrasjon 092): 'hunt' gir ingen mening utenfor
|
||||||
|
# stroke_net -- de andre metodene har ingen netto-til-par-visning å
|
||||||
|
# endre presentasjonen av. Sjekker den EFFEKTIVE tilstanden ETTER
|
||||||
|
# denne PATCH-en (kan selv sette scoring_method/net_display_style i
|
||||||
|
# samme kall) -- dekker BEGGE retninger: (a) satt til 'hunt' på en
|
||||||
|
# ikke-stroke_net-turnering, (b) scoring_method byttet bort fra
|
||||||
|
# stroke_net mens 'hunt' henger igjen fra før, uendret i denne
|
||||||
|
# PATCH-en. Unngår en stille, meningsløs kombinasjon i databasen.
|
||||||
|
if "scoring_method" in updates or "net_display_style" in updates:
|
||||||
|
current = await conn.fetchrow(
|
||||||
|
"SELECT scoring_method, net_display_style FROM tournament WHERE id = $1", tournament_id
|
||||||
|
)
|
||||||
|
effective_method = updates.get("scoring_method", current["scoring_method"] if current else None)
|
||||||
|
effective_style = updates.get("net_display_style", current["net_display_style"] if current else "standard")
|
||||||
|
if effective_style == "hunt" and effective_method != "stroke_net":
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Jakter Jesper-visningen krever scoring_method='stroke_net'.",
|
||||||
|
)
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
UPDATE tournament SET {', '.join(set_clauses)}
|
UPDATE tournament SET {', '.join(set_clauses)}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Target,
|
||||||
Trash2,
|
Trash2,
|
||||||
Trophy,
|
Trophy,
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -153,6 +154,10 @@ type ApiTournamentInfo = {
|
||||||
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
|
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
|
||||||
// på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
// på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||||
tiebreak_playoff_rule: string | null
|
tiebreak_playoff_rule: string | null
|
||||||
|
// Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
|
||||||
|
// Jesper". IKKE en ny scoring_method -- kun gyldig sammen med
|
||||||
|
// scoring_method="stroke_net". "standard" (default) = uendret oppførsel.
|
||||||
|
net_display_style: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiParticipant = {
|
type ApiParticipant = {
|
||||||
|
|
@ -659,6 +664,29 @@ export function IndividualTournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
|
||||||
|
// Jesper". Samme optimistiske PATCH-mønster som tiebreak/cut over. Ren
|
||||||
|
// presentasjon -- endrer ALDRI selve rangeringen, kun hvilket tall som
|
||||||
|
// vises løpende (se individual_tournaments.py sin
|
||||||
|
// _attach_stroke_play_columns).
|
||||||
|
async function updateNetDisplayStyle(style: string) {
|
||||||
|
if (!tournament) return
|
||||||
|
const previous = tournament
|
||||||
|
setTournament({ ...tournament, net_display_style: style })
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ net_display_style: style }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
} catch {
|
||||||
|
setTournament(previous)
|
||||||
|
setError("Klarte ikke å lagre visningsmodus. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Turneringsinnstillinger (2026-08-18, TournamentSettingsCard --
|
// Turneringsinnstillinger (2026-08-18, TournamentSettingsCard --
|
||||||
// V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN
|
// V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN
|
||||||
// lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her
|
// lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her
|
||||||
|
|
@ -1093,6 +1121,7 @@ export function IndividualTournamentDetail({
|
||||||
onUpdateFlagMapVisible={updateFlagMapVisible}
|
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||||||
onUpdateCutConfig={updateCutConfig}
|
onUpdateCutConfig={updateCutConfig}
|
||||||
onUpdateTiebreakConfig={updateTiebreakConfig}
|
onUpdateTiebreakConfig={updateTiebreakConfig}
|
||||||
|
onUpdateNetDisplayStyle={updateNetDisplayStyle}
|
||||||
onUpdateSettings={updateSettings}
|
onUpdateSettings={updateSettings}
|
||||||
onApplyCut={applyCut}
|
onApplyCut={applyCut}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
|
|
@ -1216,6 +1245,7 @@ function SetupTab({
|
||||||
onUpdateCutConfig,
|
onUpdateCutConfig,
|
||||||
onApplyCut,
|
onApplyCut,
|
||||||
onUpdateTiebreakConfig,
|
onUpdateTiebreakConfig,
|
||||||
|
onUpdateNetDisplayStyle,
|
||||||
onUpdateSettings,
|
onUpdateSettings,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
onAddNewPlayer,
|
onAddNewPlayer,
|
||||||
|
|
@ -1246,6 +1276,7 @@ function SetupTab({
|
||||||
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
|
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
|
||||||
onApplyCut: () => Promise<void>
|
onApplyCut: () => Promise<void>
|
||||||
onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
|
onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
|
||||||
|
onUpdateNetDisplayStyle: (style: string) => Promise<void>
|
||||||
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
|
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
|
||||||
onAddParticipant: (playerId: string) => Promise<void>
|
onAddParticipant: (playerId: string) => Promise<void>
|
||||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||||
|
|
@ -1778,6 +1809,9 @@ function SetupTab({
|
||||||
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
|
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{tournament.scoring_method === "stroke_net" && (
|
||||||
|
<NetDisplayStyleCard tournament={tournament} onUpdateConfig={onUpdateNetDisplayStyle} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TournamentSetupNav>
|
</TournamentSetupNav>
|
||||||
|
|
@ -1905,6 +1939,11 @@ const TIEBREAK_OPTIONS_WINNER: { value: string; label: string; hint: string }[]
|
||||||
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1»." },
|
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1»." },
|
||||||
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
|
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
|
||||||
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
|
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
|
||||||
|
{
|
||||||
|
value: "lowest_playing_hcp",
|
||||||
|
label: "Lavest spillehandicap",
|
||||||
|
hint: "Spilleren med lavest spillehandicap i siste runde vinner uavgjortheten",
|
||||||
|
},
|
||||||
{ value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" },
|
{ value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1912,6 +1951,11 @@ const TIEBREAK_OPTIONS_FIELD: { value: string; label: string; hint: string }[] =
|
||||||
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T5» osv." },
|
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T5» osv." },
|
||||||
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
|
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
|
||||||
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
|
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
|
||||||
|
{
|
||||||
|
value: "lowest_playing_hcp",
|
||||||
|
label: "Lavest spillehandicap",
|
||||||
|
hint: "Spilleren med lavest spillehandicap i siste runde vinner uavgjortheten",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function TiebreakCard({
|
function TiebreakCard({
|
||||||
|
|
@ -2010,6 +2054,43 @@ function TiebreakCard({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visningsmodus for netto-scoring ("Jakter Jesper", migrasjon 092,
|
||||||
|
// 2026-08-23) -- IKKE en ny scoring_method, kun en annen presentasjon av
|
||||||
|
// LØPENDE tall for stroke_net-turneringer (se ADR/CHANGELOG). Egen,
|
||||||
|
// enkel bryter-card, samme mønster som CutCard/TiebreakCard rett over.
|
||||||
|
function NetDisplayStyleCard({
|
||||||
|
tournament,
|
||||||
|
onUpdateConfig,
|
||||||
|
}: {
|
||||||
|
tournament: ApiTournamentInfo
|
||||||
|
onUpdateConfig: (style: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Visningsmodus for netto-score</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Sluttresultatet blir identisk uansett -- dette endrer kun hvordan tallet vises LØPENDE
|
||||||
|
gjennom runden.
|
||||||
|
</p>
|
||||||
|
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tournament.net_display_style === "hunt"}
|
||||||
|
onChange={(e) => void onUpdateConfig(e.target.checked ? "hunt" : "standard")}
|
||||||
|
className="mt-1 size-5 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Vis som Jakter Jesper</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Alle starter på minus spillehandicapet sitt, og hullscore vises brutto mot par
|
||||||
|
løpende -- i stedet for at slag "tjenes inn" gradvis gjennom runden. Av som standard.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
|
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
|
||||||
// ClassesCard for lagturnering-varianten (samme mønster, men uten
|
// ClassesCard for lagturnering-varianten (samme mønster, men uten
|
||||||
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
|
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
|
||||||
|
|
@ -5491,6 +5572,12 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
? tournament.tiebreak_playoff_rule
|
? tournament.tiebreak_playoff_rule
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Jakter Jesper-varsel (migrasjon 092, 2026-08-23) -- ren presentasjon,
|
||||||
|
// samme "informer proaktivt over selve tavlen"-mønster som playoffNote
|
||||||
|
// over. Forklarer HVORFOR tallene starter dypt i minus, for spillere som
|
||||||
|
// ikke kjenner formatet fra før.
|
||||||
|
const huntModeNote = isStrokePlay && tournament.scoring_method === "stroke_net" && tournament.net_display_style === "hunt"
|
||||||
|
|
||||||
// base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i
|
// base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i
|
||||||
// stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken.
|
// stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken.
|
||||||
const [, , printOrgId, , printTournamentId] = base.split("/")
|
const [, , printOrgId, , printTournamentId] = base.split("/")
|
||||||
|
|
@ -5513,6 +5600,14 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{huntModeNote && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div key={group.key} className="flex flex-col gap-2">
|
<div key={group.key} className="flex flex-col gap-2">
|
||||||
{showSectionHeaders && (
|
{showSectionHeaders && (
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
221
tests/test_net_hunt_display.py
Normal file
221
tests/test_net_hunt_display.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""
|
||||||
|
Visningsmodus for netto-scoring ("Jakter Jesper", migrasjon 092,
|
||||||
|
2026-08-23). IKKE en ny scoring_method -- sluttallet er matematisk
|
||||||
|
identisk med stroke_net sitt netto-til-par (`net_total - par_played`).
|
||||||
|
Det eneste som faktisk er nytt er den LØPENDE visningen: standard
|
||||||
|
"tjener inn" handicap-slag gradvis per slagindeks etter hvert som hull
|
||||||
|
spilles, mens 'hunt' trekker fra HELE spillehandicapet med én gang og
|
||||||
|
viser brutto-til-par-differansen løpende. Se
|
||||||
|
`_attach_stroke_play_columns` i individual_tournaments.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.routers.individual_tournaments import (
|
||||||
|
HoleUpdate as TournamentHoleUpdate,
|
||||||
|
individual_leaderboard,
|
||||||
|
update_hole as tournament_update_hole,
|
||||||
|
)
|
||||||
|
from app.routers.tournaments import TournamentUpdate, update_tournament
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
add_membership,
|
||||||
|
create_org,
|
||||||
|
create_org_hole,
|
||||||
|
create_course,
|
||||||
|
create_player,
|
||||||
|
create_tee,
|
||||||
|
create_tournament,
|
||||||
|
create_tournament_participant,
|
||||||
|
create_tournament_round,
|
||||||
|
create_tournament_round_participant,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> None:
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
for n, gross in enumerate(scores, start=1):
|
||||||
|
await tournament_update_hole(
|
||||||
|
tournament_id, round_id, rp_id, n,
|
||||||
|
TournamentHoleUpdate(gross_strokes=gross, expected_version=None),
|
||||||
|
organization_id=org_id, user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_stroke_net_round(org_id, method="stroke_net"):
|
||||||
|
tournament_id = await create_tournament(org_id, name="Jakter Jesper-turnering")
|
||||||
|
course_id = await create_course(org_id, name="Hunt Links")
|
||||||
|
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)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
|
||||||
|
tournament_id, method,
|
||||||
|
)
|
||||||
|
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
|
||||||
|
return tournament_id, course_id, tee_id, round_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, name, playing_handicap):
|
||||||
|
player_id = await create_player(org_id, display_name=name)
|
||||||
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||||
|
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
|
||||||
|
playing_handicap, rp_id,
|
||||||
|
)
|
||||||
|
return tp_id, rp_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_differs_mid_round_but_converges_at_completion(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id)
|
||||||
|
p1, rp1 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P1", 12)
|
||||||
|
|
||||||
|
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
|
||||||
|
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4])
|
||||||
|
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
by_id = {e.tournament_participant_id: e for e in entries}
|
||||||
|
# Standard (default): netto = 4-1=3, til par = 3-4 = -1 -- slaget er
|
||||||
|
# kun DELVIS "tjent inn" ennå (1 av 12 spilte hull).
|
||||||
|
assert by_id[p1].total_label == "-1"
|
||||||
|
|
||||||
|
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
by_id = {e.tournament_participant_id: e for e in entries}
|
||||||
|
# Hunt: gross(4) - FULLT spillehandicap(12) = -8, til par = -8-4 = -12
|
||||||
|
# -- hele handicapet trukket fra med én gang, mye "dypere" tall.
|
||||||
|
assert by_id[p1].total_label == "-12"
|
||||||
|
|
||||||
|
# Fullfør runden med par på alle resterende hull.
|
||||||
|
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 17)
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
by_id = {e.tournament_participant_id: e for e in entries}
|
||||||
|
# Fortsatt -12 under hunt -- flatt hele veien når hvert hull er par.
|
||||||
|
assert by_id[p1].total_label == "-12"
|
||||||
|
|
||||||
|
# Bytt tilbake til standard nå som runden er fullført -- KONVERGERER
|
||||||
|
# til nøyaktig samme tall som hunt (selve beviset på ekvivalensen).
|
||||||
|
await update_tournament(tournament_id, TournamentUpdate(net_display_style="standard"), organization_id=org_id)
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
by_id = {e.tournament_participant_id: e for e in entries}
|
||||||
|
assert by_id[p1].total_label == "-12"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hunt_display_style_rejected_for_non_stroke_net(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stableford' WHERE id = $1",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_switching_away_from_stroke_net_rejected_while_hunt_active(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id)
|
||||||
|
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_tournament(
|
||||||
|
tournament_id, TournamentUpdate(scoring_method="stableford"), organization_id=org_id
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
# Men lov når begge felt endres sammen i samme kall.
|
||||||
|
out = await update_tournament(
|
||||||
|
tournament_id,
|
||||||
|
TournamentUpdate(scoring_method="stableford", net_display_style="standard"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert out.scoring_method == "stableford"
|
||||||
|
assert out.net_display_style == "standard"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lowest_playing_hcp_tiebreak_resolves_field_tie(pool):
|
||||||
|
"""Speiler `test_lowest_hcp_resolves_field_tie_but_not_winner`
|
||||||
|
(test_tiebreak.py) sitt oppsett nøyaktig, men med `stroke_gross`
|
||||||
|
(handicap påvirker IKKE selve rangeringen der -- kun tiebreaken --
|
||||||
|
slik at identiske bruttoscorer faktisk BLIR en reell uavgjort å
|
||||||
|
løse). `stroke_net` ville gjort dette meningsløst: ulikt
|
||||||
|
spillehandicap med identisk brutto gir ulik netto, altså ingen
|
||||||
|
uavgjort i det hele tatt å teste tiebreaken på."""
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id, method="stroke_gross")
|
||||||
|
await update_tournament(
|
||||||
|
tournament_id,
|
||||||
|
TournamentUpdate(tiebreak_winner_method="none", tiebreak_field_method="lowest_playing_hcp"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
leader, rp_leader = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "Leader", 5)
|
||||||
|
p2, rp2 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P2", 8)
|
||||||
|
p3, rp3 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P3", 20)
|
||||||
|
|
||||||
|
await _score_holes(tournament_id, round_id, rp_leader, org_id, owner_id, [3] * 18) # klar leder
|
||||||
|
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) # uavgjort brutto med p3
|
||||||
|
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, [4] * 18) # uavgjort brutto med p2
|
||||||
|
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
by_id = {e.tournament_participant_id: e for e in entries}
|
||||||
|
|
||||||
|
assert by_id[leader].position == "1"
|
||||||
|
assert by_id[p2].position == "2" # lavere spillehandicap (8 < 20) -> foran
|
||||||
|
assert by_id[p3].position == "3"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lowest_playing_hcp_missing_value_sorts_last(pool):
|
||||||
|
"""Speiler `lowest_hcp` sin regel: manglende data kan ALDRI vinne en
|
||||||
|
tiebreak ved å mangle -- sorteres sist. `stroke_gross`, samme
|
||||||
|
begrunnelse som testen over -- spillehandicap er ikke påkrevd for
|
||||||
|
brutto-scoring (`_SCORING_METHODS_REQUIRING_HANDICAP` gjelder ikke
|
||||||
|
her), så et reelt NULL-scenario for tiebreak-metrikken kan faktisk
|
||||||
|
oppstå (ulikt stroke_net, der manglende handicap gjør at netto-
|
||||||
|
tallet aldri blir beregnet i det hele tatt -- deltakeren havner da
|
||||||
|
helt utenfor rangeringen, ikke i en tiebreak-gruppe)."""
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id, method="stroke_gross")
|
||||||
|
# Kun to deltakere, begge uavgjort for 1.-plass -- tiebreak_winner_method
|
||||||
|
# er den relevante innstillingen her (rank==1), ikke feltmetoden.
|
||||||
|
await update_tournament(
|
||||||
|
tournament_id,
|
||||||
|
TournamentUpdate(tiebreak_winner_method="lowest_playing_hcp", tiebreak_field_method="none"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
p1, rp1 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P1", 10)
|
||||||
|
# P2 -- ingen spillehandicap beregnet (NULL, samme som "aldri kjørt
|
||||||
|
# gjennom _compute_round_participant_handicap").
|
||||||
|
player2 = await create_player(org_id, display_name="P2")
|
||||||
|
p2 = await create_tournament_participant(org_id, tournament_id, player2)
|
||||||
|
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
|
||||||
|
|
||||||
|
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 18)
|
||||||
|
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18)
|
||||||
|
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
by_id = {e.tournament_participant_id: e for e in entries}
|
||||||
|
assert by_id[p1].position == "1" # har en verdi -> vinner uavgjortheten
|
||||||
|
assert by_id[p2].position == "2" # mangler verdi -> sist
|
||||||
Loading…
Reference in a new issue