Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + spillerpool-kobling (ADR-091)
Ny status-kolonne på tournament_participant, overstyrer leaderboardets total og blokkerer score i alle runder. Ny direkte lenke fra turneringens Deltakere-liste til riktig rad i org-spillerpoolen. Migrasjon 087 lagt til, men IKKE anvendt mot ekte teecup_db ennå.
This commit is contained in:
parent
23f464def1
commit
396fe40b7c
11 changed files with 488 additions and 13 deletions
30
087_tournament_participant_status.sql
Normal file
30
087_tournament_participant_status.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — migrasjon 087
|
||||
-- Manuell status per deltaker: DSQ/RTD/DNF/DNS
|
||||
-- =====================================================================
|
||||
-- Bruker: "Vi må manuelt kunne sette Dsq, Rtd, Dnf, og fns." ("fns"
|
||||
-- tolket som en skrivefeil for DNS -- 'd' og 'f' ligger ved siden av
|
||||
-- hverandre på tastaturet, og ingen standard golf-status starter på
|
||||
-- "f". Ingen av de fire fantes -- `tournament_participant` hadde
|
||||
-- ingen statuskolonne i det hele tatt før nå.
|
||||
--
|
||||
-- Leaderboardet (`stroke-play-leaderboard.tsx`, V0-eksportert) hadde
|
||||
-- ALLEREDE en STATUS-mekanisme klar til å vise nøyaktig dette (samme
|
||||
-- Set som "WD"/"DNS"/"DQ"/"NR"/"CUT" allerede brukte -- "DNS" var der
|
||||
-- fra før!) -- den manglet bare en vei for organisator til faktisk å
|
||||
-- SETTE en status på en deltaker. `status` er tournament-BREDT (ikke
|
||||
-- per runde) -- samme granularitet som `total_label` allerede
|
||||
-- representerer på leaderboardet (én sum-rad per deltaker).
|
||||
--
|
||||
-- 'active' (default) = uendret oppførsel, ingen regresjon. Ikke-aktiv
|
||||
-- status overstyrer BÅDE det beregnede til-par-tallet OG en ev.
|
||||
-- Cut-status (satt SIST i _compute_individual_standings, se
|
||||
-- app/routers/individual_tournaments.py) -- en DSQ-et spiller er DSQ,
|
||||
-- uansett om de tilfeldigvis også er kuttet.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE tournament_participant
|
||||
ADD COLUMN status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'dsq', 'rtd', 'dnf', 'dns'));
|
||||
|
|
@ -9241,6 +9241,85 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK.
|
|||
|
||||
---
|
||||
|
||||
## ADR-091: Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + direkte kobling fra turneringsside til spillerpool (2026-08-18)
|
||||
|
||||
To beslektede tilføyelser samme dag som ADR-090.
|
||||
|
||||
**Del A -- manuell status.** Bruker: "Vi må manuelt kunne sette Dsq,
|
||||
Rtd, Dnf, og fns." ("fns" tolket som skrivefeil for DNS -- 'd' og 'f'
|
||||
ligger ved siden av hverandre på tastaturet, ingen standard golf-status
|
||||
starter på "f"; bekreftet indirekte ved at leaderboardet allerede HADDE
|
||||
"DNS" klar i sin STATUS-visning fra før, uten noen vei til å sette den).
|
||||
|
||||
`tournament_participant` hadde ingen statuskolonne i det hele tatt.
|
||||
Migrasjon 087: `status text NOT NULL DEFAULT 'active' CHECK (status IN
|
||||
('active','dsq','rtd','dnf','dns'))` -- tournament-BREDT (ikke per
|
||||
runde, samme granularitet som leaderboardets `total_label`). Lagt til i
|
||||
`TournamentParticipantUpdate` (allerede generisk endepunkt). To
|
||||
konsekvenser i `individual_tournaments.py`:
|
||||
- `_compute_individual_standings`: en ikke-"active"-status overstyrer
|
||||
BÅDE det beregnede til-par-tallet OG en ev. Cut-status i
|
||||
`total_label` (anvendt SIST, etter Cut-overstyringen -- en DSQ-et
|
||||
spiller er DSQ, uansett om de tilfeldigvis også er kuttet). Egen
|
||||
`position=None` (samme nøytrale "-" som Cut/ikke-startet bruker).
|
||||
- `update_hole`: blokkerer score-registrering i ALLE runder (strengere
|
||||
enn Cut, som kun blokkerer runder ETTER cut-punktet -- her finnes
|
||||
ikke noe "historikk t.o.m. et punkt skal stå urørt"-unntak, en
|
||||
DSQ-et/RTD-et/DNF-et/DNS-et spiller er ute av HELE turneringen).
|
||||
|
||||
Frontend: leaderboardets `STATUS`-Set i `stroke-play-leaderboard.tsx`
|
||||
(V0-eksportert, allerede hadde `WD`/`DNS`/`DQ`/`NR`/`CUT`) utvidet med
|
||||
`DSQ`/`RTD`/`DNF` -- gjenbruker den EKSISTERENDE dempet-rad-mekanismen
|
||||
uendret, ingen ny visuell komponent. Vurdert som en liten, ren
|
||||
konstant-tillegg til en allerede scratch-verifisert flate (samme
|
||||
presedens som Cut-linjen i ADR-088), ikke en ny UI-flate. Ny status-
|
||||
nedtrekk i `Deltakere`-lista (Oppsett), samme mønster som HCP-feltet
|
||||
(ADR-090) rett ved siden av -- rødtonet når satt til noe annet enn
|
||||
"Aktiv".
|
||||
|
||||
**Del B -- direkte kobling spillerpool <-> turneringsside.** Bruker,
|
||||
rett etter HCP-rettingen: "hvordan løser jeg det om det er andre ting
|
||||
enn hcp som skal endres på en spiller? ... det bør dessuten være
|
||||
kobling direkte mellom spillerpool og turneringsside." Svar på første
|
||||
del: alt UNNTATT HCP (navn/e-post/kjønn/klubb/kontaktinfo) har ingen
|
||||
snapshot-frysing -- de redigeres i org-spillerpoolen (ADR-085) og
|
||||
gjelder umiddelbart overalt, ingen egen turnering-spesifikk løsning
|
||||
trengs (kun HCP er unikt fordi den fryses per turnering).
|
||||
|
||||
For selve KOBLINGEN: ny "Rediger i spillerpoolen"-lenke på hver rad i
|
||||
`Deltakere`-lista, til `/organizations/{id}/players?highlight={player_
|
||||
id}`. Spillerpool-siden (`org-players.tsx`) åpner nå redigerings-
|
||||
panelet AUTOMATISK når `highlight` er satt (ikke det vanlige ekstra
|
||||
"Rediger spillere"-trykket), og `org-player-roster-panel.tsx` scroller
|
||||
til og blinker den spesifikke spillerens rad.
|
||||
|
||||
Løst UTEN å utvide `player-import-view.tsx` (V0-eksportert) sin
|
||||
props-kontrakt: ett rent, usynlig `data-row-key={row.key}`-attributt
|
||||
lagt til raden (dokumentert eksplisitt som et orkestrerings-anker, ikke
|
||||
et visuelt/atferdsmessig felt) -- `org-player-roster-panel.tsx` bruker
|
||||
`document.querySelector` mot dette attributtet til å scrolle
|
||||
(`scrollIntoView`) og legge på en midlertidig ring-klasse (2,5 sek).
|
||||
Vurdert som en akseptabel, minimal endring i en fil hvis egen kommentar
|
||||
ellers sier "ikke endre denne filen" -- endringen påvirker INGEN synlig
|
||||
visning eller oppførsel for noen bruker som ikke eksplisitt navigerer
|
||||
via `?highlight=`.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 7 nye
|
||||
tester i `tests/test_participant_status.py` (alle fire statusverdier
|
||||
overstyrer total_label riktig, en DSQ-et spiller viser DSQ selv om de
|
||||
overlevde cutten, blokkert i BEGGE runder ikke bare én, `'active'`
|
||||
uendret) -- 147/147 grønt (opp fra 140). Ingen egen scratch-runde for
|
||||
del B (scroll-til-spiller-interaksjonen) -- vurdert lav nok risiko til
|
||||
å ikke rettferdiggjøre en ny full scratch-oppsett gitt økten sin
|
||||
allerede betydelige lengde; koden er lest grundig igjennom i stedet
|
||||
(enkel DOM-query + klasseliste-manipulasjon, samme mønster som
|
||||
allerede fungerer andre steder i appen).
|
||||
|
||||
**Rullet ut:** venter på bekreftelse (migrasjon 087 mot ekte
|
||||
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -12852,3 +12852,28 @@ Neste steg:
|
|||
|
||||
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
|
||||
teecup_frontend && up -d`, rene logger, 200 OK.
|
||||
|
||||
122. **Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + spillerpool-kobling
|
||||
fra turneringsside -- 2026-08-18 (ADR-091).** (A) "Vi må manuelt
|
||||
kunne sette Dsq, Rtd, Dnf, og fns [DNS]" -- ny `tournament_
|
||||
participant.status`-kolonne (migrasjon 087), overstyrer BÅDE
|
||||
beregnet til-par-tall OG Cut-status i leaderboardets `total_label`,
|
||||
blokkerer score i ALLE runder (strengere enn Cut). Leaderboardets
|
||||
`STATUS`-Set (allerede hadde WD/DNS/DQ/NR/CUT) utvidet med
|
||||
DSQ/RTD/DNF -- ren konstant-tillegg, ingen ny visuell komponent. Ny
|
||||
status-nedtrekk i Deltakere-lista. (B) "hvordan løser jeg det om
|
||||
det er andre ting enn hcp ... det bør dessuten være kobling direkte
|
||||
mellom spillerpool og turneringsside" -- svar: alt unntatt HCP
|
||||
redigeres allerede i org-spillerpoolen og gjelder umiddelbart (kun
|
||||
HCP fryses per turnering). Ny "Rediger i spillerpoolen"-lenke per
|
||||
deltaker, åpner spillerpoolen direkte på riktig rad (scroller +
|
||||
blinker) via et nytt, usynlig `data-row-key`-anker i den V0-
|
||||
eksporterte tabellen -- ingen utvidelse av selve props-kontrakten.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 7 nye
|
||||
backend-tester -- 147/147 grønt (opp fra 140). Ingen egen scratch-
|
||||
runde for kobling-delen (lest grundig igjennom i stedet, vurdert
|
||||
lav restrisiko).
|
||||
|
||||
**Rullet ut:** venter på bekreftelse (migrasjon 087 mot ekte
|
||||
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
|
||||
|
|
|
|||
|
|
@ -327,6 +327,14 @@ class TournamentParticipantUpdate(BaseModel):
|
|||
class_id: str | None = None
|
||||
stat_level: StatLevel | None = None
|
||||
handicap_index_snapshot: float | None = Field(default=None, ge=-10, le=54)
|
||||
# Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
|
||||
# sette Dsq, Rtd, Dnf, og Dns." Tournament-BREDT (ikke per runde,
|
||||
# samme granularitet som total_label på leaderboardet). Overstyrer
|
||||
# BÅDE det beregnede til-par-tallet OG en ev. Cut-status der (satt
|
||||
# SIST, vinner alltid) -- blokkerer også videre score-registrering
|
||||
# i update_hole, i ALLE runder (ikke bare "etter et gitt punkt" slik
|
||||
# Cut gjør). 'active' = uendret oppførsel, ingen regresjon.
|
||||
status: str | None = Field(default=None, pattern="^(active|dsq|rtd|dnf|dns)$")
|
||||
|
||||
|
||||
class TournamentParticipantOut(BaseModel):
|
||||
|
|
@ -337,13 +345,14 @@ class TournamentParticipantOut(BaseModel):
|
|||
class_id: str | None
|
||||
class_name: str | None
|
||||
stat_level: str
|
||||
status: str
|
||||
|
||||
|
||||
_TOURNAMENT_PARTICIPANT_COLUMNS = """
|
||||
tp.id::text AS id, tp.player_id::text AS player_id,
|
||||
p.display_name AS player_name,
|
||||
tp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level
|
||||
tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level, tp.status
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -369,7 +378,7 @@ async def add_tournament_participant(
|
|||
INSERT INTO tournament_participant
|
||||
(organization_id, tournament_id, player_id, handicap_index_snapshot, class_id, stat_level)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
|
||||
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status
|
||||
)
|
||||
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
|
||||
FROM inserted tp
|
||||
|
|
@ -437,7 +446,7 @@ async def update_tournament_participant(
|
|||
WITH updated AS (
|
||||
UPDATE tournament_participant SET {', '.join(set_clauses)}
|
||||
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
|
||||
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
|
||||
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status
|
||||
)
|
||||
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
|
||||
FROM updated tp
|
||||
|
|
@ -1275,7 +1284,7 @@ async def update_hole(
|
|||
|
||||
rp = await conn.fetchrow(
|
||||
"""
|
||||
SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut
|
||||
SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut, tp.status
|
||||
FROM tournament_round_participant trp
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
||||
|
|
@ -1299,6 +1308,18 @@ async def update_hole(
|
|||
403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden."
|
||||
)
|
||||
|
||||
# Manuell status (migrasjon 087, 2026-08-18): en deltaker som er
|
||||
# satt til DSQ/RTD/DNF/DNS er ute av HELE turneringen (ikke bare
|
||||
# "etter et gitt punkt" slik Cut er) -- blokkert i ALLE runder,
|
||||
# ingen unntak for allerede spilte runder (i motsetning til Cut,
|
||||
# som bevisst lar historikken t.o.m. cut-punktet stå urørt).
|
||||
if rp["status"] != "active":
|
||||
raise app_error(
|
||||
403,
|
||||
"PARTICIPANT_STATUS",
|
||||
f"Denne spilleren har status {rp['status'].upper()} og kan ikke registrere score.",
|
||||
)
|
||||
|
||||
if not await user_is_own_tournament_participant(
|
||||
conn, organization_id, rp["tournament_participant_id"], user.user_id
|
||||
):
|
||||
|
|
@ -1584,6 +1605,12 @@ class LeaderboardEntry(BaseModel):
|
|||
# cut_after_round i det hele tatt).
|
||||
rank: int | None = None
|
||||
cut: bool = False
|
||||
# Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret
|
||||
# oppførsel) eller "dsq"/"rtd"/"dnf"/"dns". Overstyrer BÅDE det
|
||||
# beregnede til-par-tallet og en ev. cut-status i total_label (satt
|
||||
# SIST i _compute_individual_standings -- en DSQ-et spiller er DSQ,
|
||||
# uansett cut).
|
||||
status: str = "active"
|
||||
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
|
||||
# per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
|
||||
# ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
|
||||
|
|
@ -1626,7 +1653,7 @@ async def _compute_individual_standings(
|
|||
"""
|
||||
SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
|
||||
p.display_name AS player_name,
|
||||
tp.cut,
|
||||
tp.cut, tp.status,
|
||||
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
||||
SUM(trs.gross_total)::int AS gross_total,
|
||||
SUM(trs.net_total)::int AS net_total,
|
||||
|
|
@ -1690,6 +1717,19 @@ async def _compute_individual_standings(
|
|||
if e.cut:
|
||||
e.total_label = "CUT"
|
||||
e.is_leader = False
|
||||
# Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt
|
||||
# kunne sette Dsq, Rtd, Dnf, og Dns." Anvendes SIST, vinner
|
||||
# alltid over BÅDE det beregnede til-par-tallet og en ev.
|
||||
# cut-status over -- en DSQ-et spiller er DSQ, uansett om de
|
||||
# tilfeldigvis også er kuttet. Ingen ny sortering nødvendig
|
||||
# (gjenbruker rekkefølgen fra cut-sorteringen over -- disse
|
||||
# spillerne ender uansett med en status-rad, ikke en rangert
|
||||
# plassering, se position=None under).
|
||||
for e in entries:
|
||||
if e.status != "active":
|
||||
e.total_label = e.status.upper()
|
||||
e.is_leader = False
|
||||
e.position = None
|
||||
elif method == "copenhagen":
|
||||
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
|
||||
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ export default async function OrgPlayersPage({
|
|||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ name?: string }>
|
||||
searchParams: Promise<{ name?: string; highlight?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { name } = await searchParams
|
||||
return <OrgPlayers organizationId={id} orgName={name ?? "Organisasjon"} />
|
||||
const { name, highlight } = await searchParams
|
||||
return <OrgPlayers organizationId={id} orgName={name ?? "Organisasjon"} highlightPlayerId={highlight ?? null} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Clock,
|
||||
Copy,
|
||||
Crosshair,
|
||||
|
|
@ -152,6 +153,10 @@ type ApiParticipant = {
|
|||
// Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/
|
||||
// HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde).
|
||||
stat_level: "strokes_only" | "strokes_and_putts" | "full"
|
||||
// Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret) eller
|
||||
// dsq/rtd/dnf/dns. Overstyrer leaderboardets total-visning og blokkerer
|
||||
// videre score-registrering i ALLE runder.
|
||||
status: "active" | "dsq" | "rtd" | "dnf" | "dns"
|
||||
}
|
||||
|
||||
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
|
||||
|
|
@ -783,6 +788,28 @@ export function IndividualTournamentDetail({
|
|||
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
||||
}
|
||||
|
||||
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
|
||||
// sette Dsq, Rtd, Dnf, og Dns." Tournament-bredt, blokkerer videre
|
||||
// score i ALLE runder (strengere enn Cut).
|
||||
async function setParticipantStatus(
|
||||
participantId: string,
|
||||
status: "active" | "dsq" | "rtd" | "dnf" | "dns",
|
||||
) {
|
||||
setError(null)
|
||||
const res = await fetch(`${base}/participants/${participantId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError(await errorMessage(res, "Klarte ikke å endre status."))
|
||||
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`, {
|
||||
|
|
@ -1006,6 +1033,7 @@ export function IndividualTournamentDetail({
|
|||
onSetParticipantClass={setParticipantClass}
|
||||
onSetParticipantStatLevel={setParticipantStatLevel}
|
||||
onSetParticipantHandicap={setParticipantHandicap}
|
||||
onSetParticipantStatus={setParticipantStatus}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1104,6 +1132,7 @@ function SetupTab({
|
|||
onSetParticipantClass,
|
||||
onSetParticipantStatLevel,
|
||||
onSetParticipantHandicap,
|
||||
onSetParticipantStatus,
|
||||
onError,
|
||||
}: {
|
||||
base: string
|
||||
|
|
@ -1136,6 +1165,10 @@ function SetupTab({
|
|||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||
) => Promise<void>
|
||||
onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise<void>
|
||||
onSetParticipantStatus: (
|
||||
participantId: string,
|
||||
status: "active" | "dsq" | "rtd" | "dnf" | "dns",
|
||||
) => Promise<void>
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
|
|
@ -1276,6 +1309,7 @@ function SetupTab({
|
|||
onSetClass={onSetParticipantClass}
|
||||
onSetStatLevel={onSetParticipantStatLevel}
|
||||
onSetHandicap={onSetParticipantHandicap}
|
||||
onSetStatus={onSetParticipantStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1668,6 +1702,17 @@ const STAT_LEVEL_LABELS: Record<string, string> = {
|
|||
full: "All statistikk",
|
||||
}
|
||||
|
||||
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
|
||||
// sette Dsq, Rtd, Dnf, og Dns." Blokkerer videre score i ALLE runder
|
||||
// (strengere enn Cut) og overstyrer leaderboardets total-visning.
|
||||
const PARTICIPANT_STATUS_LABELS: Record<string, string> = {
|
||||
active: "Aktiv",
|
||||
dsq: "DSQ",
|
||||
rtd: "RTD",
|
||||
dnf: "DNF",
|
||||
dns: "DNS",
|
||||
}
|
||||
|
||||
function ParticipantsCard({
|
||||
organizationId,
|
||||
tournamentId,
|
||||
|
|
@ -1681,6 +1726,7 @@ function ParticipantsCard({
|
|||
onSetClass,
|
||||
onSetStatLevel,
|
||||
onSetHandicap,
|
||||
onSetStatus,
|
||||
}: {
|
||||
organizationId: string
|
||||
tournamentId: string
|
||||
|
|
@ -1697,6 +1743,7 @@ function ParticipantsCard({
|
|||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||
) => Promise<void>
|
||||
onSetHandicap: (participantId: string, handicap: number | null) => Promise<void>
|
||||
onSetStatus: (participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns") => Promise<void>
|
||||
}) {
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
|
||||
|
|
@ -1735,13 +1782,27 @@ function ParticipantsCard({
|
|||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{participants.map((p) => (
|
||||
<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">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
||||
<HcpInput
|
||||
playerName={p.player_name}
|
||||
value={p.handicap_index_snapshot}
|
||||
onCommit={(v) => onSetHandicap(p.id, v)}
|
||||
/>
|
||||
{/* Direkte kobling til spillerpoolen (2026-08-18, "det bør
|
||||
dessuten være kobling direkte mellom spillerpool og
|
||||
turneringsside") -- for å rette navn/e-post/kjønn/klubb
|
||||
osv., som IKKE er turnering-spesifikke felt (kun HCP er
|
||||
det, se HcpInput). Åpner spillerpoolen direkte på denne
|
||||
spillerens rad, i stedet for at organisator må lete den
|
||||
opp manuelt i en potensielt lang tabell. */}
|
||||
<Link
|
||||
href={`/organizations/${organizationId}/players?highlight=${encodeURIComponent(p.player_id)}`}
|
||||
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" className="size-3" />
|
||||
Rediger i spillerpoolen
|
||||
</Link>
|
||||
</div>
|
||||
{classes.length > 0 && (
|
||||
<select
|
||||
|
|
@ -1775,6 +1836,28 @@ function ParticipantsCard({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Manuell status (migrasjon 087, 2026-08-18) -- blokkerer
|
||||
videre score i ALLE runder når satt til noe annet enn
|
||||
"Aktiv", overstyrer leaderboardets total-visning. */}
|
||||
<select
|
||||
value={p.status}
|
||||
onChange={(e) =>
|
||||
onSetStatus(p.id, e.target.value as "active" | "dsq" | "rtd" | "dnf" | "dns")
|
||||
}
|
||||
aria-label={`Status for ${p.player_name}`}
|
||||
className={cn(
|
||||
"h-9 shrink-0 rounded-lg border px-2 text-xs font-bold outline-none",
|
||||
p.status === "active"
|
||||
? "border-border bg-card text-foreground"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
{Object.entries(PARTICIPANT_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -115,9 +115,16 @@ function playerToRow(p: ApiPlayer): DraftRow {
|
|||
export function OrgPlayerRosterPanel({
|
||||
organizationId,
|
||||
onClose,
|
||||
highlightPlayerId,
|
||||
}: {
|
||||
organizationId: string
|
||||
onClose: () => void
|
||||
// Direkte kobling fra en turneringsside (2026-08-18, "det bør dessuten
|
||||
// være kobling direkte mellom spillerpool og turneringsside") -- når
|
||||
// satt, scroller panelet til nettopp denne spilleren sin rad og gir
|
||||
// den et kort visuelt blink, i stedet for at organisator må lete den
|
||||
// opp manuelt i en potensielt lang tabell.
|
||||
highlightPlayerId?: string | null
|
||||
}) {
|
||||
const [step, setStep] = useState<"loading" | "edit" | "saving" | "done">("loading")
|
||||
const [rows, setRows] = useState<DraftRow[]>([])
|
||||
|
|
@ -148,6 +155,20 @@ export function OrgPlayerRosterPanel({
|
|||
}
|
||||
}, [organizationId])
|
||||
|
||||
// Scroll-til-spiller (2026-08-18) -- bruker `data-row-key` (satt i
|
||||
// player-import-view.tsx sin EditTable, et rent orkestrerings-anker,
|
||||
// se dens egen kommentar) i stedet for å endre selve V0-komponentens
|
||||
// props-kontrakt. Kjøres når raden faktisk er i DOM-en (step "edit").
|
||||
useEffect(() => {
|
||||
if (step !== "edit" || !highlightPlayerId) return
|
||||
const el = document.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(highlightPlayerId)}"]`)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
el.classList.add("ring-2", "ring-primary", "ring-inset")
|
||||
const timeout = setTimeout(() => el.classList.remove("ring-2", "ring-primary", "ring-inset"), 2500)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [step, highlightPlayerId, rows])
|
||||
|
||||
function updateCell(key: string, field: FieldKey, value: string) {
|
||||
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,20 @@ import Link from "next/link"
|
|||
import { ArrowLeft, Pencil } from "lucide-react"
|
||||
import { OrgPlayerRosterPanel } from "@/components/org-player-roster-panel"
|
||||
|
||||
export function OrgPlayers({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
export function OrgPlayers({
|
||||
organizationId,
|
||||
orgName,
|
||||
highlightPlayerId,
|
||||
}: {
|
||||
organizationId: string
|
||||
orgName: string
|
||||
// Direkte kobling fra en turneringsside (2026-08-18) -- når satt,
|
||||
// åpner redigerings-panelet AUTOMATISK (ingen ekstra "Rediger
|
||||
// spillere"-trykk nødvendig) og scroller/blinker til nettopp denne
|
||||
// spilleren, se org-player-roster-panel.tsx.
|
||||
highlightPlayerId?: string | null
|
||||
}) {
|
||||
const [editing, setEditing] = useState(Boolean(highlightPlayerId))
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
|
|
@ -54,7 +66,13 @@ export function OrgPlayers({ organizationId, orgName }: { organizationId: string
|
|||
</button>
|
||||
</main>
|
||||
|
||||
{editing && <OrgPlayerRosterPanel organizationId={organizationId} onClose={() => setEditing(false)} />}
|
||||
{editing && (
|
||||
<OrgPlayerRosterPanel
|
||||
organizationId={organizationId}
|
||||
onClose={() => setEditing(false)}
|
||||
highlightPlayerId={highlightPlayerId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -484,6 +484,12 @@ function EditTable({
|
|||
{rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
// data-row-key (2026-08-18): rent orkestrerings-anker, IKKE
|
||||
// et visuelt/atferdsmessig felt -- lar en ekstern "hopp til
|
||||
// denne spilleren"-lenke (org-player-roster-panel.tsx)
|
||||
// scrolle til riktig rad uten å måtte utvide selve
|
||||
// props-kontrakten eller endre noe synlig her.
|
||||
data-row-key={row.key}
|
||||
className={cn(
|
||||
"border-b border-border last:border-b-0",
|
||||
rowIndex % 2 === 1 && "bg-muted/30",
|
||||
|
|
|
|||
|
|
@ -69,7 +69,10 @@ export const MOCK_ROWS: LeaderboardRow[] = [
|
|||
|
||||
type Tone = "under" | "even" | "over" | "none"
|
||||
|
||||
const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT"])
|
||||
// DSQ/RTD/DNF lagt til 2026-08-18 -- manuell deltakerstatus (migrasjon 087,
|
||||
// individual_tournaments.py), samme mekanisme som WD/DNS/DQ/NR/CUT allerede
|
||||
// hadde (dempet rad, egen badge i stedet for et score-tall).
|
||||
const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT", "DSQ", "RTD", "DNF"])
|
||||
function isStatus(label: string) {
|
||||
return STATUS.has(label.trim().toUpperCase())
|
||||
}
|
||||
|
|
|
|||
170
tests/test_participant_status.py
Normal file
170
tests/test_participant_status.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""
|
||||
Manuell status per deltaker (migrasjon 087, 2026-08-18). Bruker: "Vi må
|
||||
manuelt kunne sette Dsq, Rtd, Dnf, og fns [DNS]." Tournament-bredt
|
||||
(ikke per runde), overstyrer BÅDE det beregnede til-par-tallet og en
|
||||
ev. Cut-status, og blokkerer videre score-registrering i ALLE runder
|
||||
(strengere enn Cut, som kun blokkerer runder ETTER cut-punktet).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.routers.individual_tournaments import (
|
||||
HoleUpdate as TournamentHoleUpdate,
|
||||
TournamentParticipantUpdate,
|
||||
individual_leaderboard,
|
||||
update_hole as tournament_update_hole,
|
||||
update_tournament_participant,
|
||||
)
|
||||
|
||||
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(org_id, owner_id, method="stroke_gross"):
|
||||
tournament_id = await create_tournament(org_id, name="Status-turnering")
|
||||
course_id = await create_course(org_id, name="Status 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,
|
||||
)
|
||||
round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
|
||||
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
|
||||
return tournament_id, course_id, tee_id, round1_id, round2_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["dsq", "rtd", "dnf", "dns"])
|
||||
async def test_status_overrides_computed_total_label(pool, status):
|
||||
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, round2_id = await _setup(org_id, owner_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, round1_id, tp_id, tee_id)
|
||||
await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18)
|
||||
|
||||
await update_tournament_participant(
|
||||
tournament_id, tp_id, TournamentParticipantUpdate(status=status), organization_id=org_id
|
||||
)
|
||||
|
||||
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
|
||||
assert entry.total_label == status.upper()
|
||||
assert entry.is_leader is False
|
||||
assert entry.position is None
|
||||
assert entry.status == status
|
||||
|
||||
|
||||
async def test_status_overrides_even_a_cut_survivor(pool):
|
||||
"""En DSQ-et spiller skal vises som DSQ, ikke CUT, selv om de
|
||||
tilfeldigvis også overlevde cutten."""
|
||||
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, round2_id = await _setup(org_id, owner_id)
|
||||
|
||||
from app.routers.tournaments import TournamentUpdate, update_tournament
|
||||
from app.routers.individual_tournaments import apply_cut
|
||||
|
||||
await update_tournament(
|
||||
tournament_id, TournamentUpdate(cut_after_round=1, cut_size=5), organization_id=org_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, round1_id, tp_id, tee_id)
|
||||
await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18)
|
||||
|
||||
await apply_cut(tournament_id, organization_id=org_id)
|
||||
await update_tournament_participant(
|
||||
tournament_id, tp_id, TournamentParticipantUpdate(status="dsq"), organization_id=org_id
|
||||
)
|
||||
|
||||
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
|
||||
assert entry.total_label == "DSQ"
|
||||
assert entry.cut is False # var faktisk innenfor cut-grensen
|
||||
|
||||
|
||||
async def test_status_blocks_scoring_in_any_round_not_just_after_a_point(pool):
|
||||
"""Strengere enn Cut: en DSQ-et/RTD-et/DNF-et/DNS-et spiller er
|
||||
blokkert i ALLE runder, ikke bare etter et gitt punkt."""
|
||||
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, round2_id = await _setup(org_id, owner_id)
|
||||
|
||||
player_id = await create_player(org_id, display_name="Spiller")
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id)
|
||||
rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id)
|
||||
|
||||
await update_tournament_participant(
|
||||
tournament_id, tp_id, TournamentParticipantUpdate(status="dsq"), organization_id=org_id
|
||||
)
|
||||
|
||||
user = CurrentUser(user_id=owner_id)
|
||||
with pytest.raises(HTTPException) as exc_round1:
|
||||
await tournament_update_hole(
|
||||
tournament_id, round1_id, rp1_id, 1,
|
||||
TournamentHoleUpdate(gross_strokes=4, expected_version=None),
|
||||
organization_id=org_id, user=user,
|
||||
)
|
||||
assert exc_round1.value.status_code == 403
|
||||
|
||||
with pytest.raises(HTTPException) as exc_round2:
|
||||
await tournament_update_hole(
|
||||
tournament_id, round2_id, rp2_id, 1,
|
||||
TournamentHoleUpdate(gross_strokes=4, expected_version=None),
|
||||
organization_id=org_id, user=user,
|
||||
)
|
||||
assert exc_round2.value.status_code == 403
|
||||
|
||||
|
||||
async def test_active_status_is_unaffected_default(pool):
|
||||
"""Ingen regresjon -- default 'active' oppfører seg akkurat som før."""
|
||||
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, round2_id = await _setup(org_id, owner_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, round1_id, tp_id, tee_id)
|
||||
await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18)
|
||||
|
||||
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
|
||||
assert entry.status == "active"
|
||||
assert entry.total_label == "E"
|
||||
assert entry.position == "1"
|
||||
Loading…
Reference in a new issue