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,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<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',
}
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<EncounterDetail | null>(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() {
</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 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
@@ -115,7 +172,7 @@ export function RunDashboard() {
{/* Active Team */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Active Team
{isActive ? 'Active Team' : 'Final Team'}
</h2>
{alive.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">
@@ -127,7 +184,7 @@ export function RunDashboard() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={() => setSelectedEncounter(enc)}
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
/>
))}
</div>
@@ -146,7 +203,7 @@ export function RunDashboard() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={() => setSelectedEncounter(enc)}
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
/>
))}
</div>
@@ -154,13 +211,23 @@ export function RunDashboard() {
)}
{/* Quick Actions */}
<div className="mt-8">
<Link
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"
>
Log Encounter
</Link>
<div className="mt-8 flex gap-3">
{isActive && (
<>
<Link
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"
>
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>
{/* Status Change Modal */}
@@ -176,6 +243,20 @@ export function RunDashboard() {
isPending={updateEncounter.isPending}
/>
)}
{/* End Run Modal */}
{showEndRun && (
<EndRunModal
onConfirm={(status) => {
updateRun.mutate(
{ status },
{ onSuccess: () => setShowEndRun(false) },
)
}}
onClose={() => setShowEndRun(false)}
isPending={updateRun.isPending}
/>
)}
</div>
)
}