diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index a57b1e7..2179e13 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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`. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c0235..f3557f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index f1d5941..f94bdd7 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -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} />
@@ -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 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) => 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 }) { @@ -4068,6 +4111,14 @@ function PlayerList({
+ h.played).length ?? 0} + readOnly={readOnly} + onPatch={onPatchParticipant} + onSendScorecard={onSendScorecard} + /> + {editing && canEdit && ( ) => 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(null) + const [sendState, setSendState] = useState<"idle" | "sending" | "sent" | "error">("idle") + const [sendError, setSendError] = useState(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 ( +
+ {player.completed ? ( + <> + + + {!readOnly && ( + + )} + + ) : ( + !readOnly && ( + + ) + )} + + {busyError && ( +

+ {busyError} +

+ )} + {sendState === "error" && sendError && ( +

+ {sendError} +

+ )} +
+ ) +} + 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) => Promise<{ ok: true } | { ok: false; message: string }> + onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }> }) { const holeMeta: Record = {} for (const list of Object.values(holesByParticipant)) { @@ -5551,6 +5699,9 @@ function PlayerHoleCards({ const allRegisteredByHole: Record = {} 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,7 +5831,9 @@ 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" : ""}` - : `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" > @@ -5749,6 +5902,10 @@ function PlayerHoleCards({ {points !== null ? ` · ${points} p` : ""} + ) : player.completed ? ( + // ADR-065 -- spilleren er individuelt avsluttet, dette + // hullet blir aldri spilt. Ingen "Registrer"-oppfordring. + Ferdig ) : ( <> @@ -5775,6 +5932,13 @@ function PlayerHoleCards({ /> )} + h.played).length ?? 0} + readOnly={readOnly} + onPatch={onPatchParticipant} + onSendScorecard={onSendScorecard} + /> )