Tiebreak ved likt resultat: countback eller laveste HCP (ADR-089)

Organisator velger metode fritt i Oppsett, uavhengig for vinner og
resten av feltet. Countback er kaskaderende siste 9->6->3->1 hull i
siste runde. Påvirker aldri Cut-grensen -- strukturelt håndhevet, ikke
bare avtalt ved konvensjon. Migrasjon 085 lagt til, men IKKE anvendt mot
ekte teecup_db ennå -- venter på bekreftelse.
This commit is contained in:
Erol Haagenrud 2026-08-18 16:29:10 +02:00
parent d57f4e5478
commit 300cb8aeaf
7 changed files with 634 additions and 10 deletions

View file

@ -0,0 +1,36 @@
-- =====================================================================
-- TeeCup — migrasjon 085
-- Tiebreak ved likt resultat i individuelle turneringer (brutto/netto/
-- stableford): countback eller laveste HCP, valgt av organisator
-- =====================================================================
-- Bruker spurte, etter å ha fått Cut-funksjonen (ADR-087): "Ved likt
-- resultat ... Skal laveste hcp plasseres foran, beste siste 18/9/6/3/
-- siste hull plasseres foran, eller deles plasseringene. Her kan svaret
-- være forskjellig for vinner og resten av feltet."
--
-- Design bekreftet med bruker (tre spørsmål):
-- - Metoden er IKKE fastlåst i koden -- "Dette skal defineres av
-- arrangøren ved oppsett av turneringen" -- for BÅDE vinneren og
-- resten av feltet, hver for seg (samme svar begge steder, altså to
-- uavhengige innstillinger, ikke én felles).
-- - Tiebreak påvirker KUN sluttresultatet/plasseringsvisningen, ALDRI
-- selve Cut-grensen (Cut sin "topp N og delt plass" fra ADR-087
-- forblir uendret -- fullt delt plass på grensen, uansett hva
-- tiebreak-innstillingen sier).
--
-- `tiebreak_winner_method`/`tiebreak_field_method`: 'none' (uendret
-- oppførsel -- delt plassering, dagens eneste faktiske adferd) er
-- default, ingen regresjon for eksisterende turneringer. 'countback' =
-- kaskaderende "matching cards" (siste 9 -> siste 6 -> siste 3 -> siste
-- hull i turneringens SISTE runde, standard klubbgolf-metode -- ingen
-- egen dybde-parameter, kaskaden er alltid full). 'lowest_hcp' = laveste
-- handicap-indeks vinner uavgjortheten.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament
ADD COLUMN tiebreak_winner_method text NOT NULL DEFAULT 'none'
CHECK (tiebreak_winner_method IN ('none', 'countback', 'lowest_hcp')),
ADD COLUMN tiebreak_field_method text NOT NULL DEFAULT 'none'
CHECK (tiebreak_field_method IN ('none', 'countback', 'lowest_hcp'));

View file

