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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
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
|
tomt før/etter, 1 rad uendret), deretter `docker compose build
|
||||||
teecup_api teecup_frontend && up -d`. Begge containere friske, rene
|
teecup_api teecup_frontend && up -d`. Begge containere friske, rene
|
||||||
logger. `https://teecup.golf/health` 200 etter utrulling.
|
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 type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronsUpDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
|
|
@ -1093,7 +1095,13 @@ export function IndividualTournamentDetail({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "score" && (
|
{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" && (
|
{tab === "leaderboard" && (
|
||||||
<LeaderboardTab base={base} tournament={tournament} />
|
<LeaderboardTab base={base} tournament={tournament} />
|
||||||
|
|
@ -2952,26 +2960,27 @@ function ScoreTab({
|
||||||
base,
|
base,
|
||||||
rounds,
|
rounds,
|
||||||
scoringMethod,
|
scoringMethod,
|
||||||
|
participants,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
rounds: ApiRound[]
|
rounds: ApiRound[]
|
||||||
scoringMethod: string | null
|
scoringMethod: string | null
|
||||||
|
participants: ApiParticipant[]
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
|
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
|
||||||
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
|
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
|
||||||
const [participantId, setParticipantId] = useState("")
|
const [participantId, setParticipantId] = useState("")
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
|
||||||
const round = rounds.find((r) => r.id === roundId) ?? null
|
const round = rounds.find((r) => r.id === roundId) ?? null
|
||||||
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
|
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
|
||||||
// fetch) vet de skal hente på nytt.
|
// fetch) vet de skal hente på nytt.
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
|
||||||
// Flight-scoping (ADR-099) -- standard er å vise KUN egen
|
// Flight-scoping (ADR-099) -- standard er å vise KUN egen
|
||||||
// utslagsgruppe i velgeren under, med en lenke for å se hele feltet.
|
// utslagsgruppe, med en lenke for å se hele feltet. roundParticipants
|
||||||
// roundParticipants selv holdes ALLTID ufiltrert (BBB/Flag-panelene
|
// selv holdes ALLTID ufiltrert (BBB/Flag-panelene under bruker den
|
||||||
// under bruker den direkte, uavhengig av dette).
|
// direkte, uavhengig av dette).
|
||||||
const [showFullField, setShowFullField] = useState(false)
|
const [showFullField, setShowFullField] = useState(false)
|
||||||
const myParticipant = roundParticipants.find((rp) => rp.is_self) ?? null
|
const myParticipant = roundParticipants.find((rp) => rp.is_self) ?? null
|
||||||
const myGroupId = myParticipant?.tournament_round_group_id ?? null
|
const myGroupId = myParticipant?.tournament_round_group_id ?? null
|
||||||
|
|
@ -2979,6 +2988,13 @@ function ScoreTab({
|
||||||
showFullField || !myGroupId
|
showFullField || !myGroupId
|
||||||
? roundParticipants
|
? roundParticipants
|
||||||
: roundParticipants.filter((rp) => rp.tournament_round_group_id === myGroupId)
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (!roundId) return
|
if (!roundId) return
|
||||||
|
|
@ -2998,43 +3014,43 @@ function ScoreTab({
|
||||||
}
|
}
|
||||||
}, [base, roundId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!roundId || !participantId) {
|
if (!roundId) return
|
||||||
setHoles(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => {
|
const ids = new Set(flightParticipants.map((rp) => rp.id))
|
||||||
if (!cancelled) setHoles(data ?? [])
|
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 () => {
|
return () => {
|
||||||
cancelled = true
|
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 holes = participantId ? (holesByParticipant[participantId] ?? null) : null
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full statistikkdybde (migrasjon 075, ADR-071) -- samme endepunkt som
|
// Full statistikkdybde (migrasjon 075, ADR-071) -- ÉN batched save() ved
|
||||||
// updateHole over, men med hele HolePatch-kroppen fra HoleStatsSheet i
|
// siste steg (ikke PATCH per felt, se ADR-078). Skriver alltid inn i
|
||||||
// stedet for kun gross_strokes. Kaster videre ved feil (409 STALE_VERSION
|
// holesByParticipant (nøkkelbasert på HVEM det gjelder, ikke bare "den
|
||||||
// inkludert) -- HoleStatsSheet fanger og viser en generisk feilmelding,
|
// valgte spilleren" -- ADR-099 utvidet dette fra ett-spiller-om-gangen
|
||||||
// beholder brukerens utfylte skjema i stedet for å lukke sheet-et blindt.
|
// siden flere spilleres ark nå kan være i bruk etter hverandre uten
|
||||||
async function updateHoleFull(holeNumber: number, patch: HolePatch) {
|
// remount).
|
||||||
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
|
async function updateHoleFull(participantIdParam: string, holeNumber: number, patch: HolePatch) {
|
||||||
|
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantIdParam}/holes/${holeNumber}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -3044,10 +3060,103 @@ function ScoreTab({
|
||||||
throw new Error("Klarte ikke å lagre.")
|
throw new Error("Klarte ikke å lagre.")
|
||||||
}
|
}
|
||||||
const updated: ApiHole = await res.json()
|
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)
|
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) {
|
if (rounds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
|
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
|
||||||
|
|
@ -3086,6 +3195,177 @@ function ScoreTab({
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{!myGroupId && (
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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={[]}
|
||||||
|
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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{visibleParticipants.map((rp) => (
|
{visibleParticipants.map((rp) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -3105,7 +3385,7 @@ function ScoreTab({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{myGroupId ? (
|
{myGroupId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowFullField((v) => !v)}
|
onClick={() => setShowFullField((v) => !v)}
|
||||||
|
|
@ -3113,10 +3393,6 @@ function ScoreTab({
|
||||||
>
|
>
|
||||||
{showFullField ? "Vis kun min flight" : `Vis hele feltet (${roundParticipants.length})`}
|
{showFullField ? "Vis kun min flight" : `Vis hele feltet (${roundParticipants.length})`}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{round && holes && (
|
{round && holes && (
|
||||||
|
|
@ -3130,10 +3406,12 @@ function ScoreTab({
|
||||||
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
|
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
|
||||||
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
|
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
|
||||||
ownBagClubs={[]}
|
ownBagClubs={[]}
|
||||||
onUpdate={updateHole}
|
onUpdateFull={(holeNumber, patch) => updateHoleFull(participantId, holeNumber, patch)}
|
||||||
onUpdateFull={updateHoleFull}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
|
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
|
||||||
uavhengig av gross_strokes-registreringen over. */}
|
uavhengig av gross_strokes-registreringen over. */}
|
||||||
|
|
@ -4109,12 +4387,22 @@ type HoleHistorySummary = {
|
||||||
|
|
||||||
type SheetStep = "overview" | "strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"
|
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"]
|
if (statLevel === "full") return ["overview", "strokes", "putts", "puttDistance", "direction", "holeDetails"]
|
||||||
return ["overview", "strokes", "putts", "puttDistance"]
|
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,
|
hole,
|
||||||
playerName,
|
playerName,
|
||||||
statLevel,
|
statLevel,
|
||||||
|
|
@ -4122,17 +4410,31 @@ function HoleStatsSheet({
|
||||||
ownBagClubs,
|
ownBagClubs,
|
||||||
historyUrl,
|
historyUrl,
|
||||||
targetPointsBaseUrl,
|
targetPointsBaseUrl,
|
||||||
|
players,
|
||||||
|
activePlayerId,
|
||||||
|
onSwitchPlayer,
|
||||||
|
hasNextPlayer,
|
||||||
|
nextPlayerName,
|
||||||
onSave,
|
onSave,
|
||||||
|
onAdvance,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
hole: ApiHole
|
hole: ApiHole
|
||||||
playerName: string
|
playerName: string
|
||||||
statLevel: "strokes_and_putts" | "full"
|
statLevel: "strokes_only" | "strokes_and_putts" | "full"
|
||||||
strokesReceived: number | null
|
strokesReceived: number | null
|
||||||
ownBagClubs: string[]
|
ownBagClubs: string[]
|
||||||
historyUrl: string
|
historyUrl: string
|
||||||
targetPointsBaseUrl: 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>
|
onSave: (patch: HolePatch) => Promise<void>
|
||||||
|
onAdvance: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [strokes, setStrokes] = useState<number | null>(hole.gross_strokes)
|
const [strokes, setStrokes] = useState<number | null>(hole.gross_strokes)
|
||||||
|
|
@ -4155,12 +4457,34 @@ function HoleStatsSheet({
|
||||||
const isLastStep = stepIndex === steps.length - 1
|
const isLastStep = stepIndex === steps.length - 1
|
||||||
const canProceed = step === "strokes" ? strokes !== null : true
|
const canProceed = step === "strokes" ? strokes !== null : true
|
||||||
|
|
||||||
// "overview"-steget (ADR-079): hentet én gang ved åpning, ikke re-hentet
|
// Nullstill ALLE utkast-felt + steg når AKTIV SPILLER bytter (kontekst-
|
||||||
// per steg-bytte. undefined = laster ennå, null = ingen historikk.
|
// 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 [historySummary, setHistorySummary] = useState<HoleHistorySummary | null | undefined>(undefined)
|
||||||
const [showFullHistory, setShowFullHistory] = useState(false)
|
const [showFullHistory, setShowFullHistory] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
setHistorySummary(undefined)
|
||||||
fetch(historyUrl, { credentials: "include" })
|
fetch(historyUrl, { credentials: "include" })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
|
@ -4172,8 +4496,7 @@ function HoleStatsSheet({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [historyUrl])
|
||||||
}, [])
|
|
||||||
// Hopp automatisk forbi "overview" når det ikke er noe å vise -- unngår
|
// Hopp automatisk forbi "overview" når det ikke er noe å vise -- unngår
|
||||||
// et ekstra obligatorisk trykk for HVER hull-registrering (se
|
// et ekstra obligatorisk trykk for HVER hull-registrering (se
|
||||||
// begrunnelsen på SheetStep over).
|
// begrunnelsen på SheetStep over).
|
||||||
|
|
@ -4184,15 +4507,32 @@ function HoleStatsSheet({
|
||||||
|
|
||||||
// Auto-hopp, samme mønster som ScoringWizard (round-detail.tsx) --
|
// 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.
|
// 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 autoField = step === "strokes" ? "strokes" : step === "putts" ? "putts" : step === "puttDistance" ? "puttBucket" : null
|
||||||
const enteredWithValueRef = useRef(false)
|
const enteredWithValueRef = useRef(false)
|
||||||
const firedRef = useRef(false)
|
const firedRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firedRef.current = false
|
firedRef.current = false
|
||||||
enteredWithValueRef.current =
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [step])
|
}, [step, activePlayerId, hole.hole_number])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoField || firedRef.current || enteredWithValueRef.current) return
|
if (!autoField || firedRef.current || enteredWithValueRef.current) return
|
||||||
const value = autoField === "strokes" ? strokes : autoField === "putts" ? putts : puttBucket
|
const value = autoField === "strokes" ? strokes : autoField === "putts" ? putts : puttBucket
|
||||||
|
|
@ -4233,7 +4573,8 @@ function HoleStatsSheet({
|
||||||
anyway_strokes: anywayStrokes,
|
anyway_strokes: anywayStrokes,
|
||||||
expected_version: hole.version,
|
expected_version: hole.version,
|
||||||
})
|
})
|
||||||
onClose()
|
setSaving(false)
|
||||||
|
onAdvance()
|
||||||
} catch {
|
} catch {
|
||||||
setError("Klarte ikke å lagre. Prøv igjen.")
|
setError("Klarte ikke å lagre. Prøv igjen.")
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|
@ -4275,6 +4616,31 @@ function HoleStatsSheet({
|
||||||
<div className="size-11 shrink-0" aria-hidden="true" />
|
<div className="size-11 shrink-0" aria-hidden="true" />
|
||||||
</header>
|
</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">
|
<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">
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
|
||||||
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
|
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
|
||||||
|
|
@ -4415,7 +4781,7 @@ function HoleStatsSheet({
|
||||||
onClick={forward}
|
onClick={forward}
|
||||||
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
|
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>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
@ -4436,7 +4802,6 @@ function HoleGrid({
|
||||||
playerName,
|
playerName,
|
||||||
strokesReceived,
|
strokesReceived,
|
||||||
ownBagClubs,
|
ownBagClubs,
|
||||||
onUpdate,
|
|
||||||
onUpdateFull,
|
onUpdateFull,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
|
|
@ -4448,15 +4813,12 @@ function HoleGrid({
|
||||||
playerName: string
|
playerName: string
|
||||||
strokesReceived: (holeNumber: number) => number | null
|
strokesReceived: (holeNumber: number) => number | null
|
||||||
ownBagClubs: string[]
|
ownBagClubs: string[]
|
||||||
onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
|
|
||||||
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
|
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [editingHole, setEditingHole] = useState<number | null>(null)
|
// Full statistikkdybde (migrasjon 075, ADR-071), utvidet ADR-099 (2026-
|
||||||
const [draft, setDraft] = useState("")
|
// 08-22): ÉN wizard for alle tre statLevel-nivåer nå (strokes_only fikk
|
||||||
// Full statistikkdybde (migrasjon 075, ADR-071): en annen deltaker-
|
// tidligere kun et bart inline-tallfelt her -- fjernet, samme picker-
|
||||||
// celle enn den enkle inline-tekstboksen under -- kun for statLevel !=
|
// opplevelse som resten av appen for alle nivåer, se TournamentScoringWizard).
|
||||||
// "strokes_only". HoleGrid sin egen strokes_only-vei (editingHole/
|
|
||||||
// draft/openEditor/commit over) er BEVISST uendret, ingen regresjon.
|
|
||||||
const [sheetHole, setSheetHole] = useState<number | null>(null)
|
const [sheetHole, setSheetHole] = useState<number | null>(null)
|
||||||
const numbers = holeNumbersFor(holeConfig)
|
const numbers = holeNumbersFor(holeConfig)
|
||||||
const byNumber = new Map(holes.map((h) => [h.hole_number, h]))
|
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 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
|
const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null
|
||||||
|
|
||||||
function openEditor(holeNumber: number, current: number | null) {
|
function openEditor(holeNumber: number) {
|
||||||
if (statLevel !== "strokes_only") {
|
|
||||||
setSheetHole(holeNumber)
|
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("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -4505,34 +4853,12 @@ function HoleGrid({
|
||||||
: gross
|
: gross
|
||||||
const diff = net !== null && hole ? net - hole.par : null
|
const diff = net !== null && hole ? net - hole.par : null
|
||||||
const kind = diff !== null ? classify(diff) : 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`cell-${n}`}
|
key={`cell-${n}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEditor(n, gross)}
|
onClick={() => openEditor(n)}
|
||||||
aria-label={
|
aria-label={
|
||||||
gross !== null
|
gross !== null
|
||||||
? `Hull ${n}, ${gross} slag, trykk for å endre`
|
? `Hull ${n}, ${gross} slag, trykk for å endre`
|
||||||
|
|
@ -4564,8 +4890,8 @@ function HoleGrid({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sheetHole !== null && statLevel !== "strokes_only" && byNumber.get(sheetHole) && (
|
{sheetHole !== null && byNumber.get(sheetHole) && (
|
||||||
<HoleStatsSheet
|
<TournamentScoringWizard
|
||||||
hole={byNumber.get(sheetHole)!}
|
hole={byNumber.get(sheetHole)!}
|
||||||
playerName={playerName}
|
playerName={playerName}
|
||||||
statLevel={statLevel}
|
statLevel={statLevel}
|
||||||
|
|
@ -4573,7 +4899,13 @@ function HoleGrid({
|
||||||
ownBagClubs={ownBagClubs}
|
ownBagClubs={ownBagClubs}
|
||||||
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
|
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
|
||||||
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
|
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)}
|
onSave={(patch) => onUpdateFull(sheetHole, patch)}
|
||||||
|
onAdvance={() => setSheetHole(null)}
|
||||||
onClose={() => setSheetHole(null)}
|
onClose={() => setSheetHole(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue