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:
parent
fea701091c
commit
ebd60da746
3 changed files with 112 additions and 11 deletions
53
CHANGELOG.md
53
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue