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
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`-felt,
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper i
`PlayerHoleCards` og "Spillere og runde"-fanen, `allHolesEnteredFor
Everyone`-justering, ScoringWizard-spillerkjede-hopp for fullførte
deltakere) — backend er bygget og verifisert, men ikke koblet inn i UI
ennå.
**Tillegg samme dag — frontend-UI bygget og rullet ut.** `Player.
completed` lagt til (avledet fra `ApiParticipant.completed`), delt
`ParticipantCompletionActions`-komponent gjenbrukt i BÅDE
`PlayerHoleCards` (Score-fanen) og `PlayerList` ("Spillere og
runde"-fanen) -- "Avslutt for [navn]" (skjult når runden selv er
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
(punkt 79 under). `docker compose build teecup_api && up -d`.
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`,
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper,
nagging-justering, ScoringWizard-spillerkjede-hopp) -- backend
bygget og verifisert, ikke koblet inn i UI ennå.
**Tillegg samme dag -- frontend-UI bygget og rullet ut.**
`Player.completed` (fra `ApiParticipant.completed`), delt
`ParticipantCompletionActions`-komponent i BÅDE `PlayerHoleCards`
(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
GolfAPI + en reell datakvalitetsbug funnet under importen —

View file

@ -91,6 +91,8 @@ type Player = {
playingHandicap: number | null
// Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
lineupOrder: number | null
// ADR-065 -- se ApiParticipant.completed.
completed: boolean
}
type HoleStat = {
@ -195,6 +197,10 @@ type ApiParticipant = {
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
// null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
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 = {
@ -880,6 +886,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
roundSideId: p.round_side_id,
playingHandicap: p.playing_handicap,
lineupOrder: p.lineup_order,
completed: p.completed,
})) ?? []
// 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å
// de skal aldri kunne bli et "neste"-mål her heller.
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 isLast = idx === -1 || idx === chain.length - 1
if (!isLast) {
@ -1062,6 +1072,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
})
: players.length > 0 &&
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]
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 }
}
// 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() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
setCompleting(true)
@ -1473,6 +1506,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
roundId={roundId}
players={players}
sides={round.sides}
holesByParticipant={holesByParticipant}
activePlayerId={activePlayer.id}
onSelect={setActivePlayerId}
onRemove={removeGuest}
@ -1483,6 +1517,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
editingPlayerId={editingPlayerId}
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
onPatchParticipant={patchParticipant}
onSendScorecard={sendScorecard}
onAddGuest={addGuest}
onAddSearched={addSearchedParticipant}
/>
@ -1638,6 +1673,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
onPrevHole={goPrev}
onNextHole={goNext}
onOpenEntry={openPlayerOrTeamSideEntry}
onPatchParticipant={patchParticipant}
onSendScorecard={sendScorecard}
/>
<div className="overflow-hidden rounded-2xl border border-border bg-card">
@ -3929,6 +3966,7 @@ function PlayerList({
roundId,
players,
sides,
holesByParticipant,
activePlayerId,
onSelect,
onRemove,
@ -3939,6 +3977,7 @@ function PlayerList({
editingPlayerId,
onToggleEdit,
onPatchParticipant,
onSendScorecard,
onAddGuest,
onAddSearched,
}: {
@ -3947,6 +3986,9 @@ function PlayerList({
// ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
// tildelingen skjer i SidesPanel, ikke her.
sides: ApiSide[]
// ADR-065 -- kun brukt til "Ferdig · N hull"-tellingen i
// ParticipantCompletionActions.
holesByParticipant: Record<string, ApiHole[]>
activePlayerId: string
onSelect: (id: string) => void
onRemove: (id: string) => void
@ -3960,6 +4002,7 @@ function PlayerList({
editingPlayerId: string | null
onToggleEdit: (id: string) => 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 }>
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
}) {
@ -4068,6 +4111,14 @@ function PlayerList({
</div>
</div>
<ParticipantCompletionActions
player={player}
holesPlayed={holesByParticipant[player.id]?.filter((h) => h.played).length ?? 0}
readOnly={readOnly}
onPatch={onPatchParticipant}
onSendScorecard={onSendScorecard}
/>
{editing && canEdit && (
<EditParticipantPanel
roundId={roundId}
@ -5469,6 +5520,99 @@ function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
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 {
if (!list || numbers.length === 0) return null
const played = numbers
@ -5509,6 +5653,8 @@ function PlayerHoleCards({
onPrevHole,
onNextHole,
onOpenEntry,
onPatchParticipant,
onSendScorecard,
}: {
roundId: string
players: Player[]
@ -5525,6 +5671,8 @@ function PlayerHoleCards({
onPrevHole: () => void
onNextHole: () => 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 }> = {}
for (const list of Object.values(holesByParticipant)) {
@ -5551,6 +5699,9 @@ function PlayerHoleCards({
const allRegisteredByHole: Record<number, boolean> = {}
for (const n of holeOrder) {
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)
return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
})
@ -5680,6 +5831,8 @@ function PlayerHoleCards({
aria-label={
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.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"
@ -5749,6 +5902,10 @@ function PlayerHoleCards({
{points !== null ? ` · ${points} p` : ""}
</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">
@ -5775,6 +5932,13 @@ function PlayerHoleCards({
/>
</div>
)}
<ParticipantCompletionActions
player={player}
holesPlayed={list?.filter((h) => h.played).length ?? 0}
readOnly={readOnly}
onPatch={onPatchParticipant}
onSendScorecard={onSendScorecard}
/>
</div>
</li>
)