Live. Kort oppsummert:

Full skjermovertagende veiviser — trykk på en spillers score-knapp åpner nå et eget fullskjermbilde for akkurat den spilleren på det hullet, med Forrige/Neste gjennom stegene (Slag → Putter → detaljer, avhengig av statistikknivå). Siste steg for siste spiller blir "Ferdig" og går videre til neste hull automatisk.
Akkumulert score synlig for alle — hver spiller viser nå "HCP X · E/+N så langt (Y hull)" hele tiden i hovedlisten, ikke bare for én valgt spiller.
Hovedsiden er kraftig forenklet: det gamle lange skjemaet er borte, erstattet med én kompakt rad per spiller.
Gitt at forrige forsøk så dårlig ut til tross for at logikken var riktig, er dette en større usikkerhet enn vanlig — jeg oppfordrer deg til å teste dette grundig i nettleseren før du stoler på det.
This commit is contained in:
Erol Haagenrud 2026-07-26 19:54:18 +02:00
parent e2ea0c0fe3
commit 9ec6f1c9ed
3 changed files with 461 additions and 226 deletions

View file

@ -78,7 +78,10 @@
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_logic.mjs)", "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_logic.mjs)",
"Bash(tee /tmp/wizard_setup.json)", "Bash(tee /tmp/wizard_setup.json)",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['round_id']\\)\")", "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['round_id']\\)\")",
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")" "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")",
"Bash(grep -n \"activePlayerIndex\\\\|isLastPlayer\\\\|nextPlayer\\\\|canAdvance\\\\|advanceToNextPlayerOrHole\\\\|isEntryComplete\\\\|activePlayerIdRef\\\\|const loadHoles\\\\|useEffect\\(\\(\\) => {$\" /opt/teecup/frontend/components/round-detail.tsx)",
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_v2.mjs)",
"Bash(tee /tmp/wizard_setup2.json)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teecup/frontend/app/my-rounds/[id]" "/opt/teecup/frontend/app/my-rounds/[id]"

View file

@ -3412,6 +3412,59 @@ Ferdig og verifisert:
ingen migrasjon, `docker compose up -d --build teecup_api ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket. → 200, `teeoff.no` upåvirket.
- **Scoringsflyt v2: full skjermovertagende veiviser, BYGGET, IKKE ENNÅ
RULLET UT (2026-07-26) -- ERSTATTER forrige rundes forsøk.** Brukeren
testet forrige rundes "auto-fremdrift-knapp"-versjon live og var tydelig:
"ingen forbedring i det hele tatt", "visuelt like overveldende og
rotete" -- den inline-baserte tilnærmingen var IKKE nok, en ekte
skjermovertagende veiviser (som opprinnelig vurdert og lagt til side pga.
risiko uten visuell testing) var det som faktisk kreves. Bygget nå for
ekte, pluss et eksplisitt nytt krav: akkumulert score-så-langt for RUNDEN
synlig for HVER spiller samtidig (ikke bare aktiv), matchende
konkurrentappens vedvarende "E"/"+1"-visning ved siden av hvert navn.
**Datalasting endret** (nødvendig for punktet over): laster nå hull for
ALLE deltakere med det samme rundén lastes (ikke lenger lat lasting kun
for aktiv spiller), og sanntid-signalet (WS) henter nå alles hull på
nytt, ikke bare énes.
**Ny `ScoringWizard`-komponent** (fullskjerm, `fixed inset-0 z-50`, egen
stack utenfor `<main>`): tre steg maks, drevet av `stat_level` --
"strokes_only" (kun Slag), "strokes_and_putts" (+ Putter/avstand første
putt), "full" (+ ett samlet detalj-steg: kølle/utslag/innspill/chip/
bunker/straffeslag/anywayslag). Bevisst FÆRRE, grovere steg enn
konkurrentens egne 5-6 skjermer (risikoreduksjon uten visuell testing,
og TeeCups stat_level-modell gjør en så fin oppdeling mindre naturlig).
Alle spillerne vises som en fast, ikke-trykkbar kontekst-rad øverst i
veiviseren (aktiv fremhevet) -- speiler konkurrentappens "mist aldri
oversikten"-prinsipp. "Forrige"/"Neste" beveger seg gjennom stegene;
siste steg for siste spiller blir "Ferdig" (lukker + går til neste hull,
starter på spiller 1 igjen), ellers "Neste: {navn}" (bytter spiller i
SAMME veiviser, nullstiller til steg 1).
**Hovedsiden forenklet radikalt:** den gamle Slag/Putter/"flere
detaljer"-inline-blokken er FJERNET -- erstattet med én kompakt liste,
ett kort per spiller: navn, "HCP X · {til-par så langt} ({N} hull)", og
en stor rund knapp som viser gjeldende hulls slagtall (eller "") og
åpner veiviseren ved trykk. `NumberPicker`s `showGolfTerms`-funksjon fra
forrige runde gjenbrukes uendret inni veiviserens Slag-steg (det arbeidet
var ikke bortkastet).
**Verifisert:** stegmaskinen (steg-antall per stat_level, fremover/
bakover-navigasjon, disabled-gating per steg, "avbryt på steg 1 lukker",
"siste steg for siste spiller fullfører") og akkumulert-score-
beregningen UAVHENGIG simulert i Node (16/16). Et ekte API-rundtur-
script som sender NØYAKTIG samme felt-kombinasjon som veiviseren ville
sendt (fullt detalj-steg for en "full"-spiller, kun slag for en
"strokes_only"-gjest) bekreftet begge lagres korrekt. Full regresjon
(35-punkts co-player-testsuite) fortsatt grønn, `test_isolation.sql`
12/12 (ingen migrasjon). Ekte typesjekket produksjonsbuild kompilerte
rent, samme engangs `next dev`-container-sjekk ga 200.
**Samme ærlige begrensning som alle håndkodede runder denne uken:** ingen
ekte nettleser-interaksjonstest -- gitt at FORRIGE runde ble avvist
nettopp fordi den så gal ut i praksis til tross for at logikken var
korrekt, er dette IKKE en ubetydelig forbehold denne gangen. Bruker bør
teste grundig før tillit.
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
ingen migrasjon, `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket.
Neste steg: Neste steg:
0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet 0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet

