"Utslag brukt"-statistikk for scramble/greensome finnes nå også for org-scopede turneringer — samme funksjon som frittstående runder fikk 2026-07-25, bygget som en rask, avgrenset oppfølger.

Ny kolonne hole_score.selected_participant_id (migrasjon 039), validert til å tilhøre riktig side og kun gyldig for delt-ball-hull (ikke individuell ball).
Ny "Hvem sitt utslag ble brukt?"-knapperad i scorekortet for foursome/greensome/scramble-matcher.
Ny "Utslag brukt"-oppsummering i "Vis full oversikt", med opptelling per spiller og "registrert på N av M spilte hull".
Verifisert med en full org-turnering bygget fra bunnen (org/bane/lag/spillere/foursome-match), 51/51 API-sjekker, og en ekte nettleser-gjennomgang der jeg klikket et faktisk valg og bekreftet det landet riktig i databasen. Migrasjon kjørt, begge containere redeployet, teeoff.no upåvirket.

Dermed er "Scramble-grensesnitt" (ARCHITECTURE_DECISIONS.md sitt åpne spørsmål 4) helt avsluttet — begge domener dekket.
This commit is contained in:
Erol Haagenrud 2026-07-29 22:20:50 +02:00
parent 4b9d93941e
commit 64bcc7ad2e
6 changed files with 321 additions and 32 deletions

View file

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

View file

@ -3614,18 +3614,20 @@ Disse må avklares før eller under de relevante fasene:
mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)* mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)*
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke 3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell. isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt 4. **Scramble-grensesnitt — ✅ HELT FERDIG 2026-07-29.** Arkitekturen skal
UI-løsning spesifiseres senere. **Utvidet 2026-07-25, DELVIS AVKLART ta høyde for formatet; eksakt UI-løsning spesifiseres senere.
OG BYGGET 2026-07-28:** brukeren ba om statistikk over hvor mange **Utvidet 2026-07-25, DELVIS AVKLART OG BYGGET 2026-07-28:** brukeren
utslag hver spiller har hatt (dvs. hvor mange ganger spillerens drive ba om statistikk over hvor mange utslag hver spiller har hatt (dvs.
ble valgt). Bygget for FRITTSTÅENDE RUNDER (ADR-039 sitt delt-ball- hvor mange ganger spillerens drive ble valgt). Bygget for FRITTSTÅENDE
format, `round_hole.selected_participant_id`, migrasjon 036) — se RUNDER (ADR-039 sitt delt-ball-format, `round_hole.selected_
FEATURE_BACKLOG.md ("Scramble: statistikk over utslag brukt per participant_id`, migrasjon 036) — se FEATURE_BACKLOG.md ("Scramble:
spiller") for full detalj. **Fortsatt åpent:** samme funksjon for statistikk over utslag brukt per spiller") for full detalj.
org-scopede turnering-scramble (`hole_score`/`match_hole_result`) — **Fullført 2026-07-29:** samme funksjon bygget for org-scopede
bevisst utenfor omfang i 2026-07-28-runden, samme underliggende turnering-scramble/-greensome også (`hole_score.selected_
problem som punkt 5 under. Sannsynligvis samme problemstilling for participant_id`, migrasjon 039) — samme mønster, se CLAUDE.md-status
greensome i begge domener. 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 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 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. på at f.eks. et foursome ikke får per-spiller-scorer.

View file

@ -5439,6 +5439,79 @@ Ferdig og verifisert:
compose up -d --build teecup_api teecup_frontend`. Begge containere compose up -d --build teecup_api teecup_frontend`. Begge containere
boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. 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: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen

View file

