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(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]"
|
||||||
|
|
|
||||||
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,42 +850,249 @@ 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 må
|
samlebåndsflyt): én kompakt rad per spiller med
|
||||||
oppdage og trykke på (funnet 2026-07-25: brukeren huket
|
akkumulert score-så-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 lå 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
|
||||||
|
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
|
<NumberPicker
|
||||||
label="Slag"
|
label="Slag"
|
||||||
value={currentStat.strokes}
|
value={stat.strokes}
|
||||||
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||||||
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
|
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
|
||||||
expandLabel="10+"
|
expandLabel="10+"
|
||||||
parValue={hole.par}
|
parValue={hole.par}
|
||||||
showGolfTerms
|
showGolfTerms
|
||||||
onChange={(v) => updateStat({ strokes: v, played: true })}
|
onChange={(v) => onUpdate({ 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 })}
|
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activePlayer.statLevel === "full" && (
|
{step === "putts" && (
|
||||||
<>
|
<>
|
||||||
{/* Rett under antall putter (etterspurt av bruker 2026-07-25) --
|
<NumberPicker
|
||||||
hører naturlig sammen med Putter-feltet rett over, ikke med
|
label="Putter"
|
||||||
kølle/retning/chip-gruppen lenger ned. */}
|
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
|
<ChoiceRow
|
||||||
label="Avstand første putt"
|
label="Avstand første putt"
|
||||||
options={[
|
options={[
|
||||||
|
|
@ -882,44 +1103,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
{ value: "<8m", label: "<8m" },
|
{ value: "<8m", label: "<8m" },
|
||||||
{ value: "8m+", label: "8m+" },
|
{ value: "8m+", label: "8m+" },
|
||||||
]}
|
]}
|
||||||
value={currentStat.firstPuttBucket}
|
value={stat.firstPuttBucket}
|
||||||
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
|
onChange={(v) => onUpdate({ firstPuttBucket: v as PuttBucket })}
|
||||||
readOnly={readOnly}
|
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>
|
{step === "details" && (
|
||||||
{detailsOpen && (
|
<>
|
||||||
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ownBagClubs.map((club) => {
|
{ownBagClubs.map((club) => {
|
||||||
const selected = currentStat.club === club
|
const selected = stat.club === club
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={club}
|
key={club}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onClick={() => updateStat({ club })}
|
onClick={() => onUpdate({ club })}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
|
"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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id="club"
|
value={stat.club}
|
||||||
value={currentStat.club}
|
onChange={(e) => onUpdate({ club: e.target.value })}
|
||||||
onChange={(e) => updateStat({ club: e.target.value })}
|
|
||||||
placeholder="F.eks. Driver, 3-jern"
|
placeholder="F.eks. Driver, 3-jern"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className="h-12 rounded-2xl text-base"
|
className="h-12 rounded-2xl text-base"
|
||||||
|
|
@ -950,8 +1153,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
label="Utslag"
|
label="Utslag"
|
||||||
variant="horizontal"
|
variant="horizontal"
|
||||||
centerLabel="Fairway"
|
centerLabel="Fairway"
|
||||||
value={currentStat.teeShot}
|
value={stat.teeShot}
|
||||||
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
|
onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -960,69 +1163,45 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
label="Innspill"
|
label="Innspill"
|
||||||
variant="full"
|
variant="full"
|
||||||
centerLabel="Traff"
|
centerLabel="Traff"
|
||||||
value={currentStat.approach}
|
value={stat.approach}
|
||||||
onChange={(v) => updateStat({ approach: v as Approach })}
|
onChange={(v) => onUpdate({ approach: v as Approach })}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<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="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||||||
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
|
<Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||||||
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
|
<Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NumberPicker
|
<NumberPicker
|
||||||
label="Anywayslag"
|
label="Anywayslag"
|
||||||
value={currentStat.anywayStrokes}
|
value={stat.anywayStrokes}
|
||||||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||||||
expandValues={[7, 8, 9, 10]}
|
expandValues={[7, 8, 9, 10]}
|
||||||
expandLabel="7+"
|
expandLabel="7+"
|
||||||
maxValue={currentStat.strokes ?? undefined}
|
maxValue={stat.strokes ?? undefined}
|
||||||
onChange={(v) => updateStat({ anywayStrokes: v })}
|
onChange={(v) => onUpdate({ anywayStrokes: v })}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Button type="button" variant="outline" onClick={back} className="h-14 rounded-2xl text-base font-bold">
|
||||||
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
|
{isFirstStep ? "Avbryt" : "Forrige"}
|
||||||
<ChevronLeft aria-hidden="true" className="size-5" />
|
|
||||||
Forrige hull
|
|
||||||
</Button>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canAdvance}
|
disabled={!canProceed}
|
||||||
onClick={advanceToNextPlayerOrHole}
|
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"
|
||||||
>
|
>
|
||||||
{isLastPlayer ? "Neste hull" : `Neste: ${nextPlayer?.name}`}
|
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
||||||
<ChevronRight aria-hidden="true" className="size-5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</footer>
|
||||||
{!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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue