diff --git a/086_tournament_playoff.sql b/086_tournament_playoff.sql new file mode 100644 index 0000000..1c5e44e --- /dev/null +++ b/086_tournament_playoff.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index e0d3ad4..e5ef1c2 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43807a5..7dca128 100644 --- a/CHANGELOG.md +++ b/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). diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 456cffc..317d054 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -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) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index ee888fb..beb3a54 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -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. diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 47ccfa2..efee3f9 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -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 onApplyCut: () => Promise - onUpdateTiebreakConfig: (winner: string, field: string) => Promise + onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise onUpdateSettings: (next: TournamentSettingsValues) => Promise onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (input: NewPlayerInput) => Promise @@ -1098,6 +1135,7 @@ function SetupTab({ participantId: string, statLevel: "strokes_only" | "strokes_and_putts" | "full", ) => Promise + onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise onError: (message: string) => void }) { const [pool, setPool] = useState([]) @@ -1237,6 +1275,7 @@ function SetupTab({ onRemoveParticipant={onRemoveParticipant} onSetClass={onSetParticipantClass} onSetStatLevel={onSetParticipantStatLevel} + onSetHandicap={onSetParticipantHandicap} /> ) @@ -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 + onUpdateConfig: (winner: string, field: string, playoffRule: string | null) => Promise }) { 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) => ( ))} - {TIEBREAK_OPTIONS.find((o) => o.value === winner)?.hint} + {TIEBREAK_OPTIONS_WINNER.find((o) => o.value === winner)?.hint} + {winner === "playoff" && ( + + )} +