Kjedet flerspiller-scoring i ScoreTab (ADR-100) -- steg 2 av parity-planen

Hull-først kortliste + kjedet flerspiller-veiviser (V0-skall, ekte
skjemakomponenter fra HoleStatsSheet/TournamentScoringWizard). Ingen
backend-endring. Tre reelle bugs funnet og rettet i scratch: standardhull-
valg racet mot ufullstendig data, auto-hopp uteble for spiller 2+ pga.
stale state ved spillerbytte, DSQ'et spiller blokkerte "hull ferdig"
permanent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-21 23:00:22 +02:00
parent 71c1b81e3b
commit 707df01e2a
3 changed files with 580 additions and 136 deletions

View file

@ -10068,6 +10068,87 @@ IKKE bygget/rullet ut mot ekte containere ennå.
---
## ADR-100: Kjedet flerspiller-scoring i ScoreTab (2026-08-22)
**Kontekst:** steg 2 av den samme 8-punkts parity-rekkefølgen som
ADR-099 (steg 1, flight-scoping) startet. Bruker godkjente en V0-prompt
(`Temp-uploads/v0-prompt-tournament-scoring-wizard.md`) for en hull-
først kortliste + kjedet flerspiller-veiviser, samme mønster
frittstående runder alt har (`PlayerHoleCards`/`ScoringWizard`,
`round-detail.tsx`). Eksporten (`Temp-uploads/tee-cup-login-screen.zip`
-- filnavnet stemte ikke med innholdet, verifisert nøye før bruk, jf.
tidligere feilopplastings-hendelse denne uken) ble lest i sin helhet
(830 linjer) og hadde høy troskap mot prompten.
**Beslutning A -- V0s EGNE skjemakontroller ble IKKE tatt i bruk, kun
dens NYE lag (hull-navigator/kortliste/kontekst-rad/kjede-footer).**
`HoleStatsSheet` (nå omdøpt `TournamentScoringWizard`) hadde ALLEREDE
ekte, mer fullverdige steg-komponenter (golf-fagbegreper, netto-par-
markering via `strokesReceived`, `ownBagClubs`, rangefinder, hull-
historikk-oversikt) enn V0s forenklede stand-ins -- disse gjenbrukes
UENDRET, kun utvidet med kjeding. `sheetStepsFor` fikk et nytt
`strokes_only`-tilfelle (ett steg, ingen "overview") slik at ALLE tre
statLevel-nivåer nå går via wizard -- `HoleGrid`s tidligere bare
inline-tallfelt for strokes_only er fjernet (død kode).
**Beslutning B -- kjede-logikken eies av `ScoreTab` (forelderen), ikke
av wizard-komponenten selv.** Speiler `advanceWizardPlayer()`-mønsteret
i `round-detail.tsx` nøyaktig: wizardkomponenten er ren presentasjon
(kontekst-rad + `onAdvance`/`onSwitchPlayer`-callbacks), `ScoreTab`
beregner `chain` (flight-scoped, ekskluderer DSQ/RTD/DNF/DNS via
`tournament_participant.status`), hvem som er neste, og hull-wrap ved
"Ferdig". Ingen ny autorisasjon -- samme self-only/org-admin-regel som
ADR-099 Beslutning A, uendret.
**Beslutning C -- ingen backend-endring i det hele tatt.** Gjenbruker
`GET .../rounds/{roundId}/participants/{id}/holes` parallelt per
flight-medlem (samme N-parallelle mønster som `round-detail.tsx` sin
`holesByParticipant`/`loadHoles`) og eksisterende
`PATCH .../holes/{holeNumber}` uendret.
**Beslutning D -- tre reelle bugs funnet i scratch, alle rettet FØR
utrulling (ingen av dem synlige ved kun `tsc`/typesjekk):**
1. Standardhull-valget (`activeHole`) kunne låse seg til hull 1 --
effekten kjørte FØR alle flight-medlemmers hull-data faktisk hadde
kommet inn, konkluderte feilaktig "hull 1 er første åpne" basert på
ufullstendige data, og ventet aldri på et bedre svar (guard-en
`if (activeHole !== null) return` hindret ny beregning). Rettet med
en eksplisitt "er ALT lastet ennå"-sjekk før standardvalget settes.
2. Auto-hopp mellom steg uteble for spiller nummer to (og senere) i
kjeden -- fordi wizard-instansen nå holdes I LIVE på tvers av
spillerbytter (ikke lenger remount per åpning som før), leste
vakt-nullstillingen (`enteredWithValueRef`) fortsatt FORRIGE spillers
`strokes`-verdi (React batcher `setStrokes(...)` fra reset-effekten,
den nye verdien er ikke synlig før neste render). Rettet ved å lese
fra `hole`-PROPEN (alltid fersk) i stedet for det lokale utkastet.
3. En diskvalifisert spiller blokkerte "hull ferdig"-sjekken (og dermed
standardhull-beregningen) PERMANENT, siden den daværende sjekken
krevde registrering fra HELE flighten (`flightParticipants`), ikke
bare de som faktisk kan scores (`chain`). En DSQ'et spiller får
ALDRI registrert ny score (self-only-regelen), så "alle ferdig"
ville aldri blitt sant for noe hull. Rettet: fullførthet-sjekkene
(hurtig-hopp-stripen OG standardhull-beregningen) bruker nå `chain`
-- kortlisten viser fortsatt ALLE flight-medlemmer, inkl. DSQ'et.
**Status:** 164/164 pytest (uendret -- ingen backend rørt), `tsc
--noEmit` rent. Full-stack scratch-verifisering (egen scratch-db, egen
API-container, egen MinIO, ekte innlogging, ingen fetch-mocking --
samme oppskrift som ADR-099) med en flight på 4 (blandet `stat_level`:
full/strokes_and_putts/strokes_only, én DSQ'et) bekreftet, ETTER at de
tre bugene over var rettet: standardhull-valg riktig, kjeding gjennom
alle tre gjenværende spillere riktig (inkl. korrekt "Neste: {navn}"-
tekst per steg-antall), DSQ'et spiller korrekt hoppet over i kjeden
men fortsatt synlig som kort, hull-fremgang etter "Ferdig" riktig,
"Vis hele scorekortet" (sekundær visning fra ADR-099) uendret og
fungerende, mørk modus OK. Selvregistrering (403 for ikke-egne
deltakere) bekreftet fungerende -- måtte midlertidig gjøre scratch-
brukeren til org-eier for å teste HELE kjeden (samme
selv-only-begrensning som ADR-099 Beslutning A, ikke en bug). Alle
scratch-ressurser ryddet opp etter verifisering. IKKE bygget/rullet ut
mot ekte containere ennå.
---
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14307,3 +14307,34 @@ Neste steg:
tomt før/etter, 1 rad uendret), deretter `docker compose build
teecup_api teecup_frontend && up -d`. Begge containere friske, rene
logger. `https://teecup.golf/health` 200 etter utrulling.
157. **Kjedet flerspiller-scoring i ScoreTab (ADR-100), 2026-08-22 --
steg 2 av parity-planen, direkte fortsettelse av #156.** V0-
eksporten (`Temp-uploads/tee-cup-login-screen.zip` -- filnavnet
stemte ikke med innholdet, sjekket nøye før bruk) kom tilbake, høy
troskap mot prompten. Integrert ved å beholde V0s NYE lag (hull-
navigator + kortliste + kontekst-rad + kjede-footer) men fylle
steg-INNHOLDET med de allerede eksisterende, mer fullverdige
komponentene fra `HoleStatsSheet` (omdøpt `TournamentScoringWizard`)
i stedet for V0s egne forenklede skjemakontroller -- samme prinsipp
som print-funksjonen og "Følg live" tidligere denne uken.
**Ingen backend-endring.** Gjenbruker eksisterende endepunkter
uendret (parallell henting per flight-medlem + samme PATCH som før).
**Tre reelle bugs funnet og rettet i scratch før utrulling** (se
ADR-100 Beslutning D for full detalj): (1) standardhull-valget
kunne låse seg til hull 1 pga. en race mot ufullstendig lastet data,
(2) auto-hopp mellom steg uteble for spiller 2+ i kjeden pga. en
stale-closure-lesning av forrige spillers utkast, (3) en
diskvalifisert spiller blokkerte "hull ferdig"-sjekken permanent
siden den daværende sjekken krevde registrering fra HELE flighten
i stedet for kun den faktisk score-bare kjeden.
**Verifisert:** 164/164 pytest (uendret), `tsc --noEmit` rent.
Full-stack scratch (egen db/API-container/MinIO, ekte innlogging,
ingen fetch-mocking) med en flight på 4 (blandet stat_level, én
DSQ) -- kjeding gjennom alle steg og alle spillere bekreftet riktig
ETTER de tre rettelsene, DSQ hoppet korrekt over i kjeden men
fortsatt synlig som kort, "Vis hele scorekortet" (steg 1) uendret,
mørk modus OK. Alle scratch-ressurser ryddet opp.

