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:
parent
e2ea0c0fe3
commit
9ec6f1c9ed
3 changed files with 461 additions and 226 deletions
|
|
@ -78,7 +78,10 @@
|
|||
"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(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": [
|
||||
"/opt/teecup/frontend/app/my-rounds/[id]"
|
||||
|
|
|
|||
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -3412,6 +3412,59 @@ Ferdig og verifisert:
|
|||
ingen migrasjon, `docker compose up -d --build teecup_api
|
||||
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
||||
→ 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:
|
||||
0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet
|
||||
|
|
|
|||
|
|
@ -113,16 +113,21 @@ function statKey(playerId: string, holeNumber: number) {
|
|||
return `${playerId}:${holeNumber}`
|
||||
}
|
||||
|
||||
// "Er dette godt nok registrert til å gå videre til neste spiller?"
|
||||
// (2026-07-26, inspirert av en konkurrentapps samlebånd-flyt: én spiller
|
||||
// om gangen, automatisk videre uten å måtte navigere manuelt tilbake til
|
||||
// spillerlisten). Krever KUN det stat_level faktisk gjør obligatorisk --
|
||||
// "full"-nivåets ekstra detaljer (kølle/retning/chip osv.) er fortsatt
|
||||
// valgfrie og blokkerer ALDRI videre fremdrift, samme som før.
|
||||
function isEntryComplete(stat: HoleStat, statLevel: StatLevel): boolean {
|
||||
if (stat.strokes === null) return false
|
||||
if (statLevel === "strokes_only") return true
|
||||
return stat.putts !== null
|
||||
// Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som
|
||||
// round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for
|
||||
// akkumulert score-så-langt per spiller i den nye scoringsflyten).
|
||||
function formatToPar(value: number): string {
|
||||
if (value === 0) return "E"
|
||||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||||
}
|
||||
|
||||
function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | 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 {
|
||||
|
|
@ -296,11 +301,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
// panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan
|
||||
// derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere).
|
||||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
|
||||
// "Flere detaljer" er kollapset som default (etterspurt av bruker
|
||||
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
|
||||
// stablet oppå hverandre for et enkelt slag-registrering) slik at en
|
||||
// rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt.
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
// Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT
|
||||
// HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for
|
||||
// aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt).
|
||||
const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
|
||||
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
|
||||
// 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
|
||||
|
|
@ -357,36 +361,40 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
[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(() => {
|
||||
if (activePlayerId && !holesByParticipant[activePlayerId]) {
|
||||
void loadHoles(activePlayerId)
|
||||
if (!round) return
|
||||
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
|
||||
// under, uten å måtte koble socket-en til/fra ved hvert fanebytte (som en
|
||||
// vanlig closure over `activePlayerId` i effektens dependency-array ville
|
||||
// krevd -- ville ødelagt WS-tilkoblingen hver gang brukeren bytter fane).
|
||||
const activePlayerIdRef = useRef<string | null>(null)
|
||||
// Alltid gjeldende deltaker-id-er tilgjengelig INNI WebSocket-handleren
|
||||
// under, uten å måtte koble socket-en til/fra ved hver ny runde-lasting
|
||||
// (en vanlig closure over `round` i effektens dependency-array ville
|
||||
// krevd det).
|
||||
const participantIdsRef = useRef<string[]>([])
|
||||
useEffect(() => {
|
||||
activePlayerIdRef.current = activePlayerId
|
||||
}, [activePlayerId])
|
||||
participantIdsRef.current = round?.participants.map((p) => p.id) ?? []
|
||||
}, [round])
|
||||
|
||||
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
|
||||
// signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST-
|
||||
// kallene den uansett gjør ved førstegangslasting. Aktiv spillers hull
|
||||
// hentes på nytt DIREKTE (overskriver når svaret kommer, ingen mellomsteg
|
||||
// med tom stat som ville blinket til for spilleren som selv nettopp
|
||||
// registrerte et slag) -- de ANDRE spillernes cache droppes i stedet, slik
|
||||
// at et senere fanebytte henter friskt fremfor en stale verdi.
|
||||
// kallene den uansett gjør ved førstegangslasting. Henter nå ALLE
|
||||
// deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten
|
||||
// viser alles akkumulerte score-så-langt samtidig.
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
||||
socket.onmessage = () => {
|
||||
void loadRound()
|
||||
const active = activePlayerIdRef.current
|
||||
setHolesByParticipant((prev) => (active && prev[active] ? { [active]: prev[active] } : {}))
|
||||
if (active) void loadHoles(active)
|
||||
for (const id of participantIdsRef.current) {
|
||||
void loadHoles(id)
|
||||
}
|
||||
}
|
||||
return () => socket.close()
|
||||
}, [roundId, loadRound, loadHoles])
|
||||
|
|
@ -446,19 +454,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
hole !== null &&
|
||||
currentStat.strokes - currentStat.putts <= hole.par - 2
|
||||
|
||||
// Samlebånd-fremdrift (2026-07-26): når aktiv spiller er ferdig registrert
|
||||
// for hullet, gå til NESTE spiller i rekkefølgen -- er hen den siste,
|
||||
// gå til neste hull og start på spiller 1 igjen. Fjerner behovet for å
|
||||
// manuelt skrolle opp til spillerlisten og velge neste spiller for hånd.
|
||||
const activePlayerIndex = activePlayer ? players.findIndex((p) => p.id === activePlayer.id) : -1
|
||||
const isLastPlayer = activePlayerIndex === -1 || activePlayerIndex === players.length - 1
|
||||
const nextPlayer = !isLastPlayer ? players[activePlayerIndex + 1] : null
|
||||
const canAdvance = !readOnly && activePlayer !== null && isEntryComplete(currentStat, activePlayer.statLevel)
|
||||
// Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat
|
||||
// nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin
|
||||
// "Så langt i runden"-oversikt lenger ned som vises).
|
||||
const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null
|
||||
const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null
|
||||
const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat()
|
||||
|
||||
async function updateStat(patch: Partial<HoleStat>) {
|
||||
if (readOnly || !activePlayerId || !currentApiHole) return
|
||||
const merged: HoleStat = { ...currentStat, ...patch }
|
||||
const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, {
|
||||
async function updateWizardStat(patch: Partial<HoleStat>) {
|
||||
if (readOnly || !wizardPlayerId) return
|
||||
const merged: HoleStat = { ...wizardStat, ...patch }
|
||||
const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
|
@ -468,7 +474,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
const updated: ApiHole = await res.json()
|
||||
setHolesByParticipant((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()
|
||||
}
|
||||
|
||||
function advanceToNextPlayerOrHole() {
|
||||
if (nextPlayer) {
|
||||
setActivePlayerId(nextPlayer.id)
|
||||
scrollToHolePanel()
|
||||
// Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
|
||||
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
||||
// 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 {
|
||||
setWizardPlayerId(null)
|
||||
setActivePlayerId(players[0]?.id ?? null)
|
||||
goNext()
|
||||
}
|
||||
|
|
@ -836,42 +850,249 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slag/Putter og (ved statLevel="full") alle detaljene vises
|
||||
ALLTID samlet under hverandre -- ikke bak en fane man må
|
||||
oppdage og trykke på (funnet 2026-07-25: brukeren huket
|
||||
av "All statistikk", men fikk aldri se noe utover
|
||||
slag/putter siden det lå bak en fane). */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Registrering (2026-07-26, inspirert av en konkurrentapps
|
||||
samlebåndsflyt): én kompakt rad per spiller med
|
||||
akkumulert score-så-langt for RUNDEN alltid synlig, og en
|
||||
trykkflate som åpner en fokusert, skjermovertagende
|
||||
veiviser for AKKURAT denne spilleren og hullet -- i
|
||||
stedet for ett langt skjema man skrollet gjennom for én
|
||||
spiller om gangen, med manuell fanebytting mellom
|
||||
spillere (rapportert av bruker 2026-07-26 som "ingen
|
||||
forbedring", "visuelt like overveldende og rotete"). */}
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||||
{players.map((player) => {
|
||||
const playerHoles = holesByParticipant[player.id]
|
||||
const playerApiHole = playerHoles?.find((h) => h.hole_number === activeHole) ?? null
|
||||
const hasScore = Boolean(playerApiHole?.played) && playerApiHole?.score !== null
|
||||
const running = computeRunningToPar(playerHoles)
|
||||
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">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
|
||||
{player.isSelf && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
Deg
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
|
||||
{running && (
|
||||
<>
|
||||
{" · "}
|
||||
{formatToPar(running.toPar)} så langt ({running.holesPlayed} hull)
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{hasScore ? playerApiHole?.score : "–"}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
|
||||
Neste hull
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</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 på 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={currentStat.strokes}
|
||||
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) => updateStat({ strokes: v, played: true })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{activePlayer.statLevel !== "strokes_only" && (
|
||||
<NumberPicker
|
||||
label="Putter"
|
||||
value={currentStat.putts}
|
||||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||||
expandValues={[7, 8, 9, 10]}
|
||||
expandLabel="7+"
|
||||
maxValue={currentStat.strokes ?? undefined}
|
||||
onChange={(v) => updateStat({ putts: v })}
|
||||
onChange={(v) => onUpdate({ strokes: v, played: true })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activePlayer.statLevel === "full" && (
|
||||
{step === "putts" && (
|
||||
<>
|
||||
{/* Rett under antall putter (etterspurt av bruker 2026-07-25) --
|
||||
hører naturlig sammen med Putter-feltet rett over, ikke med
|
||||
kølle/retning/chip-gruppen lenger ned. */}
|
||||
<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={[
|
||||
|
|
@ -882,44 +1103,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
{ value: "<8m", label: "<8m" },
|
||||
{ value: "8m+", label: "8m+" },
|
||||
]}
|
||||
value={currentStat.firstPuttBucket}
|
||||
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
|
||||
value={stat.firstPuttBucket}
|
||||
onChange={(v) => onUpdate({ 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
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((v) => !v)}
|
||||
aria-expanded={detailsOpen}
|
||||
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"
|
||||
>
|
||||
Flere detaljer
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-5 shrink-0 text-muted-foreground transition-transform",
|
||||
detailsOpen && "rotate-180",
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{detailsOpen && (
|
||||
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
|
||||
|
||||
{step === "details" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
|
||||
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
|
||||
{player.isSelf && ownBagClubs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ownBagClubs.map((club) => {
|
||||
const selected = currentStat.club === club
|
||||
const selected = stat.club === club
|
||||
return (
|
||||
<button
|
||||
key={club}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => updateStat({ club })}
|
||||
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",
|
||||
|
|
@ -935,9 +1139,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="club"
|
||||
value={currentStat.club}
|
||||
onChange={(e) => updateStat({ club: e.target.value })}
|
||||
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"
|
||||
|
|
@ -950,8 +1153,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
label="Utslag"
|
||||
variant="horizontal"
|
||||
centerLabel="Fairway"
|
||||
value={currentStat.teeShot}
|
||||
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
|
||||
value={stat.teeShot}
|
||||
onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -960,69 +1163,45 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
label="Innspill"
|
||||
variant="full"
|
||||
centerLabel="Traff"
|
||||
value={currentStat.approach}
|
||||
onChange={(v) => updateStat({ approach: v as Approach })}
|
||||
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={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} />
|
||||
<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={currentStat.anywayStrokes}
|
||||
value={stat.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 })}
|
||||
maxValue={stat.strokes ?? undefined}
|
||||
onChange={(v) => onUpdate({ anywayStrokes: v })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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
|
||||
<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>
|
||||
{/* 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 på 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}
|
||||
disabled={!canProceed}
|
||||
onClick={forward}
|
||||
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" />
|
||||
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue