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:
Erol Haagenrud 2026-08-18 17:29:59 +02:00
parent fb00c65430
commit 0c864f0705
8 changed files with 413 additions and 22 deletions

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

View file

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

View file

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

View file

@ -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 spillere?": å redigere spillerens indeks i selve
spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE
turnering-påmeldinger -- en allerede satt `handicap_index_snapshot`
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)

View file

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

View file

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

View file

@ -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, 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 grensen uansett

View 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), 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