Frontend for per-deltaker-fullføring (ADR-065): Avslutt/Ferdig/Angre/Send scorekort

Player.completed lagt til, delt ParticipantCompletionActions-komponent
i både Score-fanens PlayerHoleCards og "Spillere og runde"-fanens
PlayerList. allHolesEnteredForEveryone og ScoringWizard-samlebåndet
tar nå hensyn til individuelt fullførte spillere.

Nettleserverifisert lys+mørk mot ekte teecup.golf (test-runde slettet
etterpå). tsc/vitest rent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-13 21:36:57 +02:00
parent baa1c42d24
commit d12af07ba7
3 changed files with 223 additions and 12 deletions

View file

@ -6396,12 +6396,36 @@ on-demand-endepunktet fungerer også lenge etter rundefullføring.
Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067 Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13. (GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`-felt, **Tillegg samme dag — frontend-UI bygget og rullet ut.** `Player.
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper i completed` lagt til (avledet fra `ApiParticipant.completed`), delt
`PlayerHoleCards` og "Spillere og runde"-fanen, `allHolesEnteredFor `ParticipantCompletionActions`-komponent gjenbrukt i BÅDE
Everyone`-justering, ScoringWizard-spillerkjede-hopp for fullførte `PlayerHoleCards` (Score-fanen) og `PlayerList` ("Spillere og
deltakere) — backend er bygget og verifisert, men ikke koblet inn i UI runde"-fanen) -- "Avslutt for [navn]" (skjult når runden selv er
ennå. fullført, siden backend uansett avviser da), "Ferdig ✓ (N hull)" +
"Angre" (kun når ikke rundefullført), "Send scorekort" (alltid synlig,
uansett fullføringsstatus -- dekker "send til enkeltspiller i etterkant,
lenge etter fullføring"-tillegget). `allHolesEnteredForEveryone` og
hurtig-hopp-stripens "registrert"-sjekk teller nå en `completed`-spiller
som ferdig uavhengig av faktisk hull-dekning. `advanceWizardPlayer`
hopper over fullførte spillere i samlebånds-kjeden. Et ikke-spilt hull
for en fullført spiller viser nå "Ferdig" i stedet for "Registrer" +
den stiplede pluss-boksen.
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest. Ekte nettleser-
verifisering (ekte innlogging inkl. reell TOTP-2FA-kode generert fra
kontoens lagrede secret, ekte runde opprettet på Tjøme -- cachet bane,
0 nye GolfAPI-kall -- med to deltakere): "Avslutt for X"/"Send
scorekort" synlig på begge kort i begge faner, avslutning av gjesten
trigget e-post umiddelbart (ingen feil i `teecup_api`-loggen, SMTP
konfigurert), UI oppdaterte seg til "Ferdig ✓"-merke + "Angre"-lenke +
"Ferdig"-tekst i score-kolonnen, "Send scorekort" for eieren (lenket
konto, andre kodesti enn gjesten) ga "Sendt ✓"-tilbakemelding, "Angre"
tilbakestilte korrekt og var persistent etter fane-bytte. Bekreftet
visuelt lys+mørk. Ingen konsollfeil gjennom hele forløpet. Testrunden
slettet etter verifisering (`DELETE /rounds/{id}`, bekreftet fjernet fra
`teecup_db`). **Rullet ut 2026-08-13** -- ren frontend-endring (ingen ny
migrasjon), `docker compose build teecup_frontend && up -d
teecup_frontend`.
--- ---

View file

@ -10909,10 +10909,33 @@ Neste steg:
**Rullet ut 2026-08-13**, samme økt, sammen med migrasjon 067 **Rullet ut 2026-08-13**, samme økt, sammen med migrasjon 067
(punkt 79 under). `docker compose build teecup_api && up -d`. (punkt 79 under). `docker compose build teecup_api && up -d`.
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`, **Tillegg samme dag -- frontend-UI bygget og rullet ut.**
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper, `Player.completed` (fra `ApiParticipant.completed`), delt
nagging-justering, ScoringWizard-spillerkjede-hopp) -- backend `ParticipantCompletionActions`-komponent i BÅDE `PlayerHoleCards`
bygget og verifisert, ikke koblet inn i UI ennå. (Score-fanen) og `PlayerList` ("Spillere og runde"-fanen):
"Avslutt for [navn]" (skjult når runden er fullført),
"Ferdig ✓ (N hull)" + "Angre" (kun før rundefullføring),
"Send scorekort" (alltid synlig, uansett fullføringsstatus).
`allHolesEnteredForEveryone` + hurtig-hopp-stripen teller en
`completed`-spiller som ferdig uansett faktisk hull-dekning.
`advanceWizardPlayer` hopper over fullførte spillere. Et ikke-spilt
hull for en fullført spiller viser "Ferdig" i stedet for
"Registrer".
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest. Ekte
nettleserverifisering (ekte innlogging inkl. reell TOTP-2FA-kode
generert fra kontoens lagrede secret, ekte runde på Tjøme -- cachet
bane, 0 nye GolfAPI-kall -- to deltakere): avslutning av gjesten
trigget e-post umiddelbart (ingen feil i loggen), UI oppdaterte til
"Ferdig ✓"/"Angre"/"Ferdig"-tekst korrekt, "Send scorekort" for
eieren (lenket konto, annen kodesti enn gjesten) ga "Sendt ✓",
"Angre" tilbakestilte og var persistent etter fane-bytte. Lys+mørk
bekreftet visuelt, ingen konsollfeil. Testrunden slettet etter
verifisering, bekreftet fjernet fra `teecup_db`.
**Rullet ut 2026-08-13**, samme økt. Ren frontend-endring (ingen ny
migrasjon) -- `docker compose build teecup_frontend && up -d
teecup_frontend`.
79. **To nye baner (Larvik/Seasidebanen, Nesbyen/Nesfjellet) via 79. **To nye baner (Larvik/Seasidebanen, Nesbyen/Nesfjellet) via
GolfAPI + en reell datakvalitetsbug funnet under importen — GolfAPI + en reell datakvalitetsbug funnet under importen —

