diff --git a/087_tournament_participant_status.sql b/087_tournament_participant_status.sql
new file mode 100644
index 0000000..d5fa2a6
--- /dev/null
+++ b/087_tournament_participant_status.sql
@@ -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'));
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index ae497b3..f34bbe7 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c2511f..1878552 100644
--- a/CHANGELOG.md
+++ b/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).
diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py
index 317d054..90fdc66 100644
--- a/app/routers/individual_tournaments.py
+++ b/app/routers/individual_tournaments.py
@@ -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)))
diff --git a/frontend/app/organizations/[id]/players/page.tsx b/frontend/app/organizations/[id]/players/page.tsx
index d8025d7..7bfe4d7 100644
--- a/frontend/app/organizations/[id]/players/page.tsx
+++ b/frontend/app/organizations/[id]/players/page.tsx
@@ -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
+ const { name, highlight } = await searchParams
+ return
}
diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx
index c3c9522..1ccccc9 100644
--- a/frontend/components/individual-tournament-detail.tsx
+++ b/frontend/components/individual-tournament-detail.tsx
@@ -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
onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise
+ onSetParticipantStatus: (
+ participantId: string,
+ status: "active" | "dsq" | "rtd" | "dnf" | "dns",
+ ) => Promise
onError: (message: string) => void
}) {
const [pool, setPool] = useState([])
@@ -1276,6 +1309,7 @@ function SetupTab({
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
onSetHandicap={onSetParticipantHandicap}
+ onSetStatus={onSetParticipantStatus}
/>
)
@@ -1668,6 +1702,17 @@ const STAT_LEVEL_LABELS: Record = {
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 = {
+ 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
onSetHandicap: (participantId: string, handicap: number | null) => Promise
+ onSetStatus: (participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns") => Promise
}) {
const [showImport, setShowImport] = useState(false)
@@ -1735,13 +1782,27 @@ function ParticipantsCard({
{participants.map((p) => (
-
+
{p.player_name} 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. */}
+
+
+ Rediger i spillerpoolen
+
{classes.length > 0 && (
+ {/* 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. */}
+