Add run dashboard and encounter tracking interface
Run list at /runs shows all runs with status badges. Run dashboard at /runs/:id displays stats, active team, graveyard, and rule badges. Encounter tracking at /runs/:runId/encounters shows route list with status indicators, progress bar, filters, and a modal for logging or editing encounters with pokemon picker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Run dashboard will be implemented here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export function Encounters() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Encounters</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Encounter tracking will be implemented here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function NewRun() {
|
||||
if (!selectedGame) return
|
||||
createRun.mutate(
|
||||
{ gameId: selectedGame.id, name: runName, rules },
|
||||
{ onSuccess: () => navigate('/dashboard') },
|
||||
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
152
frontend/src/pages/RunDashboard.tsx
Normal file
152
frontend/src/pages/RunDashboard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useRun } from '../hooks/useRuns'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
import { StatCard, PokemonCard, RuleBadges } from '../components'
|
||||
import type { RunStatus } from '../types'
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
completed:
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
}
|
||||
|
||||
export function RunDashboard() {
|
||||
const { runId } = useParams<{ runId: string }>()
|
||||
const { data: run, isLoading, error } = useRun(Number(runId))
|
||||
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !run) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||
Failed to load run. It may not exist.
|
||||
</div>
|
||||
<Link
|
||||
to="/runs"
|
||||
className="inline-block mt-4 text-blue-600 hover:underline"
|
||||
>
|
||||
Back to runs
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const alive = run.encounters.filter(
|
||||
(e) => e.status === 'caught' && e.faintLevel === null,
|
||||
)
|
||||
const dead = run.encounters.filter(
|
||||
(e) => e.status === 'caught' && e.faintLevel !== null,
|
||||
)
|
||||
const visitedRoutes = new Set(run.encounters.map((e) => e.routeId)).size
|
||||
const totalRoutes = routes?.length
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/runs"
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
||||
>
|
||||
← All Runs
|
||||
</Link>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{run.name}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{run.game.name} · {run.game.region} · Started{' '}
|
||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${statusStyles[run.status]}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
label="Encounters"
|
||||
value={run.encounters.length}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard label="Alive" value={alive.length} color="green" />
|
||||
<StatCard label="Deaths" value={dead.length} color="red" />
|
||||
<StatCard
|
||||
label="Routes Visited"
|
||||
value={visitedRoutes}
|
||||
total={totalRoutes}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Active Rules
|
||||
</h2>
|
||||
<RuleBadges rules={run.rules} />
|
||||
</div>
|
||||
|
||||
{/* Active Team */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Active Team
|
||||
</h2>
|
||||
{alive.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
No pokemon caught yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{alive.map((enc) => (
|
||||
<PokemonCard key={enc.id} encounter={enc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graveyard */}
|
||||
{dead.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Graveyard
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{dead.map((enc) => (
|
||||
<PokemonCard key={enc.id} encounter={enc} showFaintLevel />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
frontend/src/pages/RunEncounters.tsx
Normal file
257
frontend/src/pages/RunEncounters.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useRun } from '../hooks/useRuns'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||
import { EncounterModal } from '../components'
|
||||
import type { Route, EncounterDetail, EncounterStatus } from '../types'
|
||||
|
||||
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
||||
|
||||
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
||||
if (!encounter) return 'none'
|
||||
return encounter.status
|
||||
}
|
||||
|
||||
const statusIndicator: Record<
|
||||
RouteStatus,
|
||||
{ dot: string; label: string; bg: string }
|
||||
> = {
|
||||
caught: {
|
||||
dot: 'bg-green-500',
|
||||
label: 'Caught',
|
||||
bg: 'bg-green-50 dark:bg-green-900/10',
|
||||
},
|
||||
fainted: {
|
||||
dot: 'bg-red-500',
|
||||
label: 'Fainted',
|
||||
bg: 'bg-red-50 dark:bg-red-900/10',
|
||||
},
|
||||
missed: {
|
||||
dot: 'bg-gray-400',
|
||||
label: 'Missed',
|
||||
bg: 'bg-gray-50 dark:bg-gray-900/10',
|
||||
},
|
||||
none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' },
|
||||
}
|
||||
|
||||
export function RunEncounters() {
|
||||
const { runId } = useParams<{ runId: string }>()
|
||||
const runIdNum = Number(runId)
|
||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
||||
run?.gameId ?? null,
|
||||
)
|
||||
const createEncounter = useCreateEncounter(runIdNum)
|
||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||
|
||||
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
||||
const [editingEncounter, setEditingEncounter] =
|
||||
useState<EncounterDetail | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||
|
||||
if (isLoading || loadingRoutes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !run) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||
Failed to load run.
|
||||
</div>
|
||||
<Link
|
||||
to="/runs"
|
||||
className="inline-block mt-4 text-blue-600 hover:underline"
|
||||
>
|
||||
Back to runs
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Map routeId → encounter for quick lookup
|
||||
const encounterByRoute = new Map<number, EncounterDetail>()
|
||||
for (const enc of run.encounters) {
|
||||
encounterByRoute.set(enc.routeId, enc)
|
||||
}
|
||||
|
||||
const allRoutes = routes ?? []
|
||||
const completedCount = allRoutes.filter((r) =>
|
||||
encounterByRoute.has(r.id),
|
||||
).length
|
||||
|
||||
// Filter routes
|
||||
const filteredRoutes =
|
||||
filter === 'all'
|
||||
? allRoutes
|
||||
: allRoutes.filter((r) => {
|
||||
const enc = encounterByRoute.get(r.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
|
||||
const handleRouteClick = (route: Route) => {
|
||||
const existing = encounterByRoute.get(route.id)
|
||||
if (existing) {
|
||||
setEditingEncounter(existing)
|
||||
} else {
|
||||
setEditingEncounter(null)
|
||||
}
|
||||
setSelectedRoute(route)
|
||||
}
|
||||
|
||||
const handleCreate = (data: {
|
||||
routeId: number
|
||||
pokemonId: number
|
||||
nickname?: string
|
||||
status: EncounterStatus
|
||||
catchLevel?: number
|
||||
}) => {
|
||||
createEncounter.mutate(data, {
|
||||
onSuccess: () => {
|
||||
setSelectedRoute(null)
|
||||
setEditingEncounter(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdate = (data: {
|
||||
id: number
|
||||
data: { nickname?: string; status?: EncounterStatus; faintLevel?: number }
|
||||
}) => {
|
||||
updateEncounter.mutate(data, {
|
||||
onSuccess: () => {
|
||||
setSelectedRoute(null)
|
||||
setEditingEncounter(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/runs/${runId}`}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
||||
>
|
||||
← {run.name}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Encounters
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{run.game.name} · {completedCount} / {allRoutes.length} routes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-6">
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${allRoutes.length > 0 ? (completedCount / allRoutes.length) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'none', label: 'Unvisited' },
|
||||
{ key: 'caught', label: 'Caught' },
|
||||
{ key: 'fainted', label: 'Fainted' },
|
||||
{ key: 'missed', label: 'Missed' },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Route list */}
|
||||
<div className="space-y-1">
|
||||
{filteredRoutes.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
||||
No routes match this filter
|
||||
</p>
|
||||
)}
|
||||
{filteredRoutes.map((route) => {
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const rs = getRouteStatus(encounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => handleRouteClick(route)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{route.name}
|
||||
</div>
|
||||
{encounter && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{encounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={encounter.pokemon.spriteUrl}
|
||||
alt={encounter.pokemon.name}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{encounter.nickname ?? encounter.pokemon.name}
|
||||
{encounter.status === 'caught' &&
|
||||
encounter.faintLevel !== null &&
|
||||
' (dead)'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||
{si.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Encounter Modal */}
|
||||
{selectedRoute && (
|
||||
<EncounterModal
|
||||
route={selectedRoute}
|
||||
existing={editingEncounter ?? undefined}
|
||||
onSubmit={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onClose={() => {
|
||||
setSelectedRoute(null)
|
||||
setEditingEncounter(null)
|
||||
}}
|
||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
frontend/src/pages/RunList.tsx
Normal file
89
frontend/src/pages/RunList.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useRuns } from '../hooks/useRuns'
|
||||
import type { RunStatus } from '../types'
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
completed:
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
}
|
||||
|
||||
export function RunList() {
|
||||
const { data: runs, isLoading, error } = useRuns()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Your Runs
|
||||
</h1>
|
||||
<Link
|
||||
to="/runs/new"
|
||||
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"
|
||||
>
|
||||
Start New Run
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||
Failed to load runs. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runs && runs.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 mb-4">
|
||||
No runs yet. Start your first Nuzlocke!
|
||||
</p>
|
||||
<Link
|
||||
to="/runs/new"
|
||||
className="inline-block px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Start New Run
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runs && runs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{runs.map((run) => (
|
||||
<Link
|
||||
key={run.id}
|
||||
to={`/runs/${run.id}`}
|
||||
className="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{run.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Started{' '}
|
||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[run.status]}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Home } from './Home'
|
||||
export { NewRun } from './NewRun'
|
||||
export { Dashboard } from './Dashboard'
|
||||
export { Encounters } from './Encounters'
|
||||
export { RunList } from './RunList'
|
||||
export { RunDashboard } from './RunDashboard'
|
||||
export { RunEncounters } from './RunEncounters'
|
||||
|
||||
Reference in New Issue
Block a user