Fiks: "Fullfør runde" i header-modalen svelget feil stille og lukket seg uansett utfall

Modalen ("Administrer runde", tilgjengelig fra alle tre rundefanene) hadde en
egen, uavhengig "Fullfør runde"-vei som aldri sjekket setup_complete og
lukket dialogen umiddelbart uten å vente på fetch-svaret -- så en ufullstendig
formatoppsett (f.eks. Københavner med for få spillere) så ut til å fullføres,
men gjorde det aldri, uten noen feilmelding. Backend-valideringen var korrekt
fra før; kun frontend fulgte aldri opp svaret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-04 19:48:17 +02:00
parent fea701091c
commit ebd60da746
3 changed files with 112 additions and 11 deletions

View file

@ -7497,3 +7497,56 @@ Neste steg:
boot-et rent, `/health` og `/dashboard` → 200 over https. Gjenstår boot-et rent, `/health` og `/dashboard` → 200 over https. Gjenstår
fra opprinnelig plan (se plan-loggen 2026-08-04): Eclectic- fra opprinnelig plan (se plan-loggen 2026-08-04): Eclectic-
aggregering, lag-OOM sin faktiske leaderboard-beregning. aggregering, lag-OOM sin faktiske leaderboard-beregning.
21. **Bugfiks: "Fullfør runde" i header-modalen ("Administrer runde")
svelget feil stille og lukket dialogen uansett utfall — 2026-08-04.**
Brukeren rapporterte (skjermbilder fra en ekte runde på
teecup.teeoff.no) at en Københavner-runde med kun 1 av påkrevde 3
spillere så ut til å la seg "fullføre" via "Administrer"-knappens
modal (bekreftelsesdialogen dukket opp, ble klikket OK på), men
runden forble ufullført -- ingen feilmelding vist noe sted.
**Rotårsak, `round-header.tsx`/`round-page-shell.tsx`:** rundens
sticky header (ADR-040 Beslutning C/D, brukt på alle tre
Score/Scorekort/Leaderboard-fanene) har sin EGEN, uavhengige
"Fullfør runde"-vei via "Administrer"-modalen -- parallell til, men
ikke delt med, `round-detail.tsx` sin allerede korrekte inline
"Spillere og runde"-fane (som riktig deaktiverer knappen og viser
`setup_message` når `setup_complete` er usann). Modal-veien hadde
ALDRI blitt koblet til `setup_complete`-gaten, OG knappens
`onClick` kalte `onClose()` rett etter å ha startet
`onFinishRound()` UTEN å vente på den asynkrone
bekreftelse+`fetch`-kjeden -- dialogen lukket seg altså alltid
umiddelbart, uavhengig av om `POST /rounds/{id}/complete` faktisk
lyktes (backend-en avviste korrekt med 409 `SETUP_INCOMPLETE` +
forklarende melding -- det var ren frontend som aldri fulgte opp
svaret).
**Fiks:** `onFinishRound`-kontrakten endret fra `() => void` til
`() => Promise<{ok, message} | null>` (`null` = brukeren avbrøt
bekreftelsen, dialogen blir stående). Modalen venter nå på svaret
før den lukker seg -- ved `ok: false` vises `message` inline i
dialogen i stedet. Ny `finishDisabledReason`-prop deaktiverer
knappen og viser samme `setup_message` som den inline fanen,
fjerner selve muligheten til å trykke seg forbi en ufullstendig
formatoppsett via denne veien. Ingen backend-endring -- valideringen
fantes allerede og var korrekt, kun frontend fulgte den aldri opp.
**Scratch-verifisert** (isolert `teecup_scratch`-DB, alle 55
migrasjoner kjørt friskt, isolert `teecup_app_scratch`-rolle,
isolert scratch-MinIO, engangs API-/frontend-container bygget fra
de faktiske endrede filene, ekte nettleser via magic-link-innlogging):
reproduserte brukerens NØYAKTIGE scenario (Københavner-runde, 1 av 3
spillere) -- bekreftet modalens "Fullfør runde" nå viser seg
deaktivert med "Københavner krever nøyaktig 3 spillere -- runden har
1." synlig i dialogen (samme tekst som den inline fanen). La til to
gjestespillere (nå 3/3), bekreftet knappen ble aktiv, klikket
gjennom bekreftelsesdialogen, og bekreftet både at modalen lukket
seg OG at `completed_at` faktisk ble satt server-side. Full
typesjekket produksjonsbuild av frontend kjørt som del av samme
Docker-bygg. Scratch-miljøet ryddet opp fullstendig etterpå.
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt: `docker
compose up -d --build teecup_frontend` (ingen migrasjon involvert),
containeren boot-et rent, `/health` og `/dashboard` → 200 over
https.

View file

