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:
parent
71c1b81e3b
commit
707df01e2a
3 changed files with 580 additions and 136 deletions
|
|
@ -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)
|
||||
|
|
|
|||
31
CHANGELOG.md
31
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.
|
||||
|
|
|
|||
|
|
@ -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 på 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)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue