"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)*
|
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.
|
||||||
|
|
|
||||||
73
CLAUDE.md
73
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 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({
|
function TournamentMatchGrid({
|
||||||
teams,
|
teams,
|
||||||
units,
|
units,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue