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
fra opprinnelig plan (se plan-loggen 2026-08-04): Eclectic-
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>
/** Destination for the round-management screen ("Spillere og runde"). */
manageHref?: string
/** Mock admin actions. */
onFinishRound?: () => void
/** Fullfør runde. Returnerer `null` hvis brukeren avbrøt bekreftelsen
* (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
className?: string
}
@ -73,6 +82,7 @@ export function RoundHeader({
tabHrefs,
manageHref = "#",
onFinishRound,
finishDisabledReason,
onDeleteRound,
className,
}: RoundHeaderProps) {
@ -194,6 +204,7 @@ export function RoundHeader({
manageHref={manageHref}
onClose={() => setManageOpen(false)}
onFinishRound={onFinishRound}
finishDisabledReason={finishDisabledReason}
onDeleteRound={onDeleteRound}
/>
)}
@ -207,16 +218,34 @@ function ManageRoundDialog({
manageHref,
onClose,
onFinishRound,
finishDisabledReason,
onDeleteRound,
}: {
manageHref: string
onClose: () => void
onFinishRound?: () => void
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
finishDisabledReason?: string | null
onDeleteRound?: () => void
}) {
const closeRef = useRef<HTMLButtonElement>(null)
const [confirmDelete, setConfirmDelete] = 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.
useEffect(() => {
@ -293,15 +322,24 @@ function ManageRoundDialog({
{/* Administrative actions */}
<div className="flex flex-col gap-2">
<Button
onClick={() => {
onFinishRound?.()
onClose()
}}
onClick={handleFinish}
disabled={finishing || Boolean(finishDisabledReason)}
title={finishDisabledReason ?? undefined}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
>
<CheckCircle2 aria-hidden="true" className="size-5" />
Fullfør runde
{finishing ? "Fullfører…" : "Fullfør runde"}
</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 ? (
<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
started_at: string | null
play_format: string
setup_complete: boolean
setup_message: string | null
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
@ -52,10 +54,15 @@ export function RoundPageShell({
}
}, [roundId])
async function onFinishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
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 null
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() {
@ -84,6 +91,9 @@ export function RoundPageShell({
}}
manageHref={`/my-rounds/${roundId}?tab=manage`}
onFinishRound={onFinishRound}
finishDisabledReason={
!round.setup_complete ? round.setup_message ?? "Oppsettet er ikke komplett ennå." : undefined
}
onDeleteRound={onDeleteRound}
/>
)}