@ -9101,6 +9101,83 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK.
--- ---
## ADR-089: Tiebreak ved likt resultat -- countback eller laveste HCP, organisator velger (2026-08-18)
Bruker, etter Cut (ADR-087): "Dersom jeg ønsker at f.eks Tied 12. plass
skal klare cut'en, hvordan løser vi det? ... En annen ting: Ved likt
resultat ... Skal laveste hcp plasseres foran, beste siste 18/9/6/3/
siste hull plasseres foran, eller deles plasseringene. Her kan svaret
være forskjellig for vinner og resten av feltet."
**Første spørsmål var allerede løst** -- Cut sin "topp N og delt plass"
(ADR-087) bruker RANGERING, ikke et hodetall, som cut-grense: alle med
samme til-par som grenseplassen slipper gjennom, uansett hvor mange.
Ingen endring nødvendig, kun bekreftet med et testeksempel.
**Andre spørsmål var en ekte, udesignet mangel** -- ingen tiebreak-logikk
fantes i det hele tatt, delte plasseringer ble ALLTID vist som delt
("T1"), for vinneren og resten av feltet likt. Design avklart med
bruker (tre spørsmål):
- **Metoden er IKKE fastlåst i koden** -- "Dette skal defineres av
arrangøren ved oppsett av turneringen", for BÅDE vinneren og resten
av feltet, som TO UAVHENGIGE innstillinger (kan være ulik metode for
hver).
- **Tiebreak påvirker KUN sluttresultatet/plasseringsvisningen, ALDRI
selve Cut-grensen** -- Cut sin fulle delte plass (ADR-087) forblir
uendret uansett tiebreak-innstilling.
**Migrasjon 085:** `tournament.tiebreak_winner_method`/
`tiebreak_field_method` (`'none'` default -- uendret oppførsel, ingen
regresjon -- `'countback'`, `'lowest_hcp'`).
**Backend (`app/routers/individual_tournaments.py`):**
- `_countback_sort_keys`: kaskaderende "matching cards"-countback --
siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens SISTE
runde (standard klubbgolf-metode, ingen egen dybde-innstilling å
konfigurere -- kaskaden er alltid full). Python sin tuple-
sammenligning gir selve kaskaden gratis: `(siste9, siste6, siste3,
siste1)` sammenlignet elementvis er nøyaktig hvordan en reell
countback fungerer. Gjenbruker `allocate_over_played_holes`/
`stableford_points_for_hole` fra `handicap_engine.py` (samme
primitiver som resten av scoringen) for netto/Stableford per hull.
- `_lowest_hcp_sort_keys`: `tournament_participant.handicap_index_
snapshot`, manglende indeks sorteres sist (kan aldri vinne en
tiebreak ved å mangle data).
- Begge integrert i `_attach_stroke_play_columns` sin eksisterende
POS-løkke: en tidligere fullt delt gruppe får nå sin tiebreak-metode
anvendt (vinner-metoden for rank 1, felt-metoden ellers), tildelt med
samme "vanlig konkurranse-rangering"-algoritme REKURSIVT innad i
gruppen (`_assign_ranks_within_group`) -- resterende uavgjorte ETTER
tiebreak (samme nøkkel også der) deler fortsatt plass innbyrdes,
tiebreak garanterer ikke en total orden, den reduserer bare hvor ofte
uavgjort faktisk skjer.
- **Cut-grensen upåvirket, håndhevet strukturelt, ikke bare ved
konvensjon:** tiebreak-koden er portet bak `max_sequence is None` --
`apply_cut` (ADR-087) sender ALLTID en satt `max_sequence`, så
tiebreak-grenen kan aldri kjøre derfra, uansett hvilken metode som er
konfigurert. Egen test bekrefter dette eksplisitt (countback slått på
OG en ekte fullt uavgjort cut-grense -- begge slipper likevel gjennom).
**Frontend:** ny `TiebreakCard` i Oppsett-fanen, rett under `CutCard`,
samme "vanlig innstillingskort"-mønster (håndkodet, ikke V0 -- liten
tilføyelse til allerede håndkodede innstillingskort i samme fil, se
[[feedback_frontend_via_v0]]) -- to uavhengige nedtrekk (vinner/felt),
hver med "Delt plassering"/"Countback"/"Laveste HCP".
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 4 nye
tester i `tests/test_tiebreak.py` (countback løser en vinner-uavgjort
på bedre back9, laveste HCP løser en felt-uavgjort MEN lar en klar
vinner stå urørt, `'none'` beholder uendret delt-plassering-oppførsel,
og en eksplisitt cut-grense-test med countback slått PÅ som likevel
ikke bryter en ekte uavgjort cut-grense) -- 135/135 grønt (opp fra 131),
kjørt via `scripts/run_backend_tests.sh` (egen scratch-database).
**Rullet ut:** venter på bekreftelse (migrasjon 085 mot ekte `teecup_db`
krever egen bekreftelse, se CLAUDE.md).
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -12708,3 +12708,32 @@ Neste steg:
**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 084 kjørt mot **Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 084 kjørt mot
ekte `teecup_db`, deretter `docker compose build teecup_api ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge, rene logger, 200 OK. teecup_frontend && up -d` for begge, rene logger, 200 OK.
117. **Tiebreak ved likt resultat -- 2026-08-18 (ADR-089).** Bruker,
etter Cut: bekreftet at "topp N og delt plass" allerede fungerer
riktig for "mange kan dele 12.-plassen" (rangering, ikke hodetall).
Ekte gap derimot: ingen tiebreak-logikk fantes -- delte plasseringer
ble alltid vist delt, for vinner OG felt likt. Bruker: metoden skal
"defineres av arrangøren ved oppsett", som TO uavhengige
innstillinger (vinner/felt kan ha ulik metode), og skal ALDRI
påvirke Cut-grensen -- kun sluttresultatet.
Migrasjon 085 (`tournament.tiebreak_winner_method`/`tiebreak_field_
method`, default `'none'`). Ny `_countback_sort_keys` (kaskaderende
siste 9->6->3->1 hull i siste runde, standard klubbgolf-metode,
Python tuple-sammenligning gir kaskaden gratis) og `_lowest_hcp_
sort_keys`, begge integrert i den eksisterende POS-løkken i
`_attach_stroke_play_columns`. Cut-uavhengigheten er strukturelt
håndhevet (portet bak `max_sequence is None`, ikke bare avtalt ved
konvensjon) -- `apply_cut` kan aldri trigge tiebreak-grenen uansett
konfigurasjon. Ny `TiebreakCard` i Oppsett, samme mønster som
`CutCard`.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 4 nye
backend-tester i `tests/test_tiebreak.py` (countback, laveste HCP,
uendret `'none'`-oppførsel, og en eksplisitt bekreftelse på at Cut
IKKE påvirkes selv med countback slått på) -- 135/135 grønt (opp fra
131).
**Rullet ut:** venter på bekreftelse (migrasjon 085 mot ekte
`teecup_db` krever egen bekreftelse, se CLAUDE.md).

