diff --git a/039_hole_score_selected_participant.sql b/039_hole_score_selected_participant.sql
new file mode 100644
index 0000000..77aa9f8
--- /dev/null
+++ b/039_hole_score_selected_participant.sql
@@ -0,0 +1,27 @@
+-- =====================================================================
+-- TeeCup — scramble/greensome i ORG-SCOPEDE turneringer: hvilken
+-- spillers utslag ble valgt (migrasjon 039). Samme funksjon som
+-- migrasjon 036 ga frittstående runder 2026-07-25 -- bevisst holdt
+-- utenfor DEN runden (se dens egen kommentar), tatt fatt på nå som
+-- rask, avgrenset oppfølger (FEATURE_BACKLOG.md "Scramble: statistikk
+-- over utslag brukt per spiller").
+--
+-- Kun meningsfullt på en DELT-BALL-hull-rad (hole_score.
+-- match_participant_id IS NULL) -- håndhevet i app-laget
+-- (submit_hole_score), ikke en CHECK, samme mønster som migrasjon 036.
+-- Nullable -- helt valgfritt å registrere.
+--
+-- Composite FK til match_participant (organization_id, id), samme
+-- mønster som hole_score.match_participant_id sin egen FK over --
+-- RLS håndheves via organization_id på hole_score selv, uendret.
+-- ON DELETE SET NULL (ikke CASCADE): fjernes en deltaker fra matchen
+-- (mulig FØR lås, se matches.py DELETE-endepunkt) skal ikke slette
+-- allerede registrerte hull-scorer, kun nullstille selve valget.
+-- =====================================================================
+\set ON_ERROR_STOP on
+
+ALTER TABLE hole_score ADD COLUMN selected_participant_id uuid;
+
+ALTER TABLE hole_score ADD CONSTRAINT hole_score_selected_participant_fk
+ FOREIGN KEY (organization_id, selected_participant_id)
+ REFERENCES match_participant(organization_id, id) ON DELETE SET NULL;
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 46846a1..d5bbb93 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -3614,18 +3614,20 @@ Disse må avklares før eller under de relevante fasene:
mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)*
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
-4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt
- UI-løsning spesifiseres senere. **Utvidet 2026-07-25, DELVIS AVKLART
- OG BYGGET 2026-07-28:** brukeren ba om statistikk over hvor mange
- utslag hver spiller har hatt (dvs. hvor mange ganger spillerens drive
- ble valgt). Bygget for FRITTSTÅENDE RUNDER (ADR-039 sitt delt-ball-
- format, `round_hole.selected_participant_id`, migrasjon 036) — se
- FEATURE_BACKLOG.md ("Scramble: statistikk over utslag brukt per
- spiller") for full detalj. **Fortsatt åpent:** samme funksjon for
- org-scopede turnering-scramble (`hole_score`/`match_hole_result`) —
- bevisst utenfor omfang i 2026-07-28-runden, samme underliggende
- problem som punkt 5 under. Sannsynligvis samme problemstilling for
- greensome i begge domener.
+4. **Scramble-grensesnitt — ✅ HELT FERDIG 2026-07-29.** Arkitekturen skal
+ ta høyde for formatet; eksakt UI-løsning spesifiseres senere.
+ **Utvidet 2026-07-25, DELVIS AVKLART OG BYGGET 2026-07-28:** brukeren
+ ba om statistikk over hvor mange utslag hver spiller har hatt (dvs.
+ hvor mange ganger spillerens drive ble valgt). Bygget for FRITTSTÅENDE
+ RUNDER (ADR-039 sitt delt-ball-format, `round_hole.selected_
+ participant_id`, migrasjon 036) — se FEATURE_BACKLOG.md ("Scramble:
+ statistikk over utslag brukt per spiller") for full detalj.
+ **Fullført 2026-07-29:** samme funksjon bygget for org-scopede
+ turnering-scramble/-greensome også (`hole_score.selected_
+ participant_id`, migrasjon 039) — samme mønster, se CLAUDE.md-status
+ 2026-07-29 for full detalj (validering, delt `submitStroke`-utvidelse,
+ ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
+ dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
på at f.eks. et foursome ikke får per-spiller-scorer.
diff --git a/CLAUDE.md b/CLAUDE.md
index 25e154f..f97fd29 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5439,6 +5439,79 @@ Ferdig og verifisert:
compose up -d --build teecup_api teecup_frontend`. Begge containere
boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
+- **Scramble/greensome: "utslag brukt"-statistikk NÅ OGSÅ for org-scopede
+ turneringer, BYGGET, GRUNDIG SCRATCH-/BROWSERVERIFISERT OG LIVE
+ (2026-07-29), samme dag:** direkte oppfølging av brukerens eget forslag
+ fra "hva nå?"-runden rett etter Stableford-arbeidet -- funksjonen ble
+ bevisst holdt utenfor migrasjon 036 (frittstående runder, 2026-07-25),
+ se dens egen kommentar; dette er den avgrensede oppfølgeren.
+ **Design, ny migrasjon `039_hole_score_selected_participant.sql`:**
+ `hole_score.selected_participant_id uuid` (nullable), composite FK
+ `(organization_id, selected_participant_id) REFERENCES match_
+ participant(organization_id, id) ON DELETE SET NULL` -- samme mønster
+ som `hole_score.match_participant_id` sin egen FK, men SET NULL (ikke
+ CASCADE): fjernes en deltaker fra matchen (mulig FØR lås) skal ikke
+ slette allerede registrerte hull-scorer, kun nullstille selve valget.
+ Kun meningsfullt på en DELT-BALL-hull-rad (`match_participant_id IS
+ NULL`) -- håndhevet i app-laget (`submit_hole_score`), ikke en CHECK,
+ samme mønster som migrasjon 036.
+ **Backend (`app/routers/scoring.py`):** `HoleScoreCreate`/`HoleScoreOut`
+ fikk `selected_participant_id`. Validering: avvist (400
+ `WRONG_PARTICIPANT_MODE`) for individuell ball (`match_participant_id`
+ satt) -- gir ingen mening der. For delt ball: validert å tilhøre
+ matchen OG samme `team_side` som selve scoren (400 `VALIDATION_FAILED`
+ ellers), speiler `round.py` sin identiske sjekk for frittstående runder.
+ Begge INSERT/UPSERT-setningene i `submit_hole_score` oppdatert (delt-
+ ball-grenen skriver/oppdaterer feltet, individuell-ball-grenen inkluderer
+ det aldri i det hele tatt -- alltid NULL der). `get_scorecard` sin
+ `stroke_entries`-SELECT utvidet -- feltet flyter automatisk gjennom
+ siden den allerede delte `HoleScoreOut`-modellen gjenbrukes.
+ **Frontend (`session-scorecard.tsx`):** ny "Hvem sitt utslag ble
+ brukt?"-knapperad (samme mønster som round-detail.tsx sin
+ `SideScoreWizard` fikk 2025-07-25) under `StrokePicker` for delt-ball-
+ enheter -- vises KUN når et slagtall allerede er registrert for hullet
+ (ulikt frittstående runders `round_hole`, som alltid forhåndsopprettes
+ med nullbar score, krever `hole_score`-raden faktisk å EKSISTERE først,
+ siden `gross_strokes` er `NOT NULL` i skjemaet -- en reell strukturell
+ forskjell fra migrasjon 036s mønster, løst ved å gjenbruke samme
+ `submitStroke`-funksjon med et nytt valgfritt fjerde argument som
+ enten beholder gjeldende valg (utelatt) eller setter et nytt (inkl.
+ eksplisitt `null` for å fjerne). Ny `SelectedDriverSummary`-komponent
+ (samme presentasjon som round-stats.tsx sin tilsvarende, tilpasset
+ denne sidens `teams`/`match.participants`/`scorecard.stroke_entries`-
+ datform) lagt inn i den eksisterende "Vis full oversikt"-seksjonen.
+ **Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
+ isolert scratch-MinIO + engangs API-container, alle 39 migrasjoner
+ kjørt friskt): en FULL org-turnering-scaffold bygget fra bunnen via API
+ for FØRSTE gang i en test for akkurat denne funksjonen (org/egendefinert
+ bane/18 hull/tee/turnering/to lag/fire spillere/roster/foursome-økt+
+ match/fire deltakere/lås) -- 51/51 sjekker: skriving uten valg, skriving
+ med valg (gross_strokes bevart), feil-side-valg avvist (400
+ VALIDATION_FAILED), individuell-ball+valg avvist (400
+ WRONG_PARTICIPANT_MODE), persistens bekreftet via `GET .../scorecard`,
+ korrekt opptelling (2 vs. 1 utslag), eksplisitt `null` fjerner valget.
+ `test_isolation.sql` 12/12 uendret (additiv migrasjon). Ekte typesjekket
+ produksjonsbuild kjørt og bekreftet.
+ **Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
+ isolert scratch-backend, inkl. et ekte 2FA-oppsett for en fersk
+ organisasjonseier siden ADR-021 krever det): naviger til foursome-
+ matchens scorekort, bekreftet at allerede-registrerte valg (satt via
+ API) vises korrekt som trykte knapper for RIKTIG spiller på RIKTIG
+ hull -- OG et ekte klikk i UI-et som byttet valgt spiller for et hull,
+ bekreftet persistert direkte i databasen etterpå (ikke bare at UI-et
+ så riktig ut). "Vis full oversikt" sin nye "Utslag brukt"-seksjon
+ bekreftet med nøyaktig riktig opptelling for begge lag (2/1 og 1/0,
+ inkl. "Registrert på N av M spilte hull"-teksten). Ingen konsollfeil.
+ **Rullet ut live 2026-07-29**, bruker bekreftet eksplisitt (plan vist
+ FØR migrasjonen): migrasjon 039 kjørt mot ekte `teecup_db` (kolonne +
+ FK-constraint bekreftet, `test_isolation.sql` fortsatt 12/12), deretter
+ `docker compose up -d --build teecup_api teecup_frontend`. Begge
+ containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
+ upåvirket. **"Utslag brukt"-statistikken finnes dermed nå for BEGGE
+ domener** (frittstående runder siden 2026-07-25, org-scopede
+ turneringer siden i dag) -- se ARCHITECTURE_DECISIONS.md sitt åpne
+ spørsmål 4 (Scramble-grensesnitt), som dermed er helt avsluttet.
+
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index 2572324..815c704 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -1508,7 +1508,7 @@ blant minst to fullførte runder). Full detalj i CLAUDE.md sin statuslogg
---
-## Scramble: statistikk over utslag brukt per spiller — ✅ BYGGET OG LIVE 2026-07-28 (frittstående runder)
+## Scramble: statistikk over utslag brukt per spiller — ✅ HELT FERDIG (frittstående runder 2026-07-28, org-scopede turneringer 2026-07-29)
Brukeren ba om at det i scramble-turneringer skal føres statistikk over
hvor mange utslag hver spiller har hatt (dvs. hvor mange ganger den
@@ -1567,15 +1567,24 @@ punktene denne dagen): PATCH med valgt spiller lykkes, feil side avvist
(`selected_participant_id` pekte riktig), OG bekreftet "Utslag brukt"-
oppsummeringen viste "Gjest Partner: 1 utslag" korrekt på statistikksiden,
ingen konsollfeil.
-**Bevisst utenfor omfang:** org-scopede turnering-scramble/greensome
-(`hole_score`/`match_hole_result`, delt mellom lag i en Ryder Cup-
-tourney) har samme strukturelle hull, men ble IKKE bygget i denne
-runden -- appens hovedfokus er frittstående runder (ADR-033/035/036), og
-å bygge samme funksjon i to atskilte datamodeller i samme økt som tre
-andre store leveranser ble vurdert som unødvendig risiko. Notert her,
-ikke glemt, hvis/når turnering-scramble tas fatt på som egen sak.
-**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
-utrullingsdetalj med de tre andre punktene samme dag.
+**Org-scopede turneringer — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE
+2026-07-29,** rask, avgrenset oppfølger rett etter Stableford-runden
+samme uke: `hole_score.selected_participant_id` (migrasjon 039), samme
+composite-FK-mønster som `match_participant_id` sin egen FK men `ON
+DELETE SET NULL` (fjernes en deltaker skal ikke slette allerede
+registrerte scorer). Validering i `submit_hole_score` (app/routers/
+scoring.py): avvist for individuell ball, validert til samme `team_side`
+som scoren for delt ball. Ny "Hvem sitt utslag ble brukt?"-knapperad i
+`session-scorecard.tsx` (vises kun når et slagtall allerede er
+registrert, siden `hole_score.gross_strokes` er `NOT NULL` -- en reell
+strukturell forskjell fra `round_hole`, som alltid forhåndsoppretter en
+nullbar rad). Ny `SelectedDriverSummary` i "Vis full oversikt"-
+seksjonen. 51/51 scratch-sjekker (full org-turnering-scaffold bygget fra
+bunnen) + full nettleser-gjennomgang (ekte klikk, persistens bekreftet
+direkte i databasen, riktig opptelling). Se CLAUDE.md-status 2026-07-29
+for full detalj. **Begge domener (frittstående runder og org-scopede
+turneringer) dekker nå samme funksjon** -- ingen kjent gjenstående
+forskjell mellom scramble og greensome i noen av domenene.
---
diff --git a/app/routers/scoring.py b/app/routers/scoring.py
index 10a304f..0e224b5 100644
--- a/app/routers/scoring.py
+++ b/app/routers/scoring.py
@@ -341,6 +341,10 @@ class HoleScoreCreate(BaseModel):
match_participant_id: str | None = None
hole_number: int = Field(ge=1, le=18)
gross_strokes: int = Field(ge=1, le=20)
+ # Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme
+ # funksjon som frittstående runder fikk 2026-07-25). KUN meningsfullt for
+ # delt-ball-hull (match_participant_id er None) -- helt valgfritt.
+ selected_participant_id: str | None = None
class HoleScoreOut(BaseModel):
@@ -348,6 +352,7 @@ class HoleScoreOut(BaseModel):
team_side: str
match_participant_id: str | None
gross_strokes: int
+ selected_participant_id: str | None = None
@router.post(
@@ -416,6 +421,29 @@ async def submit_hole_score(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
)
+ # Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039) --
+ # gir kun mening for delt-ball-hull, og må tilhøre samme side som
+ # selve scoren registreres for (samme sjekk som round.py sin
+ # tilsvarende validering for frittstående runder).
+ if body.selected_participant_id is not None:
+ if body.match_participant_id is not None:
+ raise app_error(
+ 400,
+ "WRONG_PARTICIPANT_MODE",
+ "selected_participant_id gjelder kun delt-ball-hull, ikke individuell ball.",
+ )
+ selected = await conn.fetchrow(
+ "SELECT team_side::text AS team_side FROM match_participant WHERE id = $1 AND match_id = $2",
+ body.selected_participant_id,
+ match_id,
+ )
+ if selected is None:
+ raise app_error(404, "NOT_FOUND", "Den valgte spilleren finnes ikke i denne matchen.")
+ if selected["team_side"] != body.team_side:
+ raise app_error(
+ 400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
+ )
+
if not await user_is_match_participant(
conn, organization_id, match_id, user.user_id, team_side=body.team_side
):
@@ -434,7 +462,8 @@ async def submit_hole_score(
ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
RETURNING hole_number, team_side::text AS team_side,
- match_participant_id::text AS match_participant_id, gross_strokes
+ match_participant_id::text AS match_participant_id, gross_strokes,
+ selected_participant_id::text AS selected_participant_id
""",
organization_id,
match_id,
@@ -447,18 +476,23 @@ async def submit_hole_score(
row = await conn.fetchrow(
"""
INSERT INTO hole_score
- (organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
- VALUES ($1, $2, $3, NULL, $4, $5)
+ (organization_id, match_id, team_side, match_participant_id, hole_number,
+ gross_strokes, selected_participant_id)
+ VALUES ($1, $2, $3, NULL, $4, $5, $6)
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
- DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
+ DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
+ selected_participant_id = EXCLUDED.selected_participant_id,
+ updated_at = now()
RETURNING hole_number, team_side::text AS team_side,
- match_participant_id::text AS match_participant_id, gross_strokes
+ match_participant_id::text AS match_participant_id, gross_strokes,
+ selected_participant_id::text AS selected_participant_id
""",
organization_id,
match_id,
body.team_side,
body.hole_number,
body.gross_strokes,
+ body.selected_participant_id,
)
await recompute_and_cache_match_state(conn, match_id)
@@ -588,7 +622,8 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
rows = await conn.fetch(
"""
SELECT hole_number, team_side::text AS team_side,
- match_participant_id::text AS match_participant_id, gross_strokes
+ match_participant_id::text AS match_participant_id, gross_strokes,
+ selected_participant_id::text AS selected_participant_id
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx
index 6c9a34d..80e2b19 100644
--- a/frontend/components/session-scorecard.tsx
+++ b/frontend/components/session-scorecard.tsx
@@ -70,6 +70,10 @@ type ApiHoleScore = {
team_side: "a" | "b"
match_participant_id: string | null
gross_strokes: number
+ // Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme
+ // funksjon som frittstående runder fikk 2026-07-25). Kun satt for
+ // delt-ball-rader (match_participant_id null).
+ selected_participant_id: string | null
}
type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null }
@@ -205,13 +209,19 @@ export function SessionScorecard({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchId])
- async function queueStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
+ async function queueStroke(
+ holeNumber: number,
+ unit: Unit,
+ grossStrokes: number,
+ selectedParticipantId: string | null,
+ ) {
const url = `/orgs/${organizationId}/matches/${matchId}/hole-scores`
const body = {
team_side: unit.side,
match_participant_id: unit.matchParticipantId,
hole_number: holeNumber,
gross_strokes: grossStrokes,
+ selected_participant_id: selectedParticipantId,
}
await enqueueWrite({ url, method: "POST", body, matchId })
setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes))
@@ -320,10 +330,30 @@ export function SessionScorecard({
const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null
- async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
+ // Scramble/greensome: hvem sitt utslag som ble valgt for et allerede
+ // registrert hull (migrasjon 039) -- lest fra scorekortet, ikke lagret
+ // lokalt separat.
+ function selectedDriverFor(holeNumber: number, unit: Unit): string | null {
+ const entry = (scorecard?.stroke_entries ?? []).find(
+ (e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId,
+ )
+ return entry?.selected_participant_id ?? null
+ }
+
+ // `selectedParticipantId` utelatt = behold gjeldende valg uendret (brukt
+ // av selve slagregistreringen); eksplisitt sendt (inkl. `null`) = et nytt
+ // utslags-valg (brukt av "hvem sitt utslag ble brukt"-knappene under).
+ async function submitStroke(
+ holeNumber: number,
+ unit: Unit,
+ grossStrokes: number,
+ selectedParticipantId?: string | null,
+ ) {
+ const resolvedSelection =
+ selectedParticipantId !== undefined ? selectedParticipantId : selectedDriverFor(holeNumber, unit)
setError(null)
if (!navigator.onLine) {
- await queueStroke(holeNumber, unit, grossStrokes)
+ await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection)
return
}
let res: Response
@@ -337,12 +367,13 @@ export function SessionScorecard({
match_participant_id: unit.matchParticipantId,
hole_number: holeNumber,
gross_strokes: grossStrokes,
+ selected_participant_id: resolvedSelection,
}),
})
} catch {
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
// for å vise en feil, se ADR-028.
- await queueStroke(holeNumber, unit, grossStrokes)
+ await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection)
return
}
if (!res.ok) {
@@ -646,6 +677,13 @@ export function SessionScorecard({
scoringMode={session.scoring_mode}
holeConfig={session.hole_config}
/>
+ {!isIndividual && session.scoring_mode === "stroke" && match && (
+