Omspill-tiebreak for vinneren + direkte HCP-retting per deltaker (ADR-090)
'playoff' som ny tiebreak_winner_method-verdi -- rangeres som 'none' (appen kan ikke avgjøre et omspill selv), men regelen vises proaktivt i Oppsett og som banner på leaderboardet. HCP: la til handicap_index_ snapshot i TournamentParticipantUpdate + inline redigering i Deltakere-lista, uten å røre allerede frosne runde-verdier. Migrasjon 086 lagt til, men IKKE anvendt mot ekte teecup_db ennå.
This commit is contained in:
parent
fb00c65430
commit
0c864f0705
8 changed files with 413 additions and 22 deletions
30
086_tournament_playoff.sql
Normal file
30
086_tournament_playoff.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — migrasjon 086
|
||||
-- Omspill (playoff) som eget tiebreak-valg for VINNEREN + fritekst-
|
||||
-- regel som skal informeres om før turneringen
|
||||
-- =====================================================================
|
||||
-- Bruker, etter tiebreak (ADR-089): "ved lik score for leder; nytt
|
||||
-- valg: omspill med avgjørelse. Dette bør informeres om før
|
||||
-- turneringen. (Eks: Omspill på hull 18 til vinner er kåret.)"
|
||||
--
|
||||
-- KUN et gyldig valg for `tiebreak_winner_method` (ikke `tiebreak_
|
||||
-- field_method`) -- bruker sin egen formulering var eksplisitt "ved
|
||||
-- lik score for LEDER", og et omspill for f.eks. delt 8.-plass er ikke
|
||||
-- hva som ble bedt om. Appen kan ikke selv AVGJØRE et omspill (det
|
||||
-- skjer på banen, utenfor scoring-systemet) -- rangeringslogikken
|
||||
-- behandler derfor `'playoff'` likt som `'none'` (delt plassering,
|
||||
-- ingen automatisk oppløsning), se `_attach_stroke_play_columns`.
|
||||
-- Forskjellen er ren INFORMASJON: `tiebreak_playoff_rule` er fritekst
|
||||
-- organisator setter (f.eks. "Sudden death fra hull 18, til vinner er
|
||||
-- kåret") -- vist i Oppsett OG på leaderboardet (organisatorens/
|
||||
-- spillernes felles visningsflate) FØR en eventuell uavgjort faktisk
|
||||
-- oppstår, ikke bare når den skjer.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
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')),
|
||||
ADD COLUMN tiebreak_playoff_rule text;
|
||||
|
|
@ -9179,6 +9179,67 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK.
|
|||
|
||||
---
|
||||
|
||||
## ADR-090: Omspill (playoff) som tiebreak-valg for vinneren + direkte HCP-retting per deltaker (2026-08-18)
|
||||
|
||||
To mindre, beslektede oppfølgere samme dag som ADR-089.
|
||||
|
||||
**Del A -- Omspill.** Bruker: "ved lik score for leder; nytt valg:
|
||||
omspill med avgjørelse. Dette bør informeres om før turneringen. (Eks:
|
||||
Omspill på hull 18 til vinner er kåret.)"
|
||||
|
||||
Migrasjon 086: `tiebreak_winner_method` sin CHECK utvidet med `'playoff'`
|
||||
(KUN gyldig for vinner-metoden -- bruker sin egen formulering var
|
||||
eksplisitt "ved lik score for LEDER", ikke feltet forøvrig), ny
|
||||
`tiebreak_playoff_rule text` (fritekst, f.eks. "Sudden death fra hull
|
||||
18, til vinner er kåret"). Appen kan ikke selv AVGJØRE et omspill (det
|
||||
skjer på banen, utenfor scoring-systemet) -- `_attach_stroke_play_
|
||||
columns` behandler derfor `'playoff'` NØYAKTIG som `'none'` i selve
|
||||
rangeringen (delt plassering, ingen automatisk oppløsning, samme
|
||||
`is_leader=true` for alle tiede ledere). Forskjellen er ren
|
||||
INFORMASJON: regelen vises proaktivt to steder -- i Oppsett (`TiebreakCard`,
|
||||
et nytt tekstfelt som dukker opp når "Omspill" velges) OG på selve
|
||||
leaderboardet (`LeaderboardTab` i `individual-tournament-detail.tsx`,
|
||||
en gull-farget banner øverst, ALLTID synlig når konfigurert -- ikke
|
||||
bare når en uavgjort faktisk oppstår, per "dette bør informeres om FØR
|
||||
turneringen").
|
||||
|
||||
**Del B -- direkte HCP-retting per deltaker.** Bruker: "Hvordan ...
|
||||
justerer hcp på spillere?" Undersøkt: å redigere en spiller i org-
|
||||
spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE
|
||||
turnering-påmeldinger -- en allerede satt `tournament_participant.
|
||||
handicap_index_snapshot` ble ALDRI rørt (samme "frosset snapshot"-
|
||||
filosofi `PlayerUpdate` sin docstring allerede dokumenterer for
|
||||
lagturneringers `team_roster`). Ingen vei fantes til å rette EN
|
||||
SPESIFIKK deltakers indeks direkte i turneringen den faktisk gjelder
|
||||
for.
|
||||
|
||||
Løst ved å legge `handicap_index_snapshot` til `TournamentParticipantUpdate`
|
||||
(endepunktet var allerede fullt generisk -- ingen andre backend-endringer
|
||||
nødvendig). Endrer BEVISST kun selve `tournament_participant`-raden --
|
||||
allerede opprettede `tournament_round_participant.course_handicap`/
|
||||
`playing_handicap` for runder deltakeren allerede er satt opp i, forblir
|
||||
UENDRET (samme "ingen retroaktiv omskriving av frosne runde-verdier"-
|
||||
prinsipp, bekreftet med egen test). Frontend: `Deltakere`-lista i Oppsett
|
||||
fikk en ny inline `HcpInput` (lite tallfelt, forpliktet på blur, samme
|
||||
mønster som andre tallfelt i appen) i stedet for den tidligere rene
|
||||
tekstvisningen av HCP.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 1 ny
|
||||
test for omspill (bekrefter delt plassering, identisk til `'none'`) +
|
||||
2 nye tester for HCP-rettingen (verdien settes, OG at en allerede
|
||||
frosset runde-verdi IKKE skrives over) -- 140/140 grønt (opp fra 137).
|
||||
Ingen egen scratch-/mock-runde denne gangen -- begge er små, godt
|
||||
avgrensede tilføyelser til allerede scratch-verifiserte komponenter
|
||||
(`TiebreakCard` fra ADR-089, `Deltakere`-lista sitt etablerte
|
||||
inline-rediger-mønster fra klasse-/statistikknivå-feltene rett ved
|
||||
siden av), vurdert som lav restrisiko gitt sterk typesikkerhet og
|
||||
testdekning.
|
||||
|
||||
**Rullet ut:** venter på bekreftelse (migrasjon 086 mot ekte
|
||||
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -12815,3 +12815,26 @@ Neste steg:
|
|||
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
|
||||
teecup_api teecup_frontend && up -d` for begge, rene logger, 200
|
||||
OK.
|
||||
|
||||
120. **Omspill (playoff) + direkte HCP-retting per deltaker -- 2026-08-18
|
||||
(ADR-090).** To mindre oppfølgere: (A) "ved lik score for leder;
|
||||
nytt valg: omspill med avgjørelse. Dette bør informeres om før
|
||||
turneringen" -- ny `'playoff'`-verdi for `tiebreak_winner_method`
|
||||
(migrasjon 086, kun gyldig for vinneren), fritekst `tiebreak_
|
||||
playoff_rule`. Rangeringen behandler `'playoff'` identisk til
|
||||
`'none'` (appen kan ikke selv avgjøre et omspill) -- forskjellen er
|
||||
at regelen nå vises PROAKTIVT, både i Oppsett og som en gull-banner
|
||||
øverst på leaderboardet, uansett om noen faktisk er tiet akkurat nå.
|
||||
(B) "Hvordan justerer jeg hcp på spillere?" -- spillerpoolen
|
||||
(ADR-085) rørte aldri en allerede frosset `tournament_participant.
|
||||
handicap_index_snapshot`. Lagt til i `TournamentParticipantUpdate`
|
||||
(allerede generisk endepunkt, ingen andre backend-endringer), ny
|
||||
inline `HcpInput` i Deltakere-lista. Endrer bevisst KUN selve
|
||||
snapshot-raden -- allerede opprettede runde-handicap forblir frosset.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 3 nye
|
||||
backend-tester -- 140/140 grønt (opp fra 137). Ingen egen scratch-
|
||||
runde (små tilføyelser til allerede scratch-verifiserte flater).
|
||||
|
||||
**Rullet ut:** venter på bekreftelse (migrasjon 086 mot ekte
|
||||
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
|
||||
|
|
|
|||
|
|
@ -306,10 +306,27 @@ class TournamentParticipantCreate(BaseModel):
|
|||
|
||||
|
||||
class TournamentParticipantUpdate(BaseModel):
|
||||
"""exclude_unset, samme PATCH-semantikk som resten av appen."""
|
||||
"""exclude_unset, samme PATCH-semantikk som resten av appen.
|
||||
|
||||
`handicap_index_snapshot` (2026-08-18) -- bruker spurte "hvordan
|
||||
justerer jeg hcp på spillere?": å redigere spillerens indeks i selve
|
||||
spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE
|
||||
turnering-påmeldinger -- en allerede satt `handicap_index_snapshot`
|
||||
på en eksisterende `tournament_participant` ble ALDRI rørt (samme
|
||||
"frosset snapshot"-filosofi som `PlayerUpdate` sin docstring allerede
|
||||
dokumenterer for lagturneringers `team_roster`). Dette feltet er den
|
||||
manglende veien til å rette EN SPESIFIKK deltakers indeks direkte i
|
||||
turneringen den faktisk gjelder for. Endrer BEVISST KUN selve
|
||||
tournament_participant-raden -- allerede opprettede `tournament_
|
||||
round_participant.course_handicap`/`playing_handicap` for runder
|
||||
deltakeren allerede er satt opp i, forblir UENDRET (samme "ingen
|
||||
retroaktiv omskriving av frosne runde-verdier"-prinsipp) -- kun
|
||||
NYE rundepåmeldinger fra dette punktet av bruker den rettede
|
||||
indeksen."""
|
||||
|
||||
class_id: str | None = None
|
||||
stat_level: StatLevel | None = None
|
||||
handicap_index_snapshot: float | None = Field(default=None, ge=-10, le=54)
|
||||
|
||||
|
||||
class TournamentParticipantOut(BaseModel):
|
||||
|
|
@ -2114,7 +2131,12 @@ async def _attach_stroke_play_columns(
|
|||
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":
|
||||
# 'playoff' (migrasjon 086) behandles IKKE her -- appen kan ikke
|
||||
# selv avgjøre et omspill (det skjer på banen), så en "playoff"-
|
||||
# gruppe faller gjennom til else-grenen under akkurat som 'none':
|
||||
# delt plassering, ingen automatisk oppløsning. Forskjellen er
|
||||
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
|
||||
if tiebreak_method in ("countback", "lowest_hcp"):
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ _TOURNAMENT_COLUMNS = """
|
|||
visibility, description, registration_deadline, registration_capacity,
|
||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||
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
|
||||
"""
|
||||
|
||||
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
||||
|
|
@ -110,6 +111,13 @@ class Tournament(BaseModel):
|
|||
# feltet kan ha ulik metode.
|
||||
tiebreak_winner_method: str
|
||||
tiebreak_field_method: str
|
||||
# Omspill (migrasjon 086, 2026-08-18) -- 'playoff' er KUN gyldig for
|
||||
# tiebreak_winner_method (ikke felt-metoden), se individual_
|
||||
# tournaments.py sin kommentar for hvorfor rangeringen behandler den
|
||||
# likt som 'none'. tiebreak_playoff_rule er fritekst organisator
|
||||
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
|
||||
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||
tiebreak_playoff_rule: str | None
|
||||
|
||||
|
||||
def _tournament_from_row(row) -> Tournament:
|
||||
|
|
@ -143,7 +151,7 @@ async def list_tournaments(
|
|||
t.registration_requires_approval, t.hero_image_key,
|
||||
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.tiebreak_winner_method, t.tiebreak_field_method
|
||||
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
|
||||
FROM tournament t
|
||||
LEFT JOIN (
|
||||
SELECT tournament_id,
|
||||
|
|
@ -237,9 +245,11 @@ class TournamentUpdate(BaseModel):
|
|||
cut_after_round: int | None = Field(default=None, ge=1)
|
||||
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)$")
|
||||
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
|
||||
# for vinner-metoden, ikke felt-metoden.
|
||||
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$")
|
||||
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
|
||||
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
|
||||
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ type ApiTournamentInfo = {
|
|||
// plasseringsvisning. "none" = delt plassering (uendret oppførsel).
|
||||
tiebreak_winner_method: string
|
||||
tiebreak_field_method: string
|
||||
// Omspill (migrasjon 086, 2026-08-18) -- "playoff" er kun gyldig for
|
||||
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
|
||||
// på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||
tiebreak_playoff_rule: string | null
|
||||
}
|
||||
|
||||
type ApiParticipant = {
|
||||
|
|
@ -558,16 +562,25 @@ 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) {
|
||||
async function updateTiebreakConfig(winner: string, field: string, playoffRule: string | null) {
|
||||
if (!tournament) return
|
||||
const previous = tournament
|
||||
setTournament({ ...tournament, tiebreak_winner_method: winner, tiebreak_field_method: field })
|
||||
setTournament({
|
||||
...tournament,
|
||||
tiebreak_winner_method: winner,
|
||||
tiebreak_field_method: field,
|
||||
tiebreak_playoff_rule: playoffRule,
|
||||
})
|
||||
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 }),
|
||||
body: JSON.stringify({
|
||||
tiebreak_winner_method: winner,
|
||||
tiebreak_field_method: field,
|
||||
tiebreak_playoff_rule: playoffRule,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
} catch {
|
||||
|
|
@ -748,6 +761,28 @@ export function IndividualTournamentDetail({
|
|||
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
||||
}
|
||||
|
||||
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
|
||||
// spillerpoolen (ADR-085) endrer kun grunnlaget for FREMTIDIGE
|
||||
// rundepåmeldinger, aldri en allerede satt handicap_index_snapshot her.
|
||||
// Rører ALDRI allerede opprettede tournament_round_participant.course_
|
||||
// handicap for runder deltakeren allerede er satt opp i (samme "frosset
|
||||
// snapshot"-prinsipp som ADR-085 sin bulk-import allerede har).
|
||||
async function setParticipantHandicap(participantId: string, handicap: number | null) {
|
||||
setError(null)
|
||||
const res = await fetch(`${base}/participants/${participantId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ handicap_index_snapshot: handicap }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError(await errorMessage(res, "Klarte ikke å endre handicap."))
|
||||
return
|
||||
}
|
||||
const updated: ApiParticipant = await res.json()
|
||||
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
||||
}
|
||||
|
||||
async function createClass(name: string, defaultTeeId: string | null) {
|
||||
setError(null)
|
||||
const res = await fetch(`${base}/classes`, {
|
||||
|
|
@ -970,6 +1005,7 @@ export function IndividualTournamentDetail({
|
|||
onDeleteClass={deleteClass}
|
||||
onSetParticipantClass={setParticipantClass}
|
||||
onSetParticipantStatLevel={setParticipantStatLevel}
|
||||
onSetParticipantHandicap={setParticipantHandicap}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1067,6 +1103,7 @@ function SetupTab({
|
|||
onDeleteClass,
|
||||
onSetParticipantClass,
|
||||
onSetParticipantStatLevel,
|
||||
onSetParticipantHandicap,
|
||||
onError,
|
||||
}: {
|
||||
base: string
|
||||
|
|
@ -1081,7 +1118,7 @@ function SetupTab({
|
|||
onUpdateFlagMapVisible: (visible: boolean) => void
|
||||
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
|
||||
onApplyCut: () => Promise<void>
|
||||
onUpdateTiebreakConfig: (winner: string, field: string) => Promise<void>
|
||||
onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
|
||||
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
|
||||
onAddParticipant: (playerId: string) => Promise<void>
|
||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||
|
|
@ -1098,6 +1135,7 @@ function SetupTab({
|
|||
participantId: string,
|
||||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||
) => Promise<void>
|
||||
onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise<void>
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
|
|
@ -1237,6 +1275,7 @@ function SetupTab({
|
|||
onRemoveParticipant={onRemoveParticipant}
|
||||
onSetClass={onSetParticipantClass}
|
||||
onSetStatLevel={onSetParticipantStatLevel}
|
||||
onSetHandicap={onSetParticipantHandicap}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1350,8 +1389,20 @@ function CutCard({
|
|||
// 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." },
|
||||
// "Omspill" (migrasjon 086, 2026-08-18) -- KUN et gyldig valg for
|
||||
// vinneren (bruker sin egen formulering: "ved lik score for LEDER"),
|
||||
// ikke for resten av feltet -- appen kan ikke selv avgjøre et omspill,
|
||||
// det skjer på banen. tiebreak_playoff_rule er fritekst vist proaktivt
|
||||
// her OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||
const TIEBREAK_OPTIONS_WINNER: { value: string; label: string; hint: string }[] = [
|
||||
{ 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: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
|
||||
{ value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" },
|
||||
]
|
||||
|
||||
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: "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" },
|
||||
]
|
||||
|
|
@ -1361,17 +1412,21 @@ function TiebreakCard({
|
|||
onUpdateConfig,
|
||||
}: {
|
||||
tournament: ApiTournamentInfo
|
||||
onUpdateConfig: (winner: string, field: string) => Promise<void>
|
||||
onUpdateConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
|
||||
}) {
|
||||
const [winner, setWinner] = useState(tournament.tiebreak_winner_method)
|
||||
const [field, setField] = useState(tournament.tiebreak_field_method)
|
||||
const [playoffRule, setPlayoffRule] = useState(tournament.tiebreak_playoff_rule ?? "")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const dirty = winner !== tournament.tiebreak_winner_method || field !== tournament.tiebreak_field_method
|
||||
const dirty =
|
||||
winner !== tournament.tiebreak_winner_method ||
|
||||
field !== tournament.tiebreak_field_method ||
|
||||
playoffRule !== (tournament.tiebreak_playoff_rule ?? "")
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
await onUpdateConfig(winner, field)
|
||||
await onUpdateConfig(winner, field, winner === "playoff" ? playoffRule.trim() || null : null)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
|
|
@ -1391,14 +1446,14 @@ function TiebreakCard({
|
|||
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) => (
|
||||
{TIEBREAK_OPTIONS_WINNER.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}
|
||||
{TIEBREAK_OPTIONS_WINNER.find((o) => o.value === winner)?.hint}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1.5">
|
||||
|
|
@ -1408,18 +1463,34 @@ function TiebreakCard({
|
|||
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) => (
|
||||
{TIEBREAK_OPTIONS_FIELD.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}
|
||||
{TIEBREAK_OPTIONS_FIELD.find((o) => o.value === field)?.hint}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{winner === "playoff" && (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-bold text-foreground">Omspill-format</span>
|
||||
<input
|
||||
type="text"
|
||||
value={playoffRule}
|
||||
onChange={(e) => setPlayoffRule(e.target.value)}
|
||||
placeholder="F.eks. Sudden death fra hull 18, til vinner er kåret."
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Vises på leaderboardet slik at deltakerne kjenner formatet før turneringen.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
|
|
@ -1605,6 +1676,7 @@ function ParticipantsCard({
|
|||
onRemoveParticipant,
|
||||
onSetClass,
|
||||
onSetStatLevel,
|
||||
onSetHandicap,
|
||||
}: {
|
||||
organizationId: string
|
||||
tournamentId: string
|
||||
|
|
@ -1620,6 +1692,7 @@ function ParticipantsCard({
|
|||
participantId: string,
|
||||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||
) => Promise<void>
|
||||
onSetHandicap: (participantId: string, handicap: number | null) => Promise<void>
|
||||
}) {
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
|
||||
|
|
@ -1660,9 +1733,11 @@ function ParticipantsCard({
|
|||
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
|
||||
</span>
|
||||
<HcpInput
|
||||
playerName={p.player_name}
|
||||
value={p.handicap_index_snapshot}
|
||||
onCommit={(v) => onSetHandicap(p.id, v)}
|
||||
/>
|
||||
</div>
|
||||
{classes.length > 0 && (
|
||||
<select
|
||||
|
|
@ -1720,6 +1795,56 @@ function ParticipantsCard({
|
|||
)
|
||||
}
|
||||
|
||||
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
|
||||
// lokal utkast-tilstand, forpliktet KUN på blur (ikke per tastetrykk) --
|
||||
// samme mønster som tallfelt ellers i appen. Tomt felt = fjern HCP-en
|
||||
// (spilleren mister evt. adgang til netto-/Stableford-runder som krever
|
||||
// den, samme validering som ved ny påmelding).
|
||||
function HcpInput({
|
||||
playerName,
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
playerName: string
|
||||
value: number | null
|
||||
onCommit: (next: number | null) => Promise<void>
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value !== null ? String(value) : "")
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value !== null ? String(value) : "")
|
||||
}, [value])
|
||||
|
||||
function commit() {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed === "") {
|
||||
if (value !== null) void onCommit(null)
|
||||
return
|
||||
}
|
||||
const n = Number(trimmed)
|
||||
if (Number.isFinite(n) && n !== value) void onCommit(n)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Hcp</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step={0.1}
|
||||
min={-10}
|
||||
max={54}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
aria-label={`HCP for ${playerName}`}
|
||||
placeholder="—"
|
||||
className="h-8 w-16 rounded-lg border border-border bg-background px-2 text-xs font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
|
||||
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
|
||||
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
|
||||
|
|
@ -4456,8 +4581,26 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
|||
)
|
||||
}
|
||||
|
||||
// Omspill-varsel (migrasjon 086, 2026-08-18) -- vist proaktivt her,
|
||||
// FØR en eventuell uavgjort faktisk oppstår ("Dette bør informeres om
|
||||
// før turneringen"), ikke bare når leaderboardet viser en delt
|
||||
// førsteplass. Én global innstilling, vises én gang over gruppene
|
||||
// (ikke per klasse-seksjon).
|
||||
const playoffNote =
|
||||
isStrokePlay && tournament.tiebreak_winner_method === "playoff" && tournament.tiebreak_playoff_rule
|
||||
? tournament.tiebreak_playoff_rule
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{playoffNote && (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-gold bg-gold/10 px-4 py-3">
|
||||
<Trophy aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-gold-foreground" />
|
||||
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
|
||||
Ved likt resultat for 1. plass: {playoffNote}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<div key={group.key} className="flex flex-col gap-2">
|
||||
{showSectionHeaders && (
|
||||
|
|
|
|||
|
|
@ -160,6 +160,38 @@ async def test_tiebreak_method_none_keeps_shared_position(pool):
|
|||
assert by_id[p2].is_leader is True
|
||||
|
||||
|
||||
async def test_playoff_method_keeps_shared_position_like_none(pool):
|
||||
"""'playoff' (migrasjon 086) -- appen kan ikke selv avgjøre et
|
||||
omspill, så en tiebreak_winner_method='playoff'-turnering skal
|
||||
oppføre seg NØYAKTIG som 'none' i selve rangeringen (delt plassering,
|
||||
ingen automatisk oppløsning) -- forskjellen er ren informasjon
|
||||
(tiebreak_playoff_rule), ikke rangeringslogikk."""
|
||||
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 update_tournament(
|
||||
tournament_id,
|
||||
TournamentUpdate(tiebreak_winner_method="playoff", tiebreak_playoff_rule="Sudden death fra hull 18."),
|
||||
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, 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 på grensen uansett
|
||||
|
|
|
|||
70
tests/test_tournament_participant_hcp.py
Normal file
70
tests/test_tournament_participant_hcp.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""
|
||||
Retting av en spillers handicap-indeks DIREKTE i en turnering
|
||||
(2026-08-18). Bruker: "Hvordan ... justerer hcp på spillere?" -- å
|
||||
redigere spilleren i org-spillerpoolen (ADR-085) rører aldri en
|
||||
allerede satt `tournament_participant.handicap_index_snapshot` (samme
|
||||
"frosset snapshot"-filosofi som lagturneringers team_roster), så det
|
||||
manglet en direkte vei til å rette EN SPESIFIKK deltakers indeks i
|
||||
turneringen den faktisk gjelder for.
|
||||
"""
|
||||
|
||||
from app.routers.individual_tournaments import (
|
||||
TournamentParticipantUpdate,
|
||||
update_tournament_participant,
|
||||
)
|
||||
|
||||
from tests.conftest import (
|
||||
create_org,
|
||||
create_course,
|
||||
create_player,
|
||||
create_tee,
|
||||
create_tournament,
|
||||
create_tournament_participant,
|
||||
create_tournament_round,
|
||||
create_tournament_round_participant,
|
||||
)
|
||||
|
||||
import app.db as app_db
|
||||
|
||||
|
||||
async def test_update_handicap_snapshot(pool):
|
||||
org_id = await create_org()
|
||||
tournament_id = await create_tournament(org_id)
|
||||
player_id = await create_player(org_id, display_name="Spiller")
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute(
|
||||
"UPDATE tournament_participant SET handicap_index_snapshot = 24.0 WHERE id = $1", tp_id
|
||||
)
|
||||
|
||||
updated = await update_tournament_participant(
|
||||
tournament_id, tp_id, TournamentParticipantUpdate(handicap_index_snapshot=11.5), organization_id=org_id
|
||||
)
|
||||
assert updated.handicap_index_snapshot == 11.5
|
||||
|
||||
|
||||
async def test_updating_snapshot_does_not_retroactively_change_already_frozen_round_handicap(pool):
|
||||
"""Samme "frosset snapshot"-prinsipp som PlayerUpdate allerede
|
||||
dokumenterer for team_roster -- en rettelse i tournament_participant
|
||||
skal ALDRI skrive over et allerede satt tournament_round_participant.
|
||||
course_handicap/playing_handicap for en runde deltakeren allerede er
|
||||
satt opp i."""
|
||||
org_id = await create_org()
|
||||
tournament_id = await create_tournament(org_id)
|
||||
course_id = await create_course(org_id)
|
||||
tee_id = await create_tee(org_id, course_id)
|
||||
round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||
player_id = await create_player(org_id, display_name="Spiller")
|
||||
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, course_handicap=10)
|
||||
|
||||
await update_tournament_participant(
|
||||
tournament_id, tp_id, TournamentParticipantUpdate(handicap_index_snapshot=2.0), organization_id=org_id
|
||||
)
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
frozen = await conn.fetchval(
|
||||
"SELECT course_handicap FROM tournament_round_participant WHERE id = $1", rp_id
|
||||
)
|
||||
assert frozen == 10 # uendret, ikke omregnet fra den nye indeksen
|
||||
Loading…
Reference in a new issue