"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:
parent
4b9d93941e
commit
64bcc7ad2e
6 changed files with 321 additions and 32 deletions
27
039_hole_score_selected_participant.sql
Normal file
27
039_hole_score_selected_participant.sql
Normal 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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
73
CLAUDE.md
73
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<SelectedDriverSummary
|
||||
teams={teams}
|
||||
participants={match.participants}
|
||||
strokeEntries={scorecard.stroke_entries ?? []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -749,6 +787,43 @@ export function SessionScorecard({
|
|||
isPending={isPending}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
|
@ -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 på 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 på {registeredCount} av {playedCount} spilte hull.
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentMatchGrid({
|
||||
teams,
|
||||
units,
|
||||
|
|
|
|||
Loading…
Reference in a new issue