diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 740106b..25fe2d7 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc0a5b8..41b4a5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx
index 7dbded3..422496c 100644
--- a/frontend/components/individual-tournament-detail.tsx
+++ b/frontend/components/individual-tournament-detail.tsx
@@ -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" && (
-
+
)}
{tab === "leaderboard" && (
@@ -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([])
const [participantId, setParticipantId] = useState("")
- const [holes, setHoles] = useState(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>({})
useEffect(() => {
- if (!roundId || !participantId) {
- setHoles(null)
- return
- }
+ if (!roundId) return
let cancelled = false
- getJson(`${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(`${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(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(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 (