From 707df01e2ad679849dc42d09eb30cd46008994c5 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 21 Aug 2026 23:00:22 +0200 Subject: [PATCH] Kjedet flerspiller-scoring i ScoreTab (ADR-100) -- steg 2 av parity-planen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE_DECISIONS.md | 81 +++ CHANGELOG.md | 31 + .../individual-tournament-detail.tsx | 604 ++++++++++++++---- 3 files changed, 580 insertions(+), 136 deletions(-) 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 (
@@ -3086,55 +3195,224 @@ function ScoreTab({

) : ( <> -
- {visibleParticipants.map((rp) => ( - - ))} -
- - {myGroupId ? ( - - ) : ( + {!myGroupId && (

Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet.

)} - {round && holes && ( - 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 && ( +
+
+ +
+ Hull {activeHole} + + {activeHoleMeta ? `Par ${activeHoleMeta.par} · Indeks ${activeHoleMeta.strokeIndex}` : " "} + +
+ +
+
+ {flightHoleNumbers.map((n) => { + const active = n === activeHole + const allDone = chain.length > 0 && chain.every((rp) => isRegistered(rp.id, n)) + return ( + + ) + })} +
+
+ )} + + {/* Flight-kortliste (ADR-099, steg 2) */} + {activeHole !== null && ( +
    + {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 ( +
  • + +
  • + ) + })} +
+ )} + + {activeHole !== null && chain.length > 0 && ( + + )} + + {wizardPlayerId !== null && wizardHole && ( + 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 på mobil som standardvisning). Ingen ny kode for + selve gridet, kun ny plassering. */} +
+ + {showFullScorecard && ( +
+
+ {visibleParticipants.map((rp) => ( + + ))} +
+ + {myGroupId && ( + + )} + + {round && holes && ( + 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)} + /> + )} +
+ )} +
+ {/* 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 + onAdvance: () => void onClose: () => void }) { const [strokes, setStrokes] = useState(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(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({