"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)*
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.

View file

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

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
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.
---

View file

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

View file

@ -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 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({
teams,
units,