View file

@ -113,16 +113,21 @@ function statKey(playerId: string, holeNumber: number) {
return `${playerId}:${holeNumber}` return `${playerId}:${holeNumber}`
} }
// "Er dette godt nok registrert til å gå videre til neste spiller?" // Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som
// (2026-07-26, inspirert av en konkurrentapps samlebånd-flyt: én spiller // round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for
// om gangen, automatisk videre uten å måtte navigere manuelt tilbake til // akkumulert score-så-langt per spiller i den nye scoringsflyten).
// spillerlisten). Krever KUN det stat_level faktisk gjør obligatorisk -- function formatToPar(value: number): string {
// "full"-nivåets ekstra detaljer (kølle/retning/chip osv.) er fortsatt if (value === 0) return "E"
// valgfrie og blokkerer ALDRI videre fremdrift, samme som før. return value > 0 ? `+${value}` : `${Math.abs(value)}`
function isEntryComplete(stat: HoleStat, statLevel: StatLevel): boolean { }
if (stat.strokes === null) return false
if (statLevel === "strokes_only") return true function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | null {
return stat.putts !== null if (!holes) return null
const played = holes.filter((h) => h.played && h.score !== null)
if (played.length === 0) return null
const score = played.reduce((sum, h) => sum + (h.score as number), 0)
const par = played.reduce((sum, h) => sum + h.par, 0)
return { toPar: score - par, holesPlayed: played.length }
} }
function apiGenderToUi(g: ApiGender): Gender { function apiGenderToUi(g: ApiGender): Gender {
@ -296,11 +301,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan // panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan
// derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere). // derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere).
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null) const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
// "Flere detaljer" er kollapset som default (etterspurt av bruker // Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye // HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for
// stablet oppå hverandre for et enkelt slag-registrering) slik at en // aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt).
// rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt. const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger // Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet // NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i // helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
@ -357,36 +361,40 @@ export function RoundDetail({ roundId }: { roundId: string }) {
[roundId], [roundId],
) )
// Laster hull for ALLE deltakere (2026-07-26, ikke bare aktiv spiller) --
// den nye scoringslisten viser akkumulert score-så-langt for HVER spiller
// samtidig, ikke kun den ene som tilfeldigvis er "aktiv".
useEffect(() => { useEffect(() => {
if (activePlayerId && !holesByParticipant[activePlayerId]) { if (!round) return
void loadHoles(activePlayerId) for (const p of round.participants) {
if (!holesByParticipant[p.id]) {
void loadHoles(p.id)
}
} }
}, [activePlayerId, holesByParticipant, loadHoles]) }, [round, holesByParticipant, loadHoles])
// Alltid gjeldende activePlayerId tilgjengelig INNI WebSocket-handleren // Alltid gjeldende deltaker-id-er tilgjengelig INNI WebSocket-handleren
// under, uten å måtte koble socket-en til/fra ved hvert fanebytte (som en // under, uten å måtte koble socket-en til/fra ved hver ny runde-lasting
// vanlig closure over `activePlayerId` i effektens dependency-array ville // (en vanlig closure over `round` i effektens dependency-array ville
// krevd -- ville ødelagt WS-tilkoblingen hver gang brukeren bytter fane). // krevd det).
const activePlayerIdRef = useRef<string | null>(null) const participantIdsRef = useRef<string[]>([])
useEffect(() => { useEffect(() => {
activePlayerIdRef.current = activePlayerId participantIdsRef.current = round?.participants.map((p) => p.id) ?? []
}, [activePlayerId]) }, [round])
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"- // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
// signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST- // signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST-
// kallene den uansett gjør ved førstegangslasting. Aktiv spillers hull // kallene den uansett gjør ved førstegangslasting. Henter nå ALLE
// hentes på nytt DIREKTE (overskriver når svaret kommer, ingen mellomsteg // deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten
// med tom stat som ville blinket til for spilleren som selv nettopp // viser alles akkumulerte score-så-langt samtidig.
// registrerte et slag) -- de ANDRE spillernes cache droppes i stedet, slik
// at et senere fanebytte henter friskt fremfor en stale verdi.
useEffect(() => { useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
socket.onmessage = () => { socket.onmessage = () => {
void loadRound() void loadRound()
const active = activePlayerIdRef.current for (const id of participantIdsRef.current) {
setHolesByParticipant((prev) => (active && prev[active] ? { [active]: prev[active] } : {})) void loadHoles(id)
if (active) void loadHoles(active) }
} }
return () => socket.close() return () => socket.close()
}, [roundId, loadRound, loadHoles]) }, [roundId, loadRound, loadHoles])
@ -446,19 +454,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
hole !== null && hole !== null &&
currentStat.strokes - currentStat.putts <= hole.par - 2 currentStat.strokes - currentStat.putts <= hole.par - 2
// Samlebånd-fremdrift (2026-07-26): når aktiv spiller er ferdig registrert // Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat
// for hullet, gå til NESTE spiller i rekkefølgen -- er hen den siste, // nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin
// gå til neste hull og start på spiller 1 igjen. Fjerner behovet for å // "Så langt i runden"-oversikt lenger ned som vises).
// manuelt skrolle opp til spillerlisten og velge neste spiller for hånd. const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null
const activePlayerIndex = activePlayer ? players.findIndex((p) => p.id === activePlayer.id) : -1 const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null
const isLastPlayer = activePlayerIndex === -1 || activePlayerIndex === players.length - 1 const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat()
const nextPlayer = !isLastPlayer ? players[activePlayerIndex + 1] : null
const canAdvance = !readOnly && activePlayer !== null && isEntryComplete(currentStat, activePlayer.statLevel)
async function updateStat(patch: Partial<HoleStat>) { async function updateWizardStat(patch: Partial<HoleStat>) {
if (readOnly || !activePlayerId || !currentApiHole) return if (readOnly || !wizardPlayerId) return
const merged: HoleStat = { ...currentStat, ...patch } const merged: HoleStat = { ...wizardStat, ...patch }
const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, { const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
@ -468,7 +474,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const updated: ApiHole = await res.json() const updated: ApiHole = await res.json()
setHolesByParticipant((prev) => ({ setHolesByParticipant((prev) => ({
...prev, ...prev,
[activePlayerId]: (prev[activePlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), [wizardPlayerId]: (prev[wizardPlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
})) }))
} }
@ -487,11 +493,19 @@ export function RoundDetail({ roundId }: { roundId: string }) {
scrollToHolePanel() scrollToHolePanel()
} }
function advanceToNextPlayerOrHole() { // Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
if (nextPlayer) { // siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
setActivePlayerId(nextPlayer.id) // veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
scrollToHolePanel() // lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
function advanceWizardPlayer() {
const idx = players.findIndex((p) => p.id === wizardPlayerId)
const isLast = idx === -1 || idx === players.length - 1
if (!isLast) {
const next = players[idx + 1]
setActivePlayerId(next.id)
setWizardPlayerId(next.id)
} else { } else {
setWizardPlayerId(null)
setActivePlayerId(players[0]?.id ?? null) setActivePlayerId(players[0]?.id ?? null)
goNext() goNext()
} }
@ -836,186 +850,77 @@ export function RoundDetail({ roundId }: { roundId: string }) {
</div> </div>
</div> </div>
{/* Slag/Putter og (ved statLevel="full") alle detaljene vises {/* Registrering (2026-07-26, inspirert av en konkurrentapps
ALLTID samlet under hverandre -- ikke bak en fane man samlebåndsflyt): én kompakt rad per spiller med
oppdage og trykke (funnet 2026-07-25: brukeren huket akkumulert score--langt for RUNDEN alltid synlig, og en
av "All statistikk", men fikk aldri se noe utover trykkflate som åpner en fokusert, skjermovertagende
slag/putter siden det bak en fane). */} veiviser for AKKURAT denne spilleren og hullet -- i
<div className="flex flex-col gap-6"> stedet for ett langt skjema man skrollet gjennom for én
<NumberPicker spiller om gangen, med manuell fanebytting mellom
label="Slag" spillere (rapportert av bruker 2026-07-26 som "ingen
value={currentStat.strokes} forbedring", "visuelt like overveldende og rotete"). */}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]} <ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border">
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]} {players.map((player) => {
expandLabel="10+" const playerHoles = holesByParticipant[player.id]
parValue={hole.par} const playerApiHole = playerHoles?.find((h) => h.hole_number === activeHole) ?? null
showGolfTerms const hasScore = Boolean(playerApiHole?.played) && playerApiHole?.score !== null
onChange={(v) => updateStat({ strokes: v, played: true })} const running = computeRunningToPar(playerHoles)
readOnly={readOnly} return (
/> <li key={player.id} className="flex items-center gap-3 p-3 sm:p-4">
<span className="flex min-w-0 flex-1 flex-col">
{activePlayer.statLevel !== "strokes_only" && ( <span className="flex flex-wrap items-center gap-1.5">
<NumberPicker <span className="truncate text-base font-bold text-foreground">{player.name}</span>
label="Putter" {player.isSelf && (
value={currentStat.putts} <Badge variant="default" className="shrink-0">
directValues={[0, 1, 2, 3, 4, 5, 6]} Deg
expandValues={[7, 8, 9, 10]} </Badge>
expandLabel="7+" )}
maxValue={currentStat.strokes ?? undefined} </span>
onChange={(v) => updateStat({ putts: v })} <span className="text-sm text-muted-foreground">
readOnly={readOnly} HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
/> {running && (
)} <>
{" · "}
{activePlayer.statLevel === "full" && ( {formatToPar(running.toPar)} langt ({running.holesPlayed} hull)
<> </>
{/* Rett under antall putter (etterspurt av bruker 2026-07-25) -- )}
hører naturlig sammen med Putter-feltet rett over, ikke med </span>
kølle/retning/chip-gruppen lenger ned. */} </span>
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={currentStat.firstPuttBucket}
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
{/* Alt annet lever bak én kollapsbar seksjon, lukket som
default -- panelet forblir kort for det vanlige
slag-/putt-registreringstilfellet. */}
<div className="overflow-hidden rounded-2xl border border-border">
<button <button
type="button" type="button"
onClick={() => setDetailsOpen((v) => !v)} disabled={readOnly}
aria-expanded={detailsOpen} onClick={() => {
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-bold text-foreground transition-colors hover:bg-accent/50" setActivePlayerId(player.id)
setWizardPlayerId(player.id)
}}
aria-label={
hasScore
? `Rediger ${player.name} sin registrering på hull ${activeHole}, ${playerApiHole?.score} slag`
: `Registrer score for ${player.name} på hull ${activeHole}`
}
className={cn(
"flex size-14 shrink-0 items-center justify-center rounded-full border-2 text-xl font-extrabold tabular-nums transition-colors disabled:opacity-60",
hasScore
? "border-primary bg-primary text-primary-foreground"
: "border-dashed border-border text-muted-foreground hover:border-primary/60 hover:text-primary",
)}
> >
Flere detaljer {hasScore ? playerApiHole?.score : ""}
<ChevronDown
aria-hidden="true"
className={cn(
"size-5 shrink-0 text-muted-foreground transition-transform",
detailsOpen && "rotate-180",
)}
/>
</button> </button>
{detailsOpen && ( </li>
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5"> )
<div className="flex flex-col gap-2"> })}
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label> </ul>
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = currentStat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => updateStat({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
id="club"
value={currentStat.club}
onChange={(e) => updateStat({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && ( <div className="grid grid-cols-2 gap-3">
<DirectionCross <Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
label="Utslag" <ChevronLeft aria-hidden="true" className="size-5" />
variant="horizontal" Forrige hull
centerLabel="Fairway" </Button>
value={currentStat.teeShot} <Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
onChange={(v) => updateStat({ teeShot: v as TeeShot })} Neste hull
readOnly={readOnly} <ChevronRight aria-hidden="true" className="size-5" />
/> </Button>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
value={currentStat.approach}
onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
</div>
<NumberPicker
label="Anywayslag"
value={currentStat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ anywayStrokes: v })}
readOnly={readOnly}
/>
</div>
)}
</div>
</>
)}
</div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige hull
</Button>
{/* Samlebånd-knapp (2026-07-26): går til NESTE SPILLER på
samme hull, eller -- er aktiv spiller den siste --
videre til neste hull og starter spiller 1 igjen.
Erstatter den manuelle "skroll opp, velg neste
spiller"-runden man tidligere måtte gjøre for hånd. */}
<Button
type="button"
disabled={!canAdvance}
onClick={advanceToNextPlayerOrHole}
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
>
{isLastPlayer ? "Neste hull" : `Neste: ${nextPlayer?.name}`}
<ChevronRight aria-hidden="true" className="size-5" />
</Button>
</div>
{!readOnly && !canAdvance && (
<p className="text-center text-sm text-muted-foreground">
{activePlayer?.statLevel === "strokes_only"
? "Registrer slag for å gå videre."
: "Registrer slag og putter for å gå videre."}
</p>
)}
</div> </div>
</section> </section>
)} )}
@ -1023,6 +928,280 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)} )}
</main> </main>
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
{wizardPlayer && hole && (
<ScoringWizard
players={players}
player={wizardPlayer}
hole={hole}
stat={wizardStat}
onUpdate={updateWizardStat}
onClose={() => setWizardPlayerId(null)}
onAdvance={advanceWizardPlayer}
readOnly={readOnly}
ownBagClubs={ownBagClubs}
/>
)}
</div>
)
}
// --- Scoringsveiviser (2026-07-26) ------------------------------------------
// Skjermovertagende, ett-spiller-om-gangen registreringsflyt -- inspirert av
// en konkurrentapps samlebåndsflyt (video studert bilde for bilde), men
// BEVISST forenklet til TeeCups eget stat_level-drevne steg-antall i stedet
// for å kopiere konkurrentens nøyaktige skjermrekkefølge/design:
// - "strokes_only": ett steg (Slag)
// - "strokes_and_putts": to steg (Slag, Putter+avstand første putt)
// - "full": tre steg (Slag, Putter+avstand første putt, resten samlet)
// "Neste"/"Forrige" beveger seg gjennom stegene for ÉN spiller; på siste
// steg blir knappen "Neste: {navn}" (bytter til neste spiller, nullstiller
// til steg 1) eller "Ferdig" (er spilleren den siste, lukker veiviseren og
// varsler forelderen om å gå videre til neste hull).
type WizardStep = "strokes" | "putts" | "details"
function wizardStepsFor(statLevel: StatLevel): WizardStep[] {
if (statLevel === "strokes_only") return ["strokes"]
if (statLevel === "strokes_and_putts") return ["strokes", "putts"]
return ["strokes", "putts", "details"]
}
function ScoringWizard({
players,
player,
hole,
stat,
onUpdate,
onClose,
onAdvance,
readOnly,
ownBagClubs,
}: {
players: Player[]
player: Player
hole: Hole
stat: HoleStat
onUpdate: (patch: Partial<HoleStat>) => void
onClose: () => void
onAdvance: () => void
readOnly: boolean
ownBagClubs: string[]
}) {
const playerIndex = players.findIndex((p) => p.id === player.id)
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
const steps = wizardStepsFor(player.statLevel)
const [stepIndex, setStepIndex] = useState(0)
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
// uten dette ville "Neste: {navn}" hoppet rett til der FORRIGE spiller
// slapp, i stedet for å starte på Slag igjen for den nye spilleren.
useEffect(() => {
setStepIndex(0)
}, [player.id])
const step = steps[Math.min(stepIndex, steps.length - 1)]
const isFirstStep = stepIndex === 0
const isLastStep = stepIndex === steps.length - 1
const canProceed = step === "strokes" ? stat.strokes !== null : step === "putts" ? stat.putts !== null : true
function back() {
if (isFirstStep) {
onClose()
return
}
setStepIndex((i) => i - 1)
}
function forward() {
if (!isLastStep) {
setStepIndex((i) => i + 1)
return
}
onAdvance()
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
<button
type="button"
onClick={onClose}
aria-label="Lukk veiviseren"
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
>
<X aria-hidden="true" className="size-5" />
</button>
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
<span className="truncate text-base font-extrabold text-foreground">
Hull {hole.holeNumber} · Par {hole.par}
</span>
<span className="truncate text-sm font-semibold text-muted-foreground">{player.name}</span>
</div>
<div className="size-11 shrink-0" aria-hidden="true" />
</header>
{/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som
konkurrentappen: man mister aldri oversikten over hvem som allerede
er ferdig hullet. Ren informasjon, ikke en snarvei -- bytte
spiller midt i et steg ville latt uferdige felt henge løst. */}
<div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
{players.map((p) => (
<span
key={p.id}
className={cn(
"flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-bold",
p.id === player.id
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground",
)}
>
{p.name}
</span>
))}
</div>
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex max-w-sm flex-col gap-6">
{step === "strokes" && (
<NumberPicker
label="Slag"
value={stat.strokes}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+"
parValue={hole.par}
showGolfTerms
onChange={(v) => onUpdate({ strokes: v, played: true })}
readOnly={readOnly}
/>
)}
{step === "putts" && (
<>
<NumberPicker
label="Putter"
value={stat.putts}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={stat.strokes ?? undefined}
onChange={(v) => onUpdate({ putts: v })}
readOnly={readOnly}
/>
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={stat.firstPuttBucket}
onChange={(v) => onUpdate({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
</>
)}
{step === "details" && (
<>
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{player.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = stat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onUpdate({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={stat.club}
onChange={(e) => onUpdate({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={stat.teeShot}
onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
value={stat.approach}
onChange={(v) => onUpdate({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stepper label="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
<Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
<Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
</div>
<NumberPicker
label="Anywayslag"
value={stat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={stat.strokes ?? undefined}
onChange={(v) => onUpdate({ anywayStrokes: v })}
readOnly={readOnly}
/>
</>
)}
</div>
</main>
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
<Button type="button" variant="outline" onClick={back} className="h-14 rounded-2xl text-base font-bold">
{isFirstStep ? "Avbryt" : "Forrige"}
</Button>
<Button
type="button"
disabled={!canProceed}
onClick={forward}
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
>
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
</Button>
</footer>
</div> </div>
) )
} }