View file

@ -19,10 +19,12 @@ import { useRouter } from "next/navigation"
import type { FlagMapEntry } from "@/components/flag-map-overview"
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
ExternalLink,
Clock,
Copy,
@ -1093,7 +1095,13 @@ export function IndividualTournamentDetail({
/>
)}
{tab === "score" && (
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
<ScoreTab
base={base}
rounds={rounds}
scoringMethod={tournament.scoring_method}
participants={participants}
onError={setError}
/>
)}
{tab === "leaderboard" && (
<LeaderboardTab base={base} tournament={tournament} />
@ -2952,26 +2960,27 @@ function ScoreTab({
base,
rounds,
scoringMethod,
participants,
onError,
}: {
base: string
rounds: ApiRound[]
scoringMethod: string | null
participants: ApiParticipant[]
onError: (message: string) => void
}) {
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
const [participantId, setParticipantId] = useState("")
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const round = rounds.find((r) => r.id === roundId) ?? null
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
// fetch) vet de skal hente på nytt.
const [refreshTick, setRefreshTick] = useState(0)
// Flight-scoping (ADR-099) -- standard er å vise KUN egen
// utslagsgruppe i velgeren under, med en lenke for å se hele feltet.
// roundParticipants selv holdes ALLTID ufiltrert (BBB/Flag-panelene
// under bruker den direkte, uavhengig av dette).
// utslagsgruppe, med en lenke for å se hele feltet. roundParticipants
// selv holdes ALLTID ufiltrert (BBB/Flag-panelene under bruker den
// direkte, uavhengig av dette).
const [showFullField, setShowFullField] = useState(false)
const myParticipant = roundParticipants.find((rp) => rp.is_self) ?? null
const myGroupId = myParticipant?.tournament_round_group_id ?? null
@ -2979,6 +2988,13 @@ function ScoreTab({
showFullField || !myGroupId
? roundParticipants
: roundParticipants.filter((rp) => rp.tournament_round_group_id === myGroupId)
// Flighten den nye hull-først-visningen under viser -- ALLTID flight-
// scoped (fallback til hele feltet uten gruppe, samme regel som over),
// UAVHENGIG av "Vis hele feltet"-bryteren (den styrer kun det gamle,
// nå sekundære spiller-først-scorekortet lenger ned).
const flightParticipants = myGroupId
? roundParticipants.filter((rp) => rp.tournament_round_group_id === myGroupId)
: roundParticipants
useEffect(() => {
if (!roundId) return
@ -2998,43 +3014,43 @@ function ScoreTab({
}
}, [base, roundId])
// Hull-for-hull-data PER DELTAKER, ETT felles kart (ADR-099, steg 2) --
// dekker BÅDE den nye hull-først-flighten under OG den (nå sekundære)
// spiller-valgte scorekort-visningen (`holes` er en avledet variabel
// under, ikke lenger egen state). Henter kun det som faktisk trengs
// (flighten + evt. den enkeltvalgte spilleren om den ligger utenfor
// flighten, f.eks. via "Vis hele feltet") -- samme N-parallelle
// "hent det som mangler"-mønster som round-detail.tsx sin
// holesByParticipant/loadHoles.
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
useEffect(() => {
if (!roundId || !participantId) {
setHoles(null)
return
}
if (!roundId) return
let cancelled = false
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => {
if (!cancelled) setHoles(data ?? [])
})
const ids = new Set(flightParticipants.map((rp) => rp.id))
if (participantId) ids.add(participantId)
for (const id of ids) {
if (holesByParticipant[id]) continue
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${id}/holes`).then((data) => {
if (cancelled) return
setHolesByParticipant((prev) => ({ ...prev, [id]: data ?? [] }))
})
}
return () => {
cancelled = true
}
}, [base, roundId, participantId])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [base, roundId, roundParticipants, participantId, holesByParticipant])
async function updateHole(holeNumber: number, grossStrokes: number) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ gross_strokes: grossStrokes }),
})
if (!res.ok) {
onError("Klarte ikke å lagre slaget. Prøv igjen.")
return
}
const updated: ApiHole = await res.json()
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
setRefreshTick((n) => n + 1)
}
const holes = participantId ? (holesByParticipant[participantId] ?? null) : null
// Full statistikkdybde (migrasjon 075, ADR-071) -- samme endepunkt som
// updateHole over, men med hele HolePatch-kroppen fra HoleStatsSheet i
// stedet for kun gross_strokes. Kaster videre ved feil (409 STALE_VERSION
// inkludert) -- HoleStatsSheet fanger og viser en generisk feilmelding,
// beholder brukerens utfylte skjema i stedet for å lukke sheet-et blindt.
async function updateHoleFull(holeNumber: number, patch: HolePatch) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
// Full statistikkdybde (migrasjon 075, ADR-071) -- ÉN batched save() ved
// siste steg (ikke PATCH per felt, se ADR-078). Skriver alltid inn i
// holesByParticipant (nøkkelbasert på HVEM det gjelder, ikke bare "den
// valgte spilleren" -- ADR-099 utvidet dette fra ett-spiller-om-gangen
// siden flere spilleres ark nå kan være i bruk etter hverandre uten
// remount).
async function updateHoleFull(participantIdParam: string, holeNumber: number, patch: HolePatch) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantIdParam}/holes/${holeNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -3044,10 +3060,103 @@ function ScoreTab({
throw new Error("Klarte ikke å lagre.")
}
const updated: ApiHole = await res.json()
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
setHolesByParticipant((prev) => ({
...prev,
[participantIdParam]: (prev[participantIdParam] ?? []).map((h) => (h.hole_number === holeNumber ? updated : h)),
}))
setRefreshTick((n) => n + 1)
}
// --- Hull-først flerspiller-scoring (ADR-099, steg 2) -------------------
const flightHoleNumbers = round ? holeNumbersFor(round.hole_config) : []
function isRegistered(participantIdParam: string, holeNumber: number): boolean {
return (holesByParticipant[participantIdParam]?.find((h) => h.hole_number === holeNumber)?.gross_strokes ?? null) !== null
}
// Kjeden score-registreres i -- hopper over DSQ/RTD/DNF/DNS (samme
// begrunnelse som advanceWizardPlayer() i round-detail.tsx sitt
// "fullførte spillere hoppes over i samlebåndet"). Brukes OGSÅ for
// "er hullet ferdig"-sjekkene under (standardhull + hurtig-hopp-
// stripen) -- en diskvalifisert spiller kan aldri få registrert ny
// score (se team_authz.py), så å kreve DERES registrering for at et
// hull skal telle som "alle ferdig" ville låst standardvalget til
// samme hull for alltid (funnet i scratch-verifisering). Kortlisten
// under viser fortsatt ALLE flightParticipants, inkl. DSQ'et -- kun
// fullførthet-LOGIKKEN er kjede-scoped.
const chain = flightParticipants.filter((rp) => {
const tp = participants.find((p) => p.id === rp.tournament_participant_id)
return !tp || tp.status === "active"
})
const [activeHole, setActiveHole] = useState<number | null>(null)
useEffect(() => {
setActiveHole(null)
}, [roundId])
useEffect(() => {
if (activeHole !== null || flightHoleNumbers.length === 0 || chain.length === 0) return
// Vent til ALLE kjede-medlemmers hull-data faktisk har kommet inn --
// uten denne sjekken ville standardvalget kunne låse seg til hull 1
// (feilaktig "første åpne hull" basert på data som rett og slett ikke
// var lastet ennå), selv om senere data viser at hull 1-3 alt var
// registrert (funnet i scratch-verifisering).
const allLoaded = chain.every((rp) => holesByParticipant[rp.id] !== undefined)
if (!allLoaded) return
const firstOpen = flightHoleNumbers.find((n) => !chain.every((rp) => isRegistered(rp.id, n)))
setActiveHole(firstOpen ?? flightHoleNumbers[0])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeHole, chain, holesByParticipant, flightHoleNumbers])
const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
// "Vis hele scorekortet" (ADR-099, steg 2) -- kollapset som standard,
// samme fiks som round-detail.tsx sin PlayerHoleCards/ScorecardGrid-
// splitt.
const [showFullScorecard, setShowFullScorecard] = useState(false)
const wizardIdx = chain.findIndex((rp) => rp.id === wizardPlayerId)
const hasNextPlayer = wizardIdx !== -1 && wizardIdx < chain.length - 1
const nextPlayerName = hasNextPlayer ? chain[wizardIdx + 1].player_name : null
function openWizardFromButton() {
if (activeHole === null) return
const firstUnregistered = chain.find((rp) => !isRegistered(rp.id, activeHole))
setWizardPlayerId((firstUnregistered ?? chain[0])?.id ?? null)
}
function advanceWizard() {
if (wizardIdx !== -1 && wizardIdx < chain.length - 1) {
setWizardPlayerId(chain[wizardIdx + 1].id)
return
}
setWizardPlayerId(null)
if (activeHole !== null && flightHoleNumbers.length > 0) {
const i = flightHoleNumbers.indexOf(activeHole)
setActiveHole(flightHoleNumbers[(i + 1) % flightHoleNumbers.length])
}
}
function hcpFor(rp: ApiRoundParticipant): number | null {
return participants.find((p) => p.id === rp.tournament_participant_id)?.handicap_index_snapshot ?? null
}
function runningSum(participantIdParam: string): { strokes: number; toPar: number } | null {
const played = (holesByParticipant[participantIdParam] ?? []).filter((h) => h.gross_strokes !== null)
if (played.length === 0) return null
const strokes = played.reduce((s, h) => s + (h.gross_strokes as number), 0)
const par = played.reduce((s, h) => s + h.par, 0)
return { strokes, toPar: strokes - par }
}
const activeHoleMeta = (() => {
for (const rp of flightParticipants) {
const h = holesByParticipant[rp.id]?.find((x) => x.hole_number === activeHole)
if (h) return { par: h.par, strokeIndex: h.stroke_index }
}
return null
})()
const wizardHole = wizardPlayerId !== null ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null
if (rounds.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
@ -3086,55 +3195,224 @@ function ScoreTab({
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{visibleParticipants.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setParticipantId(rp.id)}
aria-pressed={rp.id === participantId}
className={cn(
"min-h-11 rounded-full border px-4 text-sm font-bold transition-colors",
rp.id === participantId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{rp.player_name}
</button>
))}
</div>
{myGroupId ? (
<button
type="button"
onClick={() => setShowFullField((v) => !v)}
className="self-start text-sm font-bold text-info underline-offset-4 hover:underline"
>
{showFullField ? "Vis kun min flight" : `Vis hele feltet (${roundParticipants.length})`}
</button>
) : (
{!myGroupId && (
<p className="text-sm font-medium text-muted-foreground">
Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet.
</p>
)}
{round && holes && (
<HoleGrid
base={base}
roundId={roundId}
participantId={participantId}
holes={holes}
holeConfig={round.hole_config}
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"}
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
{/* Hull-navigator (ADR-099, steg 2) */}
{round && activeHole !== null && (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-3">
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={() => setActiveHole(flightHoleNumbers[(flightHoleNumbers.indexOf(activeHole) - 1 + flightHoleNumbers.length) % flightHoleNumbers.length])}
className="flex min-h-11 flex-1 items-center justify-center gap-1 rounded-xl border border-border bg-background px-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige
</button>
<div className="flex flex-col items-center justify-center px-2 text-center">
<span className="text-lg font-extrabold leading-none text-foreground">Hull {activeHole}</span>
<span className="mt-1 text-xs font-semibold tabular-nums text-muted-foreground">
{activeHoleMeta ? `Par ${activeHoleMeta.par} · Indeks ${activeHoleMeta.strokeIndex}` : " "}
</span>
</div>
<button
type="button"
onClick={() => setActiveHole(flightHoleNumbers[(flightHoleNumbers.indexOf(activeHole) + 1) % flightHoleNumbers.length])}
className="flex min-h-11 flex-1 items-center justify-center gap-1 rounded-xl border border-border bg-background px-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Neste
<ChevronRight aria-hidden="true" className="size-5" />
</button>
</div>
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
{flightHoleNumbers.map((n) => {
const active = n === activeHole
const allDone = chain.length > 0 && chain.every((rp) => isRegistered(rp.id, n))
return (
<button
key={n}
type="button"
onClick={() => setActiveHole(n)}
aria-current={active ? "true" : undefined}
aria-label={`Hull ${n}${allDone ? ", alle registrert" : ""}`}
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl text-base font-bold tabular-nums transition-colors",
active
? "bg-primary text-primary-foreground"
: allDone
? "border border-primary/40 bg-primary/10 text-primary"
: "border border-border bg-background text-muted-foreground hover:bg-accent/50",
)}
>
{n}
</button>
)
})}
</div>
</div>
)}
{/* Flight-kortliste (ADR-099, steg 2) */}
{activeHole !== null && (
<ul className="flex flex-col gap-2.5">
{flightParticipants.map((rp) => {
const holeForPlayer = holesByParticipant[rp.id]?.find((h) => h.hole_number === activeHole)
const gross = holeForPlayer?.gross_strokes ?? null
const net =
gross !== null && holeForPlayer?.strokes_received !== null && holeForPlayer?.strokes_received !== undefined
? gross - holeForPlayer.strokes_received
: gross
const diff = net !== null && holeForPlayer ? net - holeForPlayer.par : null
const kind = diff !== null ? classify(diff) : null
const sum = runningSum(rp.id)
const hcp = hcpFor(rp)
return (
<li key={rp.id}>
<button
type="button"
onClick={() => setWizardPlayerId(rp.id)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border border-border bg-card p-3 text-left transition-colors hover:bg-accent/50",
rp.is_self && "border-l-4 border-l-primary",
)}
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-base font-extrabold text-foreground">
{rp.player_name}
{rp.is_self && (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
Deg
</span>
)}
</p>
<p className="text-xs font-semibold tabular-nums text-muted-foreground">
HCP {hcp !== null ? hcp.toFixed(1).replace(".", ",") : ""} · Sum{" "}
{sum ? `${sum.strokes} (${sum.toPar >= 0 ? "+" : ""}${sum.toPar})` : ""}
</p>
</div>
{gross !== null ? (
<span
className={cn(
"flex size-12 items-center justify-center text-xl font-extrabold tabular-nums transition-colors",
kind ? scoreMarkClasses(kind) : "rounded-[4px] border-2 border-border text-foreground",
)}
>
{gross}
</span>
) : (
<span
aria-hidden="true"
className="flex size-12 items-center justify-center rounded-[4px] border-2 border-dashed border-primary/50 text-primary"
>
<Plus className="size-5" />
</span>
)}
</button>
</li>
)
})}
</ul>
)}
{activeHole !== null && chain.length > 0 && (
<button
type="button"
onClick={openWizardFromButton}
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 text-base font-extrabold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Registrer score
<ArrowRight aria-hidden="true" className="size-5" />
</button>
)}
{wizardPlayerId !== null && wizardHole && (
<TournamentScoringWizard
hole={wizardHole}
playerName={roundParticipants.find((rp) => rp.id === wizardPlayerId)?.player_name ?? ""}
statLevel={roundParticipants.find((rp) => rp.id === wizardPlayerId)?.stat_level ?? "strokes_only"}
strokesReceived={wizardHole.strokes_received}
ownBagClubs={[]}
onUpdate={updateHole}
onUpdateFull={updateHoleFull}
historyUrl={`${base}/rounds/${roundId}/participants/${wizardPlayerId}/holes/${wizardHole.hole_number}/history`}
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
players={chain.map((rp) => ({ id: rp.id, playerName: rp.player_name, registered: isRegistered(rp.id, wizardHole.hole_number) }))}
activePlayerId={wizardPlayerId}
onSwitchPlayer={setWizardPlayerId}
hasNextPlayer={hasNextPlayer}
nextPlayerName={nextPlayerName}
onSave={(patch) => updateHoleFull(wizardPlayerId, wizardHole.hole_number, patch)}
onAdvance={advanceWizard}
onClose={() => setWizardPlayerId(null)}
/>
)}
{/* Fullt scorekort, spiller-først (steg 1) -- sekundært nå, bak en
bryter, samme fiks som round-detail.tsx sitt "Vis hele
scorekortet" (kommentar der: et bredt grid tar for stor plass
i bredden mobil som standardvisning). Ingen ny kode for
selve gridet, kun ny plassering. */}
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setShowFullScorecard((v) => !v)}
aria-expanded={showFullScorecard}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Vis hele scorekortet
<ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</button>
{showFullScorecard && (
<div className="flex flex-col gap-3 border-t border-border p-3">
<div className="flex flex-wrap gap-2">
{visibleParticipants.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setParticipantId(rp.id)}
aria-pressed={rp.id === participantId}
className={cn(
"min-h-11 rounded-full border px-4 text-sm font-bold transition-colors",
rp.id === participantId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{rp.player_name}
</button>
))}
</div>
{myGroupId && (
<button
type="button"
onClick={() => setShowFullField((v) => !v)}
className="self-start text-sm font-bold text-info underline-offset-4 hover:underline"
>
{showFullField ? "Vis kun min flight" : `Vis hele feltet (${roundParticipants.length})`}
</button>
)}
{round && holes && (
<HoleGrid
base={base}
roundId={roundId}
participantId={participantId}
holes={holes}
holeConfig={round.hole_config}
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"}
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
ownBagClubs={[]}
onUpdateFull={(holeNumber, patch) => updateHoleFull(participantId, holeNumber, patch)}
/>
)}
</div>
)}
</div>
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
uavhengig av gross_strokes-registreringen over. */}
{round && scoringMethod === "bingo_bango_bongo" && (
@ -4109,12 +4387,22 @@ type HoleHistorySummary = {
type SheetStep = "overview" | "strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"
function sheetStepsFor(statLevel: "strokes_and_putts" | "full"): SheetStep[] {
function sheetStepsFor(statLevel: "strokes_only" | "strokes_and_putts" | "full"): SheetStep[] {
if (statLevel === "strokes_only") return ["strokes"]
if (statLevel === "full") return ["overview", "strokes", "putts", "puttDistance", "direction", "holeDetails"]
return ["overview", "strokes", "putts", "puttDistance"]
}
function HoleStatsSheet({
// Kjedet flerspiller-scoring (ADR-099, steg 2 av parity-planen, 2026-08-22)
// -- utvidet fra den opprinnelige ett-spiller HoleStatsSheet (ADR-078/079,
// se historikken over) med en kontekst-rad + Neste/"Neste: {navn}"/Ferdig-
// kjeding, speiler ScoringWizard i round-detail.tsx (der KJEDE-LOGIKKEN
// selv eies av forelderen -- ScoreTab under -- denne komponenten er ren
// presentasjon + onAdvance/onSwitchPlayer). Instansen holdes nå I LIVE på
// tvers av spillerbytter innad på samme hull (ikke lenger remount per
// åpning) -- derfor må BÅDE utkast-feltene OG historikk-/auto-hopp-vaktene
// eksplisitt nullstilles per `activePlayerId`, ikke bare per `step`.
function TournamentScoringWizard({
hole,
playerName,
statLevel,
@ -4122,17 +4410,31 @@ function HoleStatsSheet({
ownBagClubs,
historyUrl,
targetPointsBaseUrl,
players,
activePlayerId,
onSwitchPlayer,
hasNextPlayer,
nextPlayerName,
onSave,
onAdvance,
onClose,
}: {
hole: ApiHole
playerName: string
statLevel: "strokes_and_putts" | "full"
statLevel: "strokes_only" | "strokes_and_putts" | "full"
strokesReceived: number | null
ownBagClubs: string[]
historyUrl: string
targetPointsBaseUrl: string
// Kontekst-raden -- skjules når det kun er én spiller (dekker HoleGrid
// sitt enkelt-spiller-kallsted uendret, jf. ADR-099).
players: { id: string; playerName: string; registered: boolean }[]
activePlayerId: string
onSwitchPlayer: (playerId: string) => void
hasNextPlayer: boolean
nextPlayerName: string | null
onSave: (patch: HolePatch) => Promise<void>
onAdvance: () => void
onClose: () => void
}) {
const [strokes, setStrokes] = useState<number | null>(hole.gross_strokes)
@ -4155,12 +4457,34 @@ function HoleStatsSheet({
const isLastStep = stepIndex === steps.length - 1
const canProceed = step === "strokes" ? strokes !== null : true
// "overview"-steget (ADR-079): hentet én gang ved åpning, ikke re-hentet
// per steg-bytte. undefined = laster ennå, null = ingen historikk.
// Nullstill ALLE utkast-felt + steg når AKTIV SPILLER bytter (kontekst-
// rad-trykk ELLER kjede-fremdrift) -- instansen remountes ikke lenger
// mellom spillere, så dette må gjøres eksplisitt (uten dette ville neste
// spillers ark vist FORRIGE spillers allerede utfylte tall).
useEffect(() => {
setStrokes(hole.gross_strokes)
setPutts(hole.putts)
setPuttBucket(hole.first_putt_distance_bucket)
setClub(hole.club_off_tee)
setTeeShot(hole.tee_shot_result)
setApproach(hole.approach_result)
setChip(hole.chip_count ?? 0)
setBunker(hole.bunker_shot_count ?? 0)
setPenalty(hole.penalty_strokes ?? 0)
setAnywayStrokes(hole.anyway_strokes ?? 0)
setStepIndex(0)
setError(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayerId])
// "overview"-steget (ADR-079): hentet på nytt per AKTIV SPILLER (ikke kun
// ved åpning -- se begrunnelsen over). undefined = laster ennå, null =
// ingen historikk.
const [historySummary, setHistorySummary] = useState<HoleHistorySummary | null | undefined>(undefined)
const [showFullHistory, setShowFullHistory] = useState(false)
useEffect(() => {
let cancelled = false
setHistorySummary(undefined)
fetch(historyUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
@ -4172,8 +4496,7 @@ function HoleStatsSheet({
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [historyUrl])
// Hopp automatisk forbi "overview" når det ikke er noe å vise -- unngår
// et ekstra obligatorisk trykk for HVER hull-registrering (se
// begrunnelsen på SheetStep over).
@ -4184,15 +4507,32 @@ function HoleStatsSheet({
// Auto-hopp, samme mønster som ScoringWizard (round-detail.tsx) --
// hopper videre av seg selv når steget sitt eget felt får en verdi.
// Vaktene (enteredWithValueRef/firedRef) nullstilles per `activePlayerId`
// I TILLEGG til `step` (round-detail.tsx sitt tilsvarende mønster bruker
// `[step, player.id, hole.holeNumber]`) -- uten dette ville et allerede
// utfylt "Slag"-felt for spiller 1 stille "brukt opp" auto-hoppet for
// spiller 2. Leser fra `hole`-PROPEN, ikke de lokale utkast-feltene --
// funnet i scratch-verifisering: siden reset-effekten over kaller
// `setStrokes(...)` (en STATE-oppdatering, ikke synkron), ville denne
// vakten ellers lest den GAMLE spillerens fortsatt-uendrede `strokes`
// midt i samme effekt-runde, og trodd feltet "allerede hadde en verdi"
// for en spiller som i virkeligheten var helt tom -- auto-hoppet uteble
// da helt for neste spiller i kjeden.
const autoField = step === "strokes" ? "strokes" : step === "putts" ? "putts" : step === "puttDistance" ? "puttBucket" : null
const enteredWithValueRef = useRef(false)
const firedRef = useRef(false)
useEffect(() => {
firedRef.current = false
enteredWithValueRef.current =
autoField === "strokes" ? strokes !== null : autoField === "putts" ? putts !== null : autoField === "puttBucket" ? puttBucket !== null : false
autoField === "strokes"
? hole.gross_strokes !== null
: autoField === "putts"
? hole.putts !== null
: autoField === "puttBucket"
? hole.first_putt_distance_bucket !== null
: false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step])
}, [step, activePlayerId, hole.hole_number])
useEffect(() => {
if (!autoField || firedRef.current || enteredWithValueRef.current) return
const value = autoField === "strokes" ? strokes : autoField === "putts" ? putts : puttBucket
@ -4233,7 +4573,8 @@ function HoleStatsSheet({
anyway_strokes: anywayStrokes,
expected_version: hole.version,
})
onClose()
setSaving(false)
onAdvance()
} catch {
setError("Klarte ikke å lagre. Prøv igjen.")
setSaving(false)
@ -4275,6 +4616,31 @@ function HoleStatsSheet({
<div className="size-11 shrink-0" aria-hidden="true" />
</header>
{/* Kontekst-rad (ADR-099) -- kun når det faktisk er en flight å bytte
mellom (HoleGrid sitt enkelt-spiller-kallsted sender players med
lengde 1, ingen rad vises da -- uendret oppførsel der). */}
{players.length > 1 && (
<div className="flex shrink-0 gap-2 overflow-x-auto border-b border-border px-3 py-2.5">
{players.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onSwitchPlayer(p.id)}
aria-pressed={p.id === activePlayerId}
className={cn(
"flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3.5 text-sm font-bold transition-colors",
p.id === activePlayerId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
<span className="truncate">{p.playerName}</span>
{p.registered && p.id !== activePlayerId && <span className="text-xs font-semibold">· Registrert</span>}
</button>
))}
</div>
)}
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
@ -4415,7 +4781,7 @@ function HoleStatsSheet({
onClick={forward}
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
>
{isLastStep ? (saving ? "Lagrer…" : "Lagre") : "Neste"}
{isLastStep ? (saving ? "Lagrer…" : hasNextPlayer ? `Neste: ${nextPlayerName}` : "Ferdig") : "Neste"}
</Button>
</footer>
@ -4436,7 +4802,6 @@ function HoleGrid({
playerName,
strokesReceived,
ownBagClubs,
onUpdate,
onUpdateFull,
}: {
base: string
@ -4448,15 +4813,12 @@ function HoleGrid({
playerName: string
strokesReceived: (holeNumber: number) => number | null
ownBagClubs: string[]
onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
}) {
const [editingHole, setEditingHole] = useState<number | null>(null)
const [draft, setDraft] = useState("")
// Full statistikkdybde (migrasjon 075, ADR-071): en annen deltaker-
// celle enn den enkle inline-tekstboksen under -- kun for statLevel !=
// "strokes_only". HoleGrid sin egen strokes_only-vei (editingHole/
// draft/openEditor/commit over) er BEVISST uendret, ingen regresjon.
// Full statistikkdybde (migrasjon 075, ADR-071), utvidet ADR-099 (2026-
// 08-22): ÉN wizard for alle tre statLevel-nivåer nå (strokes_only fikk
// tidligere kun et bart inline-tallfelt her -- fjernet, samme picker-
// opplevelse som resten av appen for alle nivåer, se TournamentScoringWizard).
const [sheetHole, setSheetHole] = useState<number | null>(null)
const numbers = holeNumbersFor(holeConfig)
const byNumber = new Map(holes.map((h) => [h.hole_number, h]))
@ -4465,22 +4827,8 @@ function HoleGrid({
const totalGross = played.length > 0 ? played.reduce((sum, h) => sum + (h.gross_strokes as number), 0) : null
const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null
function openEditor(holeNumber: number, current: number | null) {
if (statLevel !== "strokes_only") {
setSheetHole(holeNumber)
return
}
setEditingHole(holeNumber)
setDraft(current !== null ? String(current) : "")
}
async function commit(holeNumber: number) {
const value = Number(draft)
if (Number.isFinite(value) && value >= 1 && value <= 20) {
await onUpdate(holeNumber, value)
}
setEditingHole(null)
setDraft("")
function openEditor(holeNumber: number) {
setSheetHole(holeNumber)
}
return (
@ -4505,34 +4853,12 @@ function HoleGrid({
: gross
const diff = net !== null && hole ? net - hole.par : null
const kind = diff !== null ? classify(diff) : null
const isEditing = editingHole === n
if (isEditing) {
return (
<input
key={`cell-${n}`}
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/[^0-9]/g, ""))}
onBlur={() => commit(n)}
onKeyDown={(e) => {
if (e.key === "Enter") commit(n)
if (e.key === "Escape") {
setEditingHole(null)
setDraft("")
}
}}
className="mx-auto flex size-9 items-center justify-center rounded-[4px] border-2 border-info bg-background text-center text-sm font-extrabold tabular-nums text-foreground outline-none"
/>
)
}
return (
<button
key={`cell-${n}`}
type="button"
onClick={() => openEditor(n, gross)}
onClick={() => openEditor(n)}
aria-label={
gross !== null
? `Hull ${n}, ${gross} slag, trykk for å endre`
@ -4564,8 +4890,8 @@ function HoleGrid({
</div>
)}
{sheetHole !== null && statLevel !== "strokes_only" && byNumber.get(sheetHole) && (
<HoleStatsSheet
{sheetHole !== null && byNumber.get(sheetHole) && (
<TournamentScoringWizard
hole={byNumber.get(sheetHole)!}
playerName={playerName}
statLevel={statLevel}
@ -4573,7 +4899,13 @@ function HoleGrid({
ownBagClubs={ownBagClubs}
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
players={[{ id: participantId, playerName, registered: (byNumber.get(sheetHole)?.gross_strokes ?? null) !== null }]}
activePlayerId={participantId}
onSwitchPlayer={() => {}}
hasNextPlayer={false}
nextPlayerName={null}
onSave={(patch) => onUpdateFull(sheetHole, patch)}
onAdvance={() => setSheetHole(null)}
onClose={() => setSheetHole(null)}
/>
)}