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 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:12:56 +01:00
parent 1f198aca4c
commit 110b864e95
6 changed files with 166 additions and 15 deletions

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-jq50 # nuzlocke-tracker-jq50
title: Add ability to end a run (success/failure) title: Add ability to end a run (success/failure)
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-02-06T10:22:00Z 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 parent: nuzlocke-tracker-f5ob
--- ---

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -73,6 +75,15 @@ async def update_run(
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
update_data = data.model_dump(exclude_unset=True) 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(): for field, value in update_data.items():
setattr(run, field, value) setattr(run, field, value)

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">End Run</h2>
</div>
<div className="px-6 py-6">
<p className="text-gray-600 dark:text-gray-400 mb-6">
How did your run end?
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => onConfirm('completed')}
disabled={isPending}
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 hover:border-blue-400 dark:hover:border-blue-600 disabled:opacity-50 transition-colors"
>
<div className="font-semibold">Victory</div>
<div className="text-sm opacity-80">Beat the game successfully</div>
</button>
<button
onClick={() => onConfirm('failed')}
disabled={isPending}
className="w-full px-4 py-3 rounded-lg font-medium text-left border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 hover:border-red-400 dark:hover:border-red-600 disabled:opacity-50 transition-colors"
>
<div className="font-semibold">Defeat</div>
<div className="text-sm opacity-80">All Pokemon fainted or gave up</div>
</button>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
export { EncounterModal } from './EncounterModal' export { EncounterModal } from './EncounterModal'
export { EndRunModal } from './EndRunModal'
export { GameCard } from './GameCard' export { GameCard } from './GameCard'
export { GameGrid } from './GameGrid' export { GameGrid } from './GameGrid'
export { Layout } from './Layout' export { Layout } from './Layout'

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs' import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs'
import type { CreateRunInput, UpdateRunInput } from '../types/game' import type { CreateRunInput, UpdateRunInput } from '../types/game'
@@ -30,9 +31,14 @@ export function useUpdateRun(id: number) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (data: UpdateRunInput) => updateRun(id, data), mutationFn: (data: UpdateRunInput) => updateRun(id, data),
onSuccess: () => { onSuccess: (_result, data) => {
queryClient.invalidateQueries({ queryKey: ['runs'] }) 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}`),
}) })
} }

View File

@@ -1,9 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useRun } from '../hooks/useRuns' import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
import { useUpdateEncounter } from '../hooks/useEncounters' 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' import type { RunStatus, EncounterDetail } from '../types'
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
@@ -13,14 +13,24 @@ const statusStyles: Record<RunStatus, string> = {
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', 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() { export function RunDashboard() {
const { runId } = useParams<{ runId: string }>() const { runId } = useParams<{ runId: string }>()
const runIdNum = Number(runId) const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum) const { data: run, isLoading, error } = useRun(runIdNum)
const { data: routes } = useGameRoutes(run?.gameId ?? null) const { data: routes } = useGameRoutes(run?.gameId ?? null)
const updateEncounter = useUpdateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
const [selectedEncounter, setSelectedEncounter] = const [selectedEncounter, setSelectedEncounter] =
useState<EncounterDetail | null>(null) useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
if (isLoading) { if (isLoading) {
return ( return (
@@ -46,6 +56,7 @@ export function RunDashboard() {
) )
} }
const isActive = run.status === 'active'
const alive = run.encounters.filter( const alive = run.encounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null, (e) => e.status === 'caught' && e.faintLevel === null,
) )
@@ -87,6 +98,52 @@ export function RunDashboard() {
</div> </div>
</div> </div>
{/* Completion Banner */}
{!isActive && (
<div
className={`rounded-lg p-4 mb-6 ${
run.status === 'completed'
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
<div>
<p
className={`font-semibold ${
run.status === 'completed'
? 'text-blue-800 dark:text-blue-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{run.status === 'completed' ? 'Victory!' : 'Defeat'}
</p>
<p
className={`text-sm ${
run.status === 'completed'
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{run.completedAt && (
<>
Ended{' '}
{new Date(run.completedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{' \u00b7 '}
Duration: {formatDuration(run.startedAt, run.completedAt)}
</>
)}
</p>
</div>
</div>
</div>
)}
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard <StatCard
@@ -115,7 +172,7 @@ export function RunDashboard() {
{/* Active Team */} {/* Active Team */}
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Active Team {isActive ? 'Active Team' : 'Final Team'}
</h2> </h2>
{alive.length === 0 ? ( {alive.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm"> <p className="text-gray-500 dark:text-gray-400 text-sm">
@@ -127,7 +184,7 @@ export function RunDashboard() {
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
onClick={() => setSelectedEncounter(enc)} onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
/> />
))} ))}
</div> </div>
@@ -146,7 +203,7 @@ export function RunDashboard() {
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel showFaintLevel
onClick={() => setSelectedEncounter(enc)} onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
/> />
))} ))}
</div> </div>
@@ -154,13 +211,23 @@ export function RunDashboard() {
)} )}
{/* Quick Actions */} {/* Quick Actions */}
<div className="mt-8"> <div className="mt-8 flex gap-3">
<Link {isActive && (
to={`/runs/${runId}/encounters`} <>
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors" <Link
> to={`/runs/${runId}/encounters`}
Log Encounter className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
</Link> >
Log Encounter
</Link>
<button
onClick={() => setShowEndRun(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 transition-colors"
>
End Run
</button>
</>
)}
</div> </div>
{/* Status Change Modal */} {/* Status Change Modal */}
@@ -176,6 +243,20 @@ export function RunDashboard() {
isPending={updateEncounter.isPending} isPending={updateEncounter.isPending}
/> />
)} )}
{/* End Run Modal */}
{showEndRun && (
<EndRunModal
onConfirm={(status) => {
updateRun.mutate(
{ status },
{ onSuccess: () => setShowEndRun(false) },
)
}}
onClose={() => setShowEndRun(false)}
isPending={updateRun.isPending}
/>
)}
</div> </div>
) )
} }