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:
@@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
52
frontend/src/components/EndRunModal.tsx
Normal file
52
frontend/src/components/EndRunModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user