View file

@ -91,6 +91,8 @@ type Player = {
playingHandicap: number | null playingHandicap: number | null
// Money Ball (2026-07-30) -- se ApiParticipant.lineup_order. // Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
lineupOrder: number | null lineupOrder: number | null
// ADR-065 -- se ApiParticipant.completed.
completed: boolean
} }
type HoleStat = { type HoleStat = {
@ -195,6 +197,10 @@ type ApiParticipant = {
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3), // Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
// null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball". // null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
lineup_order: number | null lineup_order: number | null
// Per-deltaker-fullføring (ADR-065, migrasjon 066) -- UAVHENGIG av
// round.completed_at, kun en nagging-brems + e-post-trigger for DENNE
// spilleren, ikke en cascade til hele rundens fullføring.
completed: boolean
} }
type ApiSide = { type ApiSide = {
@ -880,6 +886,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
roundSideId: p.round_side_id, roundSideId: p.round_side_id,
playingHandicap: p.playing_handicap, playingHandicap: p.playing_handicap,
lineupOrder: p.lineup_order, lineupOrder: p.lineup_order,
completed: p.completed,
})) ?? [] })) ?? []
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne // Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
@ -1029,7 +1036,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så // ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
// de skal aldri kunne bli et "neste"-mål her heller. // de skal aldri kunne bli et "neste"-mål her heller.
function advanceWizardPlayer() { function advanceWizardPlayer() {
const chain = scrambleSoloTeamSideId ? players.filter((p) => p.roundSideId !== scrambleSoloTeamSideId) : players // Fullførte spillere (ADR-065) hopper over i samlebåndet -- de skal
// ikke bli "neste mål" for registrering, akkurat som scramble_solos
// lag-spillere allerede hoppes over.
const chain = players.filter((p) => (!scrambleSoloTeamSideId || p.roundSideId !== scrambleSoloTeamSideId) && !p.completed)
const idx = chain.findIndex((p) => p.id === wizardPlayerId) const idx = chain.findIndex((p) => p.id === wizardPlayerId)
const isLast = idx === -1 || idx === chain.length - 1 const isLast = idx === -1 || idx === chain.length - 1
if (!isLast) { if (!isLast) {
@ -1062,6 +1072,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}) })
: players.length > 0 && : players.length > 0 &&
players.every((p) => { players.every((p) => {
// ADR-065 -- en individuelt avsluttet spiller teller som "ferdig"
// for CTA-formål selv om ikke alle hull i holeOrder faktisk er ført.
if (p.completed) return true
const list = holesByParticipant[p.id] const list = holesByParticipant[p.id]
return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true) return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
})) }))
@ -1223,6 +1236,26 @@ export function RoundDetail({ roundId }: { roundId: string }) {
return { ok: true } return { ok: true }
} }
// ADR-065 -- fungerer UANSETT fullføringsstatus, også lenge etter at
// runden (eller kun denne deltakeren) er fullført. Muterer ingen
// databasetilstand, kun les+send -- derfor ingen lokal round-oppdatering
// her, ulikt patchParticipant.
async function sendScorecard(participantId: string): Promise<{ ok: true } | { ok: false; message: string }> {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/send-scorecard`, {
method: "POST",
credentials: "include",
})
if (!res.ok) {
const errBody = await res.json().catch(() => null)
const message =
errBody?.detail?.code === "NO_EMAIL_ADDRESS"
? "Denne spilleren har ingen registrert e-postadresse."
: (errBody?.detail?.message ?? "Klarte ikke å sende scorekortet. Prøv igjen.")
return { ok: false, message }
}
return { ok: true }
}
async function finishRound() { async function finishRound() {
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
setCompleting(true) setCompleting(true)
@ -1473,6 +1506,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
roundId={roundId} roundId={roundId}
players={players} players={players}
sides={round.sides} sides={round.sides}
holesByParticipant={holesByParticipant}
activePlayerId={activePlayer.id} activePlayerId={activePlayer.id}
onSelect={setActivePlayerId} onSelect={setActivePlayerId}
onRemove={removeGuest} onRemove={removeGuest}
@ -1483,6 +1517,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
editingPlayerId={editingPlayerId} editingPlayerId={editingPlayerId}
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))} onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
onPatchParticipant={patchParticipant} onPatchParticipant={patchParticipant}
onSendScorecard={sendScorecard}
onAddGuest={addGuest} onAddGuest={addGuest}
onAddSearched={addSearchedParticipant} onAddSearched={addSearchedParticipant}
/> />
@ -1638,6 +1673,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
onPrevHole={goPrev} onPrevHole={goPrev}
onNextHole={goNext} onNextHole={goNext}
onOpenEntry={openPlayerOrTeamSideEntry} onOpenEntry={openPlayerOrTeamSideEntry}
onPatchParticipant={patchParticipant}
onSendScorecard={sendScorecard}
/> />
<div className="overflow-hidden rounded-2xl border border-border bg-card"> <div className="overflow-hidden rounded-2xl border border-border bg-card">
@ -3929,6 +3966,7 @@ function PlayerList({
roundId, roundId,
players, players,
sides, sides,
holesByParticipant,
activePlayerId, activePlayerId,
onSelect, onSelect,
onRemove, onRemove,
@ -3939,6 +3977,7 @@ function PlayerList({
editingPlayerId, editingPlayerId,
onToggleEdit, onToggleEdit,
onPatchParticipant, onPatchParticipant,
onSendScorecard,
onAddGuest, onAddGuest,
onAddSearched, onAddSearched,
}: { }: {
@ -3947,6 +3986,9 @@ function PlayerList({
// ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve // ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
// tildelingen skjer i SidesPanel, ikke her. // tildelingen skjer i SidesPanel, ikke her.
sides: ApiSide[] sides: ApiSide[]
// ADR-065 -- kun brukt til "Ferdig · N hull"-tellingen i
// ParticipantCompletionActions.
holesByParticipant: Record<string, ApiHole[]>
activePlayerId: string activePlayerId: string
onSelect: (id: string) => void onSelect: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
@ -3960,6 +4002,7 @@ function PlayerList({
editingPlayerId: string | null editingPlayerId: string | null
onToggleEdit: (id: string) => void onToggleEdit: (id: string) => void
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }> onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null> onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
}) { }) {
@ -4068,6 +4111,14 @@ function PlayerList({
</div> </div>
</div> </div>
<ParticipantCompletionActions
player={player}
holesPlayed={holesByParticipant[player.id]?.filter((h) => h.played).length ?? 0}
readOnly={readOnly}
onPatch={onPatchParticipant}
onSendScorecard={onSendScorecard}
/>
{editing && canEdit && ( {editing && canEdit && (
<EditParticipantPanel <EditParticipantPanel
roundId={roundId} roundId={roundId}
@ -5469,6 +5520,99 @@ function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
return 2 return 2
} }
// ADR-065 -- delt mellom PlayerHoleCards (Score-fanen) og PlayerList
// ("Spillere og runde"-fanen), samme handlinger begge steder. "Avslutt
// for [navn]"/"Angre" er FLIGHT-STYRT (patchParticipant, ikke eier-only
// -- se update_participant i rounds.py), skjules når runden selv er
// fullført (readOnly) siden backend uansett avviser med ALREADY_COMPLETED
// da. "Send scorekort" fungerer derimot UANSETT fullføringsstatus.
function ParticipantCompletionActions({
player,
holesPlayed,
readOnly,
onPatch,
onSendScorecard,
}: {
player: Player
holesPlayed: number
readOnly: boolean
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
const [busy, setBusy] = useState(false)
const [busyError, setBusyError] = useState<string | null>(null)
const [sendState, setSendState] = useState<"idle" | "sending" | "sent" | "error">("idle")
const [sendError, setSendError] = useState<string | null>(null)
async function toggleCompleted(next: boolean) {
setBusy(true)
setBusyError(null)
const result = await onPatch(player.id, { completed: next })
setBusy(false)
if (!result.ok) setBusyError(result.message)
}
async function handleSend() {
setSendState("sending")
setSendError(null)
const result = await onSendScorecard(player.id)
setSendState(result.ok ? "sent" : "error")
if (!result.ok) setSendError(result.message)
}
return (
<div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-2.5">
{player.completed ? (
<>
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
<Check aria-hidden="true" className="size-4" />
Ferdig{holesPlayed > 0 ? ` · ${holesPlayed} hull` : ""}
</span>
{!readOnly && (
<button
type="button"
disabled={busy}
onClick={() => toggleCompleted(false)}
className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground disabled:opacity-60"
>
Angre
</button>
)}
</>
) : (
!readOnly && (
<button
type="button"
disabled={busy}
onClick={() => toggleCompleted(true)}
className="flex min-h-11 items-center justify-center rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
>
Avslutt for {player.name}
</button>
)
)}
<button
type="button"
disabled={sendState === "sending"}
onClick={handleSend}
className="ml-auto flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
>
{sendState === "sending" ? "Sender…" : sendState === "sent" ? "Sendt ✓" : "Send scorekort"}
</button>
{busyError && (
<p role="alert" className="w-full text-sm font-medium text-destructive">
{busyError}
</p>
)}
{sendState === "error" && sendError && (
<p role="alert" className="w-full text-sm font-medium text-destructive">
{sendError}
</p>
)}
</div>
)
}
function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null { function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null {
if (!list || numbers.length === 0) return null if (!list || numbers.length === 0) return null
const played = numbers const played = numbers
@ -5509,6 +5653,8 @@ function PlayerHoleCards({
onPrevHole, onPrevHole,
onNextHole, onNextHole,
onOpenEntry, onOpenEntry,
onPatchParticipant,
onSendScorecard,
}: { }: {
roundId: string roundId: string
players: Player[] players: Player[]
@ -5525,6 +5671,8 @@ function PlayerHoleCards({
onPrevHole: () => void onPrevHole: () => void
onNextHole: () => void onNextHole: () => void
onOpenEntry: (playerId: string, holeNumber: number) => void onOpenEntry: (playerId: string, holeNumber: number) => void
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
}) { }) {
const holeMeta: Record<number, { par: number; index: number }> = {} const holeMeta: Record<number, { par: number; index: number }> = {}
for (const list of Object.values(holesByParticipant)) { for (const list of Object.values(holesByParticipant)) {
@ -5551,6 +5699,9 @@ function PlayerHoleCards({
const allRegisteredByHole: Record<number, boolean> = {} const allRegisteredByHole: Record<number, boolean> = {}
for (const n of holeOrder) { for (const n of holeOrder) {
allRegisteredByHole[n] = players.every((p) => { allRegisteredByHole[n] = players.every((p) => {
// ADR-065 -- en avsluttet spiller blokkerer aldri "registrert"-status
// for et hull de aldri kommer til å spille.
if (p.completed) return true
const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n) const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n)
return Boolean(h?.played) && (h?.score !== null || h?.picked_up) return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
}) })
@ -5680,7 +5831,9 @@ function PlayerHoleCards({
aria-label={ aria-label={
hasScore hasScore
? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}` ? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
: `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}` : player.completed
? `${player.name} har avsluttet runden, hull ${currentHole} ikke spilt`
: `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
} }
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60" className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60"
> >
@ -5749,6 +5902,10 @@ function PlayerHoleCards({
{points !== null ? ` · ${points} p` : ""} {points !== null ? ` · ${points} p` : ""}
</span> </span>
</> </>
) : player.completed ? (
// ADR-065 -- spilleren er individuelt avsluttet, dette
// hullet blir aldri spilt. Ingen "Registrer"-oppfordring.
<span className="text-[11px] font-semibold text-muted-foreground">Ferdig</span>
) : ( ) : (
<> <>
<span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary"> <span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary">
@ -5775,6 +5932,13 @@ function PlayerHoleCards({
/> />
</div> </div>
)} )}
<ParticipantCompletionActions
player={player}
holesPlayed={list?.filter((h) => h.played).length ?? 0}
readOnly={readOnly}
onPatch={onPatchParticipant}
onSendScorecard={onSendScorecard}
/>
</div> </div>
</li> </li>
) )