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}
+ />
+ )}
)
}