@ -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 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 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"- (`selected_participant_id` pekte riktig), OG bekreftet "Utslag brukt"-
oppsummeringen viste "Gjest Partner: 1 utslag" korrekt på statistikksiden, oppsummeringen viste "Gjest Partner: 1 utslag" korrekt på statistikksiden,
ingen konsollfeil. ingen konsollfeil.
**Bevisst utenfor omfang:** org-scopede turnering-scramble/greensome **Org-scopede turneringer — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE
(`hole_score`/`match_hole_result`, delt mellom lag i en Ryder Cup- 2026-07-29,** rask, avgrenset oppfølger rett etter Stableford-runden
tourney) har samme strukturelle hull, men ble IKKE bygget i denne samme uke: `hole_score.selected_participant_id` (migrasjon 039), samme
runden -- appens hovedfokus er frittstående runder (ADR-033/035/036), og composite-FK-mønster som `match_participant_id` sin egen FK men `ON
å bygge samme funksjon i to atskilte datamodeller i samme økt som tre DELETE SET NULL` (fjernes en deltaker skal ikke slette allerede
andre store leveranser ble vurdert som unødvendig risiko. Notert her, registrerte scorer). Validering i `submit_hole_score` (app/routers/
ikke glemt, hvis/når turnering-scramble tas fatt på som egen sak. scoring.py): avvist for individuell ball, validert til samme `team_side`
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles som scoren for delt ball. Ny "Hvem sitt utslag ble brukt?"-knapperad i
utrullingsdetalj med de tre andre punktene samme dag. `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.
--- ---

View file

@ -341,6 +341,10 @@ class HoleScoreCreate(BaseModel):
match_participant_id: str | None = None match_participant_id: str | None = None
hole_number: int = Field(ge=1, le=18) hole_number: int = Field(ge=1, le=18)
gross_strokes: int = Field(ge=1, le=20) 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): class HoleScoreOut(BaseModel):
@ -348,6 +352,7 @@ class HoleScoreOut(BaseModel):
team_side: str team_side: str
match_participant_id: str | None match_participant_id: str | None
gross_strokes: int gross_strokes: int
selected_participant_id: str | None = None
@router.post( @router.post(
@ -416,6 +421,29 @@ async def submit_hole_score(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side." 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( if not await user_is_match_participant(
conn, organization_id, match_id, user.user_id, team_side=body.team_side 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 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() DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
RETURNING hole_number, team_side::text AS team_side, 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, organization_id,
match_id, match_id,
@ -447,18 +476,23 @@ async def submit_hole_score(
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
INSERT INTO hole_score INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes) (organization_id, match_id, team_side, match_participant_id, hole_number,
VALUES ($1, $2, $3, NULL, $4, $5) 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 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, 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, organization_id,
match_id, match_id,
body.team_side, body.team_side,
body.hole_number, body.hole_number,
body.gross_strokes, body.gross_strokes,
body.selected_participant_id,
) )
await recompute_and_cache_match_state(conn, match_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( rows = await conn.fetch(
""" """
SELECT hole_number, team_side::text AS team_side, 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 FROM hole_score WHERE match_id = $1 ORDER BY hole_number
""", """,
match_id, match_id,

View file

@ -70,6 +70,10 @@ type ApiHoleScore = {
team_side: "a" | "b" team_side: "a" | "b"
match_participant_id: string | null match_participant_id: string | null
gross_strokes: number 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 } 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchId]) }, [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 url = `/orgs/${organizationId}/matches/${matchId}/hole-scores`
const body = { const body = {
team_side: unit.side, team_side: unit.side,
match_participant_id: unit.matchParticipantId, match_participant_id: unit.matchParticipantId,
hole_number: holeNumber, hole_number: holeNumber,
gross_strokes: grossStrokes, gross_strokes: grossStrokes,
selected_participant_id: selectedParticipantId,
} }
await enqueueWrite({ url, method: "POST", body, matchId }) await enqueueWrite({ url, method: "POST", body, matchId })
setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes)) 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 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) setError(null)
if (!navigator.onLine) { if (!navigator.onLine) {
await queueStroke(holeNumber, unit, grossStrokes) await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection)
return return
} }
let res: Response let res: Response
@ -337,12 +367,13 @@ export function SessionScorecard({
match_participant_id: unit.matchParticipantId, match_participant_id: unit.matchParticipantId,
hole_number: holeNumber, hole_number: holeNumber,
gross_strokes: grossStrokes, gross_strokes: grossStrokes,
selected_participant_id: resolvedSelection,
}), }),
}) })
} catch { } catch {
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
// for å vise en feil, se ADR-028. // for å vise en feil, se ADR-028.
await queueStroke(holeNumber, unit, grossStrokes) await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection)
return return
} }
if (!res.ok) { if (!res.ok) {
@ -646,6 +677,13 @@ export function SessionScorecard({
scoringMode={session.scoring_mode} scoringMode={session.scoring_mode}
holeConfig={session.hole_config} holeConfig={session.hole_config}
/> />
{!isIndividual && session.scoring_mode === "stroke" && match && (
<SelectedDriverSummary
teams={teams}
participants={match.participants}
strokeEntries={scorecard.stroke_entries ?? []}
/>
)}
</div> </div>
)} )}
</div> </div>
@ -749,6 +787,43 @@ export function SessionScorecard({
isPending={isPending} isPending={isPending}
onSelect={(n) => void submitStroke(currentHole, unit, n)} onSelect={(n) => void submitStroke(currentHole, unit, n)}
/> />
{/* Scramble/greensome: hvem sitt utslag ble valgt
(migrasjon 039, samme funksjon som frittstående
runder fikk 2026-07-25). Krever et registrert
slagtall å knytte valget til -- hole_score-raden
finnes ikke før da (ulikt round_hole, som alltid
forhåndsopprettes). Helt valgfritt, egen handling. */}
{!isIndividual && value !== null && match && (
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Hvem sitt utslag ble brukt?{" "}
<span className="font-normal normal-case">(valgfritt)</span>
</legend>
<div className="flex flex-wrap gap-2">
{match.participants
.filter((p) => p.team_side === unit.side)
.map((p) => {
const active = selectedDriverFor(currentHole, unit) === p.id
return (
<button
key={p.id}
type="button"
onClick={() => void submitStroke(currentHole, unit, value, active ? null : p.id)}
aria-pressed={active}
className={cn(
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{p.player_name}
</button>
)
})}
</div>
</fieldset>
)}
</div> </div>
) )
})} })}
@ -1027,6 +1102,74 @@ function IdentityBlock({
) )
} }
// Scramble/greensome: "utslag brukt" per spiller (migrasjon 039, samme
// funksjon som frittstående runder fikk 2026-07-25, se round-stats.tsx sin
// tilsvarende SelectedDriverSummary). Ren klientside-telling fra allerede
// hentet scorecard-data -- vises kun de hullene som faktisk har et
// registrert valg, ingen antakelse om at ALLE hull må ha ett.
function SelectedDriverSummary({
teams,
participants,
strokeEntries,
}: {
teams: [ApiTeam, ApiTeam]
participants: ApiParticipant[]
strokeEntries: ApiHoleScore[]
}) {
const rows = (["a", "b"] as const).map((side, idx) => {
const sideEntries = strokeEntries.filter((e) => e.team_side === side && e.match_participant_id === null)
const withSelection = sideEntries.filter((e) => e.selected_participant_id !== null)
const counts = new Map<string, number>()
for (const e of withSelection) {
const pid = e.selected_participant_id as string
counts.set(pid, (counts.get(pid) ?? 0) + 1)
}
const sideParticipants = participants.filter((p) => p.team_side === side)
return {
team: teams[idx],
playedCount: sideEntries.length,
registeredCount: withSelection.length,
entries: sideParticipants
.map((p) => ({ name: p.player_name, count: counts.get(p.id) ?? 0 }))
.sort((a, b) => b.count - a.count),
}
})
if (rows.every((r) => r.registeredCount === 0)) {
return (
<div className="border-t border-border p-4 text-sm text-muted-foreground">
Ingen har registrert hvem sitt utslag som ble brukt noe hull ennå -- valgfritt felt i
scoreregistreringen.
</div>
)
}
return (
<div className="flex flex-col gap-4 border-t border-border p-4">
<span className="text-sm font-bold text-foreground">Utslag brukt</span>
{rows.map(({ team, playedCount, registeredCount, entries }) => (
<div key={team.id} className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
{team.name}
</span>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{entries.map((e) => (
<li key={e.name} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{e.name}</span>
<span className="text-base font-extrabold tabular-nums text-foreground">{e.count} utslag</span>
</li>
))}
</ul>
<span className="text-xs text-muted-foreground">
Registrert {registeredCount} av {playedCount} spilte hull.
</span>
</div>
))}
</div>
)
}
function TournamentMatchGrid({ function TournamentMatchGrid({
teams, teams,
units, units,