View file

@ -1596,7 +1596,9 @@ 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 FROM tournament WHERE id = $1", tournament_id "SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method "
"FROM tournament WHERE id = $1",
tournament_id,
) )
if tournament is None: if tournament is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
@ -1643,7 +1645,15 @@ async def _compute_individual_standings(
# deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran
# deltakere med -5 til par over 27-36 hull, kun fordi rått # deltakere med -5 til par over 27-36 hull, kun fordi rått
# slagtall er mindre jo færre hull man har spilt. # slagtall er mindre jo færre hull man har spilt.
await _attach_stroke_play_columns(conn, tournament_id, method, entries, max_sequence=max_sequence) await _attach_stroke_play_columns(
conn,
tournament_id,
method,
entries,
max_sequence=max_sequence,
tiebreak_winner_method=tournament["tiebreak_winner_method"],
tiebreak_field_method=tournament["tiebreak_field_method"],
)
# 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å
# frossent til-par-tall alene (en kuttet spiller sin score endrer # frossent til-par-tall alene (en kuttet spiller sin score endrer
@ -1788,8 +1798,128 @@ def _to_par_tone(v: int) -> str:
return "even" return "even"
async def _lowest_hcp_sort_keys(conn, participant_ids: list[str]) -> dict[str, tuple]:
"""Tiebreak 'lowest_hcp' (migrasjon 085) -- laveste handicap-indeks
vinner. Manglende indeks sorteres SIST (kan aldri vinne en tiebreak
ved å mangle data)."""
rows = await conn.fetch(
"SELECT id::text AS id, handicap_index_snapshot::float AS hcp "
"FROM tournament_participant WHERE id = ANY($1::uuid[])",
participant_ids,
)
return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows}
async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]:
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens
SISTE runde (standard klubbgolf-metode, ingen egen dybde-innstilling
-- kaskaden er alltid full). Python sin tuple-sammenligning gjør selve
kaskaden gratis: (siste9, siste6, siste3, siste1), sammenlignet
elementvis i rekkefølge, akkurat slik en reell countback fungerer.
ASCENDING sortering, lavere er alltid bedre -- Stableford-poeng
negeres for å passe samme konvensjon (flere poeng er bedre der).
Bruker turneringens ABSOLUTT SISTE runde (høyest sequence) som
grunnlag, uansett hvor mange runder DENNE deltakeren faktisk har
spilt -- en ekte uavgjort sluttstilling forutsetter normalt at begge
har spilt like mange runder. Mangler en deltaker data for den runden
(har ikke spilt den ennå), får de en nøytral midtverdi (0,0,0,0) --
ingen krasj, bare ingen reell tiebreak-fordel/ulempe."""
last_round = await conn.fetchrow(
"SELECT id, course_id FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence DESC LIMIT 1",
tournament_id,
)
if last_round is None:
return {pid: (0, 0, 0, 0) for pid in participant_ids}
holes_meta = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
last_round["course_id"],
)
par_by_hole = {h["hole_number"]: h["par"] for h in holes_meta}
all_18_si = [h["stroke_index"] for h in holes_meta]
keys: dict[str, tuple] = {}
for pid in participant_ids:
rp = await conn.fetchrow(
"SELECT id, playing_handicap FROM tournament_round_participant "
"WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
last_round["id"],
pid,
)
if rp is None:
keys[pid] = (0, 0, 0, 0)
continue
hole_rows = await conn.fetch(
"SELECT hole_number, gross_strokes FROM tournament_round_hole "
"WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL ORDER BY hole_number",
rp["id"],
)
if not hole_rows:
keys[pid] = (0, 0, 0, 0)
continue
played_numbers = [h["hole_number"] for h in hole_rows]
gross_by_hole = {h["hole_number"]: h["gross_strokes"] for h in hole_rows}
received = [0] * len(played_numbers)
if method != "stroke_gross" and rp["playing_handicap"] is not None:
received = allocate_over_played_holes(rp["playing_handicap"], all_18_si, played_numbers)
per_hole_values = []
for hole_number, strokes_received in zip(played_numbers, received):
gross = gross_by_hole[hole_number]
par = par_by_hole[hole_number]
if method == "stableford":
per_hole_values.append(stableford_points_for_hole(par, gross, strokes_received))
else:
per_hole_values.append((gross - strokes_received) - par)
def suffix_sum(n: int, values: list[int] = per_hole_values) -> int:
return sum(values[-n:]) if values else 0
last9, last6, last3, last1 = suffix_sum(9), suffix_sum(6), suffix_sum(3), suffix_sum(1)
keys[pid] = (-last9, -last6, -last3, -last1) if method == "stableford" else (last9, last6, last3, last1)
return keys
def _assign_ranks_within_group(
entries: list[LeaderboardEntry], indices: list[int], sort_keys: dict[str, tuple], base_rank: int
) -> None:
"""Tildeler posisjon/rank til en (tidligere fullt uavgjort) gruppe
ETTER at en tiebreak-nøkkel er beregnet -- samme "vanlig konkurranse-
rangering, uavgjort deler plass"-algoritme som hovedløkken under,
men startet `base_rank` i stedet for 1. Gjenværende uavgjorte
(samme nøkkel også etter tiebreak) deler fortsatt plass innbyrdes --
tiebreak GARANTERER ikke en total orden, den reduserer bare hvor ofte
uavgjort faktisk skjer."""
ordered = sorted(indices, key=lambda idx: sort_keys[entries[idx].tournament_participant_id])
n = len(ordered)
i = 0
while i < n:
j = i
key_i = sort_keys[entries[ordered[i]].tournament_participant_id]
while j < n and sort_keys[entries[ordered[j]].tournament_participant_id] == key_i:
j += 1
rank = base_rank + i
tied = (j - i) > 1
for k in range(i, j):
idx = ordered[k]
entries[idx].position = f"T{rank}" if tied else str(rank)
entries[idx].rank = rank
entries[idx].is_leader = rank == 1
i = j
async def _attach_stroke_play_columns( async def _attach_stroke_play_columns(
conn, tournament_id: str, method: str, entries: list[LeaderboardEntry], max_sequence: int | None = None conn,
tournament_id: str,
method: str,
entries: list[LeaderboardEntry],
max_sequence: int | None = None,
tiebreak_winner_method: str = "none",
tiebreak_field_method: str = "none",
) -> 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
@ -1799,6 +1929,17 @@ async def _attach_stroke_play_columns(
per-runde nedbrytning, og posisjon-med-uavgjort-håndtering. per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
`max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`. `max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`.
`tiebreak_winner_method`/`tiebreak_field_method` (migrasjon 085,
2026-08-18) -- BEVISST kun brukt når `max_sequence is None`. Cut-
beregningen (apply_cut) kaller alltid denne funksjonen MED en satt
max_sequence, og skal ALDRI la tiebreak påvirke hvem som slipper
gjennom (bruker bekreftet eksplisitt: "Kun sluttresultat, ikke cut")
-- cut-grensen forblir ren rangering med full delt plass uansett.
Den vanlige leaderboard-visningen (`individual_leaderboard`) sender
aldri max_sequence, tiebreak gjelder der -- inkludert midt i
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
beste vi vet akkurat "-filosofi som resten av leaderboardet).
""" """
rounds_meta = await conn.fetch( rounds_meta = await conn.fetch(
""" """
@ -1965,6 +2106,22 @@ async def _attach_stroke_play_columns(
rank = i + 1 rank = i + 1
tied = (j - i) > 1 tied = (j - i) > 1
not_started_group = key_i == NO_SCORE not_started_group = key_i == NO_SCORE
# Tiebreak (migrasjon 085) -- kun for en FAKTISK uavgjort gruppe,
# aldri for cut-beregningen (se _attach_stroke_play_columns sin
# docstring for hvorfor max_sequence is None er selve porten).
tiebreak_method = "none"
if tied and not not_started_group and max_sequence is None:
tiebreak_method = tiebreak_winner_method if rank == 1 else tiebreak_field_method
if tiebreak_method != "none":
group_ids = [entries[k].tournament_participant_id for k in range(i, j)]
if tiebreak_method == "countback":
sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids)
else:
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)
else:
for k in range(i, j): for k in range(i, j):
entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank))
entries[k].is_leader = rank == 1 and not not_started_group entries[k].is_leader = rank == 1 and not not_started_group

View file

@ -62,7 +62,7 @@ _TOURNAMENT_COLUMNS = """
visibility, description, registration_deadline, registration_capacity, visibility, description, registration_deadline, registration_capacity,
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 cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method
""" """
# 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).
@ -101,6 +101,15 @@ class Tournament(BaseModel):
cut_after_round: int | None cut_after_round: int | None
cut_size: int | None cut_size: int | None
cut_applied_at: datetime | None cut_applied_at: datetime | None
# Tiebreak (migrasjon 085, 2026-08-18) -- KUN meningsfullt for
# individuelle stroke play/net/stableford-turneringer, se
# individual_tournaments.py sin _attach_stroke_play_columns. Påvirker
# ALDRI Cut-grensen (ADR-087/088) -- kun sluttresultatets
# plasseringsvisning. 'none' (default) = delt plassering, uendret
# oppførsel. To UAVHENGIGE innstillinger -- vinneren og resten av
# feltet kan ha ulik metode.
tiebreak_winner_method: str
tiebreak_field_method: str
def _tournament_from_row(row) -> Tournament: def _tournament_from_row(row) -> Tournament:
@ -133,7 +142,8 @@ async def list_tournaments(
t.registration_capacity, t.registration_overflow_policy, t.registration_capacity, t.registration_overflow_policy,
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
FROM tournament t FROM tournament t
LEFT JOIN ( LEFT JOIN (
SELECT tournament_id, SELECT tournament_id,
@ -216,6 +226,10 @@ class TournamentUpdate(BaseModel):
# denne PATCH-en er kun for selve innstillingene). # denne PATCH-en er kun for selve innstillingene).
cut_after_round: int | None = Field(default=None, ge=1) cut_after_round: int | None = Field(default=None, ge=1)
cut_size: int | None = Field(default=None, gt=0) cut_size: int | None = Field(default=None, gt=0)
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
# se Tournament sin kommentar.
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
# 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.

View file

@ -117,6 +117,12 @@ type ApiTournamentInfo = {
cut_after_round: number | null cut_after_round: number | null
cut_size: number | null cut_size: number | null
cut_applied_at: string | null cut_applied_at: string | null
// Tiebreak (migrasjon 085, 2026-08-18) -- kun meningsfullt for brutto/
// netto/stableford. To UAVHENGIGE innstillinger (vinner/felt kan ha
// ulik metode). Påvirker ALDRI Cut-grensen, kun sluttresultatets
// plasseringsvisning. "none" = delt plassering (uendret oppførsel).
tiebreak_winner_method: string
tiebreak_field_method: string
} }
type ApiParticipant = { type ApiParticipant = {
@ -536,6 +542,27 @@ export function IndividualTournamentDetail({
} }
} }
// Tiebreak (migrasjon 085, 2026-08-18) -- ren KONFIGURASJON, samme
// optimistiske PATCH-mønster som scoringsmetode/cut over. Påvirker
// ALDRI Cut-grensen, kun sluttresultatets plasseringsvisning.
async function updateTiebreakConfig(winner: string, field: string) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, tiebreak_winner_method: winner, tiebreak_field_method: field })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ tiebreak_winner_method: winner, tiebreak_field_method: field }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å lagre tiebreak-innstillingene. Prøv igjen.")
}
}
// MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE // MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE
// automatisk ved fullspilt runde) -- idempotent på serveren, men likevel // automatisk ved fullspilt runde) -- idempotent på serveren, men likevel
// en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra // en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra
@ -876,6 +903,7 @@ export function IndividualTournamentDetail({
onUpdateBbbSweepBonus={updateBbbSweepBonus} onUpdateBbbSweepBonus={updateBbbSweepBonus}
onUpdateFlagMapVisible={updateFlagMapVisible} onUpdateFlagMapVisible={updateFlagMapVisible}
onUpdateCutConfig={updateCutConfig} onUpdateCutConfig={updateCutConfig}
onUpdateTiebreakConfig={updateTiebreakConfig}
onApplyCut={applyCut} onApplyCut={applyCut}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant} onAddNewPlayer={addNewPlayerAndParticipant}
@ -952,6 +980,7 @@ function SetupTab({
onUpdateFlagMapVisible, onUpdateFlagMapVisible,
onUpdateCutConfig, onUpdateCutConfig,
onApplyCut, onApplyCut,
onUpdateTiebreakConfig,
onAddParticipant, onAddParticipant,
onAddNewPlayer, onAddNewPlayer,
onImportComplete, onImportComplete,
@ -978,6 +1007,7 @@ function SetupTab({
onUpdateFlagMapVisible: (visible: boolean) => void onUpdateFlagMapVisible: (visible: boolean) => void
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) => Promise<void>
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void> onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void> onImportComplete: () => Promise<void>
@ -1088,7 +1118,10 @@ function SetupTab({
{tournament.scoring_method != null && {tournament.scoring_method != null &&
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && ( ["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
<>
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} /> <CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} />
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
</>
)} )}
<ClassesCard <ClassesCard
@ -1219,6 +1252,95 @@ function CutCard({
) )
} }
// Tiebreak (migrasjon 085, 2026-08-18) -- "Ved likt resultat ... Skal
// laveste hcp plasseres foran, beste siste 18/9/6/3/siste hull plasseres
// foran, eller deles plasseringene. Her kan svaret være forskjellig for
// vinner og resten av feltet." Bruker: metoden skal defineres av
// organisator ved oppsett, IKKE fastlåst i koden -- derfor to
// uavhengige nedtrekk. Samme "vanlig innstillingskort"-mønster som
// CutCard rett over.
const TIEBREAK_OPTIONS: { value: string; label: string; hint: string }[] = [
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1», «T5» osv." },
{ 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" },
]
function TiebreakCard({
tournament,
onUpdateConfig,
}: {
tournament: ApiTournamentInfo
onUpdateConfig: (winner: string, field: string) => Promise<void>
}) {
const [winner, setWinner] = useState(tournament.tiebreak_winner_method)
const [field, setField] = useState(tournament.tiebreak_field_method)
const [saving, setSaving] = useState(false)
const dirty = winner !== tournament.tiebreak_winner_method || field !== tournament.tiebreak_field_method
async function handleSave() {
setSaving(true)
await onUpdateConfig(winner, field)
setSaving(false)
}
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">Tiebreak ved likt resultat</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Velges uavhengig for vinneren og resten av feltet. Påvirker kun sluttresultatets
plassering -- Cut-grensen (over) bruker alltid full delt plass, uansett denne innstillingen.
</p>
<div className="flex flex-col gap-4 sm:flex-row">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Ved likt resultat for vinneren</span>
<select
value={winner}
onChange={(e) => setWinner(e.target.value)}
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{TIEBREAK_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{TIEBREAK_OPTIONS.find((o) => o.value === winner)?.hint}
</span>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Ved likt resultat i resten av feltet</span>
<select
value={field}
onChange={(e) => setField(e.target.value)}
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{TIEBREAK_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{TIEBREAK_OPTIONS.find((o) => o.value === field)?.hint}
</span>
</label>
</div>
<button
type="button"
onClick={() => void handleSave()}
disabled={!dirty || saving}
className="inline-flex h-11 w-fit items-center justify-center gap-2 self-end rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre innstilling"}
</button>
</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).

189
tests/test_tiebreak.py Normal file
View file

@ -0,0 +1,189 @@
"""
Tiebreak ved likt resultat i individuelle turneringer (migrasjon 085,
2026-08-18). Bruker: "Ved likt resultat ... Skal laveste hcp plasseres
foran, beste siste 18/9/6/3/siste hull plasseres foran, eller deles
plasseringene. Her kan svaret være forskjellig for vinner og resten av
feltet." Metoden er en fri organisator-innstilling, ikke fastlåst --
to UAVHENGIGE felt (vinner/felt), og påvirker ALDRI Cut-grensen (kun
sluttresultatets plasseringsvisning).
"""
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
apply_cut,
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_single_round(org_id, owner_id, method="stroke_gross"):
tournament_id = await create_tournament(org_id, name="Tiebreak-turnering")
course_id = await create_course(org_id, name="Tiebreak 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 _set_tiebreak(org_id, tournament_id, winner: str, field: str):
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method=winner, tiebreak_field_method=field),
organization_id=org_id,
)
async def test_countback_resolves_winner_tie_by_better_back_nine(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_single_round(org_id, owner_id)
await _set_tiebreak(org_id, tournament_id, winner="countback", field="none")
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
p3 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P3"))
rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id)
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
rp3 = await create_tournament_round_participant(org_id, round_id, p3, tee_id)
# P1: front9 alle par (36), back9 to birdier (34) -> total 70, back9=34.
p1_scores = [4] * 9 + [3, 3] + [4] * 7
# P2: front9 to birdier (34), back9 alle par (36) -> total 70, back9=36 (dårligere enn P1 sin back9).
p2_scores = [3, 3] + [4] * 7 + [4] * 9
# P3: klart bak, ingen uavgjort.
p3_scores = [5] * 18
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, p1_scores)
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, p2_scores)
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, p3_scores)
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" # bedre back9 -> sole leder, IKKE "T1"
assert by_id[p1].is_leader is True
assert by_id[p2].position == "2"
assert by_id[p2].is_leader is False
assert by_id[p3].position == "3"
async def test_lowest_hcp_resolves_field_tie_but_not_winner(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_single_round(org_id, owner_id)
# Vinner-tiebreak IKKE satt (none) -- kun felt-tiebreak skal virke.
await _set_tiebreak(org_id, tournament_id, winner="none", field="lowest_hcp")
leader = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="Leader"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
p3 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P3"))
async with app_db.org_connection(org_id) as conn:
await conn.execute("UPDATE tournament_participant SET handicap_index_snapshot = 8.0 WHERE id = $1", p2)
await conn.execute("UPDATE tournament_participant SET handicap_index_snapshot = 20.0 WHERE id = $1", p3)
rp_leader = await create_tournament_round_participant(org_id, round_id, leader, tee_id)
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
rp3 = await create_tournament_round_participant(org_id, round_id, p3, tee_id)
await _score_holes(tournament_id, round_id, rp_leader, org_id, owner_id, [3] * 18) # klar leder, -18
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) # uavgjort med p3, E
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, [4] * 18) # uavgjort med p2, E
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[leader].is_leader is True
assert by_id[p2].position == "2" # lavere HCP (8.0 < 20.0) -> foran
assert by_id[p3].position == "3"
async def test_tiebreak_method_none_keeps_shared_position(pool):
"""Standard/uendret oppførsel -- ingen tiebreak konfigurert i det hele
tatt (default 'none' for begge felt, ingen regresjon)."""
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_single_round(org_id, owner_id)
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id)
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 == "T1"
assert by_id[p2].position == "T1"
assert by_id[p1].is_leader is True
assert by_id[p2].is_leader is True
async def test_tiebreak_never_affects_cut_boundary(pool):
"""Bruker bekreftet eksplisitt: tiebreak påvirker KUN sluttresultatet,
ALDRI selve cut-grensen -- full delt plass grensen uansett
tiebreak-innstilling."""
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, round1_id = await _setup_single_round(org_id, owner_id)
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
# Countback konfigurert -- skal likevel IKKE påvirke cutten.
await _set_tiebreak(org_id, tournament_id, winner="countback", field="countback")
await update_tournament(
tournament_id, TournamentUpdate(cut_after_round=1, cut_size=1), organization_id=org_id,
)
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
rp1 = await create_tournament_round_participant(org_id, round1_id, p1, tee_id)
rp2 = await create_tournament_round_participant(org_id, round1_id, p2, tee_id)
# Identisk totalt OG identisk back9 -- ekte fullt uavgjort, selv med countback slått på.
await _score_holes(tournament_id, round1_id, rp1, org_id, owner_id, [4] * 18)
await _score_holes(tournament_id, round1_id, rp2, org_id, owner_id, [4] * 18)
result = await apply_cut(tournament_id, organization_id=org_id)
assert result.cut_size == 1
assert result.survivors == 2 # begge slipper gjennom -- delt 1.-plass, IKKE brutt av countback
assert result.cut_count == 0