From 110b864e9552f982370aade063d42a67b133146e Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 7 Feb 2026 13:12:56 +0100 Subject: [PATCH] Add ability to end a run as victory or defeat Add End Run button to run dashboard with a confirmation modal offering Victory/Defeat choice. Backend auto-sets completedAt timestamp and validates only active runs can be ended. Ended runs show a completion banner with date and duration, rename team to "Final Team", and hide encounter logging and status change interactions. Co-Authored-By: Claude Opus 4.6 --- ...add-ability-to-end-a-run-successfailure.md | 4 +- backend/src/app/api/runs.py | 11 ++ frontend/src/components/EndRunModal.tsx | 52 +++++++++ frontend/src/components/index.ts | 1 + frontend/src/hooks/useRuns.ts | 8 +- frontend/src/pages/RunDashboard.tsx | 105 ++++++++++++++++-- 6 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/EndRunModal.tsx diff --git a/.beans/nuzlocke-tracker-jq50--add-ability-to-end-a-run-successfailure.md b/.beans/nuzlocke-tracker-jq50--add-ability-to-end-a-run-successfailure.md index f6c98aa..a4ee345 100644 --- a/.beans/nuzlocke-tracker-jq50--add-ability-to-end-a-run-successfailure.md +++ b/.beans/nuzlocke-tracker-jq50--add-ability-to-end-a-run-successfailure.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-jq50 title: Add ability to end a run (success/failure) -status: todo +status: completed type: task priority: normal created_at: 2026-02-06T10:22:00Z -updated_at: 2026-02-06T10:22:34Z +updated_at: 2026-02-07T12:12:42Z parent: nuzlocke-tracker-f5ob --- diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index fb78ec6..1053149 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -73,6 +75,15 @@ async def update_run( raise HTTPException(status_code=404, detail="Run not found") update_data = data.model_dump(exclude_unset=True) + + # Auto-set completed_at when ending a run + if "status" in update_data and update_data["status"] in ("completed", "failed"): + if run.status != "active": + raise HTTPException( + status_code=400, detail="Only active runs can be ended" + ) + update_data["completed_at"] = datetime.now(timezone.utc) + for field, value in update_data.items(): setattr(run, field, value) diff --git a/frontend/src/components/EndRunModal.tsx b/frontend/src/components/EndRunModal.tsx new file mode 100644 index 0000000..e14962c --- /dev/null +++ b/frontend/src/components/EndRunModal.tsx @@ -0,0 +1,52 @@ +import type { RunStatus } from '../types' + +interface EndRunModalProps { + onConfirm: (status: RunStatus) => void + onClose: () => void + isPending?: boolean +} + +export function EndRunModal({ onConfirm, onClose, isPending }: EndRunModalProps) { + return ( +
+
+
+
+

End Run

+
+
+

+ How did your run end? +

+
+ + +
+
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 4675471..942e0e8 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,4 +1,5 @@ export { EncounterModal } from './EncounterModal' +export { EndRunModal } from './EndRunModal' export { GameCard } from './GameCard' export { GameGrid } from './GameGrid' export { Layout } from './Layout' diff --git a/frontend/src/hooks/useRuns.ts b/frontend/src/hooks/useRuns.ts index fbaa1d1..68fb31e 100644 --- a/frontend/src/hooks/useRuns.ts +++ b/frontend/src/hooks/useRuns.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs' import type { CreateRunInput, UpdateRunInput } from '../types/game' @@ -30,9 +31,14 @@ export function useUpdateRun(id: number) { const queryClient = useQueryClient() return useMutation({ mutationFn: (data: UpdateRunInput) => updateRun(id, data), - onSuccess: () => { + onSuccess: (_result, data) => { queryClient.invalidateQueries({ queryKey: ['runs'] }) + queryClient.invalidateQueries({ queryKey: ['runs', id] }) + if (data.status === 'completed') toast.success('Run marked as completed!') + else if (data.status === 'failed') toast.success('Run marked as failed') + else toast.success('Run updated') }, + onError: (err) => toast.error(`Failed to update run: ${err.message}`), }) } diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index d453c81..c8c0de6 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { useParams, Link } from 'react-router-dom' -import { useRun } from '../hooks/useRuns' +import { useRun, useUpdateRun } from '../hooks/useRuns' import { useGameRoutes } from '../hooks/useGames' import { useUpdateEncounter } from '../hooks/useEncounters' -import { StatCard, PokemonCard, RuleBadges, StatusChangeModal } from '../components' +import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components' import type { RunStatus, EncounterDetail } from '../types' const statusStyles: Record = { @@ -13,14 +13,24 @@ const statusStyles: Record = { failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', } +function formatDuration(start: string, end: string) { + const ms = new Date(end).getTime() - new Date(start).getTime() + const days = Math.floor(ms / (1000 * 60 * 60 * 24)) + if (days === 0) return 'Less than a day' + if (days === 1) return '1 day' + return `${days} days` +} + export function RunDashboard() { const { runId } = useParams<{ runId: string }>() const runIdNum = Number(runId) const { data: run, isLoading, error } = useRun(runIdNum) const { data: routes } = useGameRoutes(run?.gameId ?? null) const updateEncounter = useUpdateEncounter(runIdNum) + const updateRun = useUpdateRun(runIdNum) const [selectedEncounter, setSelectedEncounter] = useState(null) + const [showEndRun, setShowEndRun] = useState(false) if (isLoading) { return ( @@ -46,6 +56,7 @@ export function RunDashboard() { ) } + const isActive = run.status === 'active' const alive = run.encounters.filter( (e) => e.status === 'caught' && e.faintLevel === null, ) @@ -87,6 +98,52 @@ export function RunDashboard() {
+ {/* Completion Banner */} + {!isActive && ( +
+
+ {run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'} +
+

+ {run.status === 'completed' ? 'Victory!' : 'Defeat'} +

+

+ {run.completedAt && ( + <> + Ended{' '} + {new Date(run.completedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + {' \u00b7 '} + Duration: {formatDuration(run.startedAt, run.completedAt)} + + )} +

+
+
+
+ )} + {/* Stats */}

- Active Team + {isActive ? 'Active Team' : 'Final Team'}

{alive.length === 0 ? (

@@ -127,7 +184,7 @@ export function RunDashboard() { setSelectedEncounter(enc)} + onClick={isActive ? () => setSelectedEncounter(enc) : undefined} /> ))}

@@ -146,7 +203,7 @@ export function RunDashboard() { key={enc.id} encounter={enc} showFaintLevel - onClick={() => setSelectedEncounter(enc)} + onClick={isActive ? () => setSelectedEncounter(enc) : undefined} /> ))} @@ -154,13 +211,23 @@ export function RunDashboard() { )} {/* Quick Actions */} -
- - Log Encounter - +
+ {isActive && ( + <> + + Log Encounter + + + + )}
{/* Status Change Modal */} @@ -176,6 +243,20 @@ export function RunDashboard() { isPending={updateEncounter.isPending} /> )} + + {/* End Run Modal */} + {showEndRun && ( + { + updateRun.mutate( + { status }, + { onSuccess: () => setShowEndRun(false) }, + ) + }} + onClose={() => setShowEndRun(false)} + isPending={updateRun.isPending} + /> + )}
) }