@ -52,8 +52,17 @@ type RoundHeaderProps = {
tabHrefs?: Record<RoundTab, string> tabHrefs?: Record<RoundTab, string>
/** Destination for the round-management screen ("Spillere og runde"). */ /** Destination for the round-management screen ("Spillere og runde"). */
manageHref?: string manageHref?: string
/** Mock admin actions. */ /** Fullfør runde. Returnerer `null` hvis brukeren avbrøt bekreftelsen
onFinishRound?: () => void * (dialogen skal da bare forbli åpen, ingen feilmelding), ellers
* `{ ok, message }` -- `ok: false` viser `message` i dialogen i stedet
* for å lukke den (2026-08-04 bugfiks: denne handlingen fulgte
* tidligere aldri opp om `fetch`-kallet faktisk lyktes -- dialogen
* lukket seg uansett utfall, uten feilmelding, se CHANGELOG.md). */
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
/** Når satt: "Fullfør runde"-knappen deaktiveres og denne teksten vises
* i stedet (samme oppsett-ufullstendig-sjekk som round-detail.tsx sin
* egen, uavhengige "Spillere og runde"-fane allerede håndhever). */
finishDisabledReason?: string | null
onDeleteRound?: () => void onDeleteRound?: () => void
className?: string className?: string
} }
@ -73,6 +82,7 @@ export function RoundHeader({
tabHrefs, tabHrefs,
manageHref = "#", manageHref = "#",
onFinishRound, onFinishRound,
finishDisabledReason,
onDeleteRound, onDeleteRound,
className, className,
}: RoundHeaderProps) { }: RoundHeaderProps) {
@ -194,6 +204,7 @@ export function RoundHeader({
manageHref={manageHref} manageHref={manageHref}
onClose={() => setManageOpen(false)} onClose={() => setManageOpen(false)}
onFinishRound={onFinishRound} onFinishRound={onFinishRound}
finishDisabledReason={finishDisabledReason}
onDeleteRound={onDeleteRound} onDeleteRound={onDeleteRound}
/> />
)} )}
@ -207,16 +218,34 @@ function ManageRoundDialog({
manageHref, manageHref,
onClose, onClose,
onFinishRound, onFinishRound,
finishDisabledReason,
onDeleteRound, onDeleteRound,
}: { }: {
manageHref: string manageHref: string
onClose: () => void onClose: () => void
onFinishRound?: () => void onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
finishDisabledReason?: string | null
onDeleteRound?: () => void onDeleteRound?: () => void
}) { }) {
const closeRef = useRef<HTMLButtonElement>(null) const closeRef = useRef<HTMLButtonElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [finishing, setFinishing] = useState(false)
const [finishError, setFinishError] = useState<string | null>(null)
async function handleFinish() {
if (!onFinishRound || finishing) return
setFinishing(true)
setFinishError(null)
const result = await onFinishRound()
setFinishing(false)
if (result === null) return // brukeren avbrøt bekreftelsen -- la dialogen stå
if (result.ok) {
onClose()
} else {
setFinishError(result.message ?? "Klarte ikke å fullføre runden. Prøv igjen.")
}
}
// Escape to close + focus the close button on open + lock scroll. // Escape to close + focus the close button on open + lock scroll.
useEffect(() => { useEffect(() => {
@ -293,15 +322,24 @@ function ManageRoundDialog({
{/* Administrative actions */} {/* Administrative actions */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button <Button
onClick={() => { onClick={handleFinish}
onFinishRound?.() disabled={finishing || Boolean(finishDisabledReason)}
onClose() title={finishDisabledReason ?? undefined}
}}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold" className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
> >
<CheckCircle2 aria-hidden="true" className="size-5" /> <CheckCircle2 aria-hidden="true" className="size-5" />
Fullfør runde {finishing ? "Fullfører…" : "Fullfør runde"}
</Button> </Button>
{finishDisabledReason && (
<p className="text-sm font-medium text-muted-foreground text-pretty">
{finishDisabledReason}
</p>
)}
{finishError && (
<p role="alert" className="text-sm font-semibold text-destructive text-pretty">
{finishError}
</p>
)}
{confirmDelete ? ( {confirmDelete ? (
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3"> <div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">

View file

@ -22,6 +22,8 @@ type ApiRoundHeaderFields = {
played_at: string played_at: string
started_at: string | null started_at: string | null
play_format: string play_format: string
setup_complete: boolean
setup_message: string | null
} }
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
@ -52,10 +54,15 @@ export function RoundPageShell({
} }
}, [roundId]) }, [roundId])
async function onFinishRound() { async function onFinishRound(): Promise<{ ok: boolean; message?: string } | null> {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return null
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" }) const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
if (res.ok) router.refresh() if (res.ok) {
router.refresh()
return { ok: true }
}
const body = await res.json().catch(() => null)
return { ok: false, message: body?.detail?.message ?? "Klarte ikke å fullføre runden. Prøv igjen." }
} }
async function onDeleteRound() { async function onDeleteRound() {
@ -84,6 +91,9 @@ export function RoundPageShell({
}} }}
manageHref={`/my-rounds/${roundId}?tab=manage`} manageHref={`/my-rounds/${roundId}?tab=manage`}
onFinishRound={onFinishRound} onFinishRound={onFinishRound}
finishDisabledReason={
!round.setup_complete ? round.setup_message ?? "Oppsettet er ikke komplett ennå." : undefined
}
onDeleteRound={onDeleteRound} onDeleteRound={onDeleteRound}
/> />
)} )}