Hull-valget overlever nå en refresh (?hole=-URL-parameter)

currentHole levde kun i React-state, seedet fra round.start_hole ved
hver mount -- en refresh mistet valget og hoppet alltid tilbake til
starthullet. Fikset med samme URL-parameter-mønster tab-valget
allerede bruker: ny setHoleAndUrl() holder state og ?hole=N i sync
via router.replace (ikke push, for å unngå å fylle historikken med
ett tilbake-steg per hull).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-18 10:06:48 +02:00
parent f5c79e8767
commit 0a9320991b
2 changed files with 72 additions and 10 deletions

View file

@ -12305,3 +12305,31 @@ Neste steg:
frittstående runder, én for org-turneringer) -- verdt å huske ved frittstående runder, én for org-turneringer) -- verdt å huske ved
fremtidige datakorreksjoner, for denne eller andre baner med fremtidige datakorreksjoner, for denne eller andre baner med
begge tilkoblingstyper. begge tilkoblingstyper.
108. **Hull-valget overlevde ikke en refresh — 2026-08-18.** Bruker
rapporterte at en refresh mens man sto på f.eks. hull 7 alltid
hoppet tilbake til starthullet. Årsak: `currentHole` i
`round-detail.tsx` levde KUN i React-state (seedet fra
`round.start_hole` ved hver mount, se den eksisterende `null`- vs
`1`-forklaringen fra 2026-07-24) -- aldri i URL-en, så en full
remount (refresh) mistet valget fullstendig.
Fikset ved å gjenbruke det samme `?param`-i-URL-mønsteret siden
allerede har for fane-valget (`?tab=manage`): ny `?hole=N`-
parameter, lest ved mount (`initialHole`), holdt i sync ved
senere navigasjon via en ny felles `setHoleAndUrl()`-funksjon
(erstatter ALLE direkte `setCurrentHole`-kall utenom selve
URL-seedingen i `loadRound`) som oppdaterer BÅDE state og URL med
`router.replace` (ikke `push` -- hull-bytte skal ikke fylle
historikken med ett tilbake-steg per hull). En egen `useEffect`
speiler `?hole=`-parameteren tilbake inn i state ved eksterne
URL-endringer (nettleserens frem/tilbake).
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Scratch-
runde (database+API+next dev): naviger til hull 7 (URL bekreftet
`?hole=7`), hard refresh (`ignoreCache`) -- siden viste fortsatt
hull 7, ikke hull 1. "Neste hull" bekreftet også oppdaterer URL-en
(`?hole=8`). Scratch-stacken revet ned.
**Ingen migrasjon** -- ren frontend-endring
(`frontend/components/round-detail.tsx`).

View file

@ -12,7 +12,7 @@ import type React from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import { useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { RoundPageShell } from "@/components/round-page-shell" import { RoundPageShell } from "@/components/round-page-shell"
import { RoundMessages } from "@/components/round-messages" import { RoundMessages } from "@/components/round-messages"
import type { FlagMapEntry } from "@/components/flag-map-overview" import type { FlagMapEntry } from "@/components/flag-map-overview"
@ -453,8 +453,18 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// med en effekt som reagerer på selve søkeparameter-ENDRINGEN, ikke // med en effekt som reagerer på selve søkeparameter-ENDRINGEN, ikke
// bare mount -- ensrettet (kun til "manage"), rører aldri brukerens // bare mount -- ensrettet (kun til "manage"), rører aldri brukerens
// egen lokale "Score"-valg via pillen etterpå. // egen lokale "Score"-valg via pillen etterpå.
const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score" const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
// Bruker rapporterte 2026-08-18: en refresh mens man sto på f.eks. hull 7
// hoppet alltid tilbake til starthullet (se `currentHole` sin seeding fra
// `data.start_hole` i loadRound under) -- hull-valget levde KUN i React-
// state, aldri i URL-en, så en full remount (refresh) mistet det
// fullstendig. Samme `?param`-i-URL-mønster som `tab` over: les ved mount,
// hold i sync ved senere endringer via `setHoleAndUrl` (kalt fra ALLE
// steder som i dag kaller `setCurrentHole` direkte).
const initialHoleParam = searchParams.get("hole")
const initialHole = initialHoleParam && /^\d+$/.test(initialHoleParam) ? Number(initialHoleParam) : null
const [round, setRound] = useState<ApiRound | null>(null) const [round, setRound] = useState<ApiRound | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// ADR-057 (2026-08-10): `error` over er en FATAL, hele-siden-erstattende // ADR-057 (2026-08-10): `error` over er en FATAL, hele-siden-erstattende
@ -483,8 +493,23 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1 // `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1
// er en gyldig, truthy hullverdi og ville gjort `prev || start_hole` // er en gyldig, truthy hullverdi og ville gjort `prev || start_hole`
// lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på // lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
// hull 1 uansett faktisk starthull). // hull 1 uansett faktisk starthull). Seedes fra `?hole=`-URL-parameteren
const [currentHole, setCurrentHole] = useState<number | null>(null) // hvis den finnes (se over) -- ellers forblir den `null` til `loadRound`
// fyller inn `data.start_hole`, akkurat som før.
const [currentHole, setCurrentHole] = useState<number | null>(initialHole)
// Eneste stedet hull-navigasjon skal skje fra -- oppdaterer BÅDE React-
// state og URL-en (`?hole=`) sammen, slik at en refresh beholder valget.
// `router.replace` (ikke `push`) -- hull-bytte skal ikke fylle historikken
// med et tilbake-steg per hull.
const setHoleAndUrl = useCallback(
(n: number) => {
setCurrentHole(n)
const params = new URLSearchParams(searchParams.toString())
params.set("hole", String(n))
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
},
[pathname, router, searchParams],
)
// ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins- // ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins-
// tavlen skjer (hull-registrering, websocket-signal), slik at // tavlen skjer (hull-registrering, websocket-signal), slik at
// formatResult-hentingen under vet den skal hente på nytt. // formatResult-hentingen under vet den skal hente på nytt.
@ -516,6 +541,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
useEffect(() => { useEffect(() => {
if (searchParams.get("tab") === "manage") setPageTab("manage") if (searchParams.get("tab") === "manage") setPageTab("manage")
}, [searchParams]) }, [searchParams])
// Fanger opp nettleserens frem/tilbake-navigasjon (URL-en endrer seg uten
// at `setHoleAndUrl` kalte den) -- speiler `?hole=`-parameteren inn i
// state igjen. Sammenligner mot gjeldende verdi først for å unngå en
// uendelig løkke med `setHoleAndUrl` sin egen `router.replace`.
useEffect(() => {
const p = searchParams.get("hole")
const n = p && /^\d+$/.test(p) ? Number(p) : null
if (n !== null) setCurrentHole((prev) => (prev === n ? prev : n))
}, [searchParams])
const [completing, setCompleting] = useState(false) const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false) const [showEditRound, setShowEditRound] = useState(false)
@ -1085,12 +1119,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
function goPrev() { function goPrev() {
const i = holeOrder.indexOf(activeHole) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length]) setHoleAndUrl(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
scrollToHolePanel() scrollToHolePanel()
} }
function goNext() { function goNext() {
const i = holeOrder.indexOf(activeHole) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % holeOrder.length]) setHoleAndUrl(holeOrder[(i + 1) % holeOrder.length])
scrollToHolePanel() scrollToHolePanel()
} }
@ -1121,7 +1155,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// ScoringWizard. Alle andre formater (ingen team-side) oppfører seg // ScoringWizard. Alle andre formater (ingen team-side) oppfører seg
// som før. // som før.
function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) { function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) {
setCurrentHole(holeNumber) setHoleAndUrl(holeNumber)
const player = players.find((p) => p.id === playerId) const player = players.find((p) => p.id === playerId)
if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) { if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) {
setWizardSideId(scrambleSoloTeamSideId) setWizardSideId(scrambleSoloTeamSideId)
@ -1729,11 +1763,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)} isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
formatResult={formatResult} formatResult={formatResult}
onSelectHole={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setHoleAndUrl(n)
scrollToHolePanel() scrollToHolePanel()
}} }}
onOpenEntry={(sideId, holeNumber) => { onOpenEntry={(sideId, holeNumber) => {
setCurrentHole(holeNumber) setHoleAndUrl(holeNumber)
setWizardSideId(sideId) setWizardSideId(sideId)
}} }}
/> />
@ -1793,7 +1827,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
formatResult={formatResult} formatResult={formatResult}
ownBagClubs={ownBagClubs} ownBagClubs={ownBagClubs}
onSelectHole={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setHoleAndUrl(n)
scrollToHolePanel() scrollToHolePanel()
}} }}
onPrevHole={goPrev} onPrevHole={goPrev}
@ -1832,7 +1866,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)} isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
formatResult={formatResult} formatResult={formatResult}
onSelectHole={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setHoleAndUrl(n)
scrollToHolePanel() scrollToHolePanel()
}} }}
onOpenEntry={openPlayerOrTeamSideEntry} onOpenEntry={openPlayerOrTeamSideEntry}