2026-02-09 12:11:54 +01:00
|
|
|
import { useMemo, useState } from 'react'
|
2026-02-05 15:28:50 +01:00
|
|
|
import { useParams, Link } from 'react-router-dom'
|
2026-02-11 21:36:50 +01:00
|
|
|
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
2026-02-05 15:28:50 +01:00
|
|
|
import { useGameRoutes } from '../hooks/useGames'
|
2026-02-08 21:47:35 +01:00
|
|
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
2026-02-16 20:39:41 +01:00
|
|
|
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
2026-02-05 18:36:08 +01:00
|
|
|
import type { RunStatus, EncounterDetail } from '../types'
|
2026-02-05 15:28:50 +01:00
|
|
|
|
2026-02-09 12:11:54 +01:00
|
|
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
|
|
|
|
|
2026-02-16 20:39:41 +01:00
|
|
|
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
2026-02-09 12:11:54 +01:00
|
|
|
return [...encounters].sort((a, b) => {
|
|
|
|
|
switch (key) {
|
|
|
|
|
case 'route':
|
|
|
|
|
return a.route.order - b.route.order
|
|
|
|
|
case 'level':
|
|
|
|
|
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
|
|
|
|
|
case 'species': {
|
|
|
|
|
const nameA = (a.currentPokemon ?? a.pokemon).name
|
|
|
|
|
const nameB = (b.currentPokemon ?? b.pokemon).name
|
|
|
|
|
return nameA.localeCompare(nameB)
|
|
|
|
|
}
|
|
|
|
|
case 'dex':
|
2026-02-14 16:41:24 +01:00
|
|
|
return (
|
2026-02-16 20:39:41 +01:00
|
|
|
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
2026-02-14 16:41:24 +01:00
|
|
|
)
|
2026-02-09 12:11:54 +01:00
|
|
|
default:
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
const statusStyles: Record<RunStatus, string> = {
|
2026-02-20 20:48:16 +01:00
|
|
|
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
|
|
|
|
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
|
|
|
|
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
2026-02-05 15:28:50 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 13:12:56 +01:00
|
|
|
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`
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
export function RunDashboard() {
|
|
|
|
|
const { runId } = useParams<{ runId: string }>()
|
2026-02-05 18:36:08 +01:00
|
|
|
const runIdNum = Number(runId)
|
|
|
|
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
2026-02-05 15:28:50 +01:00
|
|
|
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
2026-02-08 21:47:35 +01:00
|
|
|
const createEncounter = useCreateEncounter(runIdNum)
|
2026-02-05 18:36:08 +01:00
|
|
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
2026-02-07 13:12:56 +01:00
|
|
|
const updateRun = useUpdateRun(runIdNum)
|
2026-02-11 21:36:50 +01:00
|
|
|
const { data: namingCategories } = useNamingCategories()
|
2026-02-16 20:39:41 +01:00
|
|
|
const [selectedEncounter, setSelectedEncounter] = useState<EncounterDetail | null>(null)
|
2026-02-07 13:12:56 +01:00
|
|
|
const [showEndRun, setShowEndRun] = useState(false)
|
2026-02-09 12:11:54 +01:00
|
|
|
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
2026-02-05 15:28:50 +01:00
|
|
|
|
2026-02-09 12:21:07 +01:00
|
|
|
const encounters = run?.encounters ?? []
|
|
|
|
|
const alive = useMemo(
|
2026-02-14 16:41:24 +01:00
|
|
|
() =>
|
|
|
|
|
sortEncounters(
|
2026-02-16 20:39:41 +01:00
|
|
|
encounters.filter((e) => e.status === 'caught' && e.faintLevel === null),
|
2026-02-14 16:41:24 +01:00
|
|
|
teamSort
|
|
|
|
|
),
|
|
|
|
|
[encounters, teamSort]
|
2026-02-09 12:21:07 +01:00
|
|
|
)
|
|
|
|
|
const dead = useMemo(
|
2026-02-14 16:41:24 +01:00
|
|
|
() =>
|
|
|
|
|
sortEncounters(
|
2026-02-16 20:39:41 +01:00
|
|
|
encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
|
2026-02-14 16:41:24 +01:00
|
|
|
teamSort
|
|
|
|
|
),
|
|
|
|
|
[encounters, teamSort]
|
2026-02-09 12:21:07 +01:00
|
|
|
)
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
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">
|
2026-02-17 20:48:42 +01:00
|
|
|
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
|
2026-02-05 15:28:50 +01:00
|
|
|
Failed to load run. It may not exist.
|
|
|
|
|
</div>
|
2026-02-16 20:39:41 +01:00
|
|
|
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
|
2026-02-05 15:28:50 +01:00
|
|
|
Back to runs
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 13:12:56 +01:00
|
|
|
const isActive = run.status === 'active'
|
2026-02-05 15:28:50 +01:00
|
|
|
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"
|
2026-02-17 20:48:42 +01:00
|
|
|
className="text-sm text-text-tertiary hover:text-text-primary mb-2 inline-block"
|
2026-02-05 15:28:50 +01:00
|
|
|
>
|
|
|
|
|
← All Runs
|
|
|
|
|
</Link>
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div>
|
2026-02-17 20:48:42 +01:00
|
|
|
<h1 className="text-3xl font-bold text-text-primary">{run.name}</h1>
|
|
|
|
|
<p className="text-text-tertiary mt-1">
|
2026-02-14 16:41:24 +01:00
|
|
|
{run.game.name} ·{' '}
|
2026-02-16 20:39:41 +01:00
|
|
|
{run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
2026-02-05 15:28:50 +01:00
|
|
|
{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>
|
|
|
|
|
|
2026-02-07 13:12:56 +01:00
|
|
|
{/* Completion Banner */}
|
|
|
|
|
{!isActive && (
|
|
|
|
|
<div
|
|
|
|
|
className={`rounded-lg p-4 mb-6 ${
|
|
|
|
|
run.status === 'completed'
|
2026-02-17 21:08:53 +01:00
|
|
|
? 'bg-blue-900/20 border border-blue-800'
|
|
|
|
|
: 'bg-status-failed-bg border border-red-800'
|
2026-02-07 13:12:56 +01:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-02-14 16:41:24 +01:00
|
|
|
<span className="text-2xl">
|
|
|
|
|
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}
|
|
|
|
|
</span>
|
2026-02-07 13:12:56 +01:00
|
|
|
<div>
|
|
|
|
|
<p
|
|
|
|
|
className={`font-semibold ${
|
2026-02-17 21:08:53 +01:00
|
|
|
run.status === 'completed' ? 'text-blue-200' : 'text-red-200'
|
2026-02-07 13:12:56 +01:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{run.status === 'completed' ? 'Victory!' : 'Defeat'}
|
|
|
|
|
</p>
|
|
|
|
|
<p
|
|
|
|
|
className={`text-sm ${
|
2026-02-17 20:48:42 +01:00
|
|
|
run.status === 'completed' ? 'text-text-link' : 'text-status-failed'
|
2026-02-07 13:12:56 +01:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
{/* Stats */}
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
2026-02-16 20:39:41 +01:00
|
|
|
<StatCard label="Encounters" value={run.encounters.length} color="blue" />
|
2026-02-05 15:28:50 +01:00
|
|
|
<StatCard label="Alive" value={alive.length} color="green" />
|
|
|
|
|
<StatCard label="Deaths" value={dead.length} color="red" />
|
2026-02-16 20:39:41 +01:00
|
|
|
<StatCard label="Routes Visited" value={visitedRoutes} total={totalRoutes} color="purple" />
|
2026-02-05 15:28:50 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Rules */}
|
|
|
|
|
<div className="mb-6">
|
2026-02-17 20:48:42 +01:00
|
|
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Active Rules</h2>
|
2026-02-05 15:28:50 +01:00
|
|
|
<RuleBadges rules={run.rules} />
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-11 21:36:50 +01:00
|
|
|
{/* Naming Scheme */}
|
|
|
|
|
{namingCategories && namingCategories.length > 0 && (
|
|
|
|
|
<div className="mb-6">
|
2026-02-17 20:48:42 +01:00
|
|
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
|
2026-02-11 21:36:50 +01:00
|
|
|
{isActive ? (
|
|
|
|
|
<select
|
|
|
|
|
value={run.namingScheme ?? ''}
|
2026-02-16 20:39:41 +01:00
|
|
|
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
2026-02-11 21:36:50 +01:00
|
|
|
>
|
|
|
|
|
<option value="">None</option>
|
|
|
|
|
{namingCategories.map((cat) => (
|
|
|
|
|
<option key={cat} value={cat}>
|
|
|
|
|
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
) : (
|
2026-02-17 20:48:42 +01:00
|
|
|
<span className="text-sm text-text-primary">
|
2026-02-11 21:36:50 +01:00
|
|
|
{run.namingScheme
|
2026-02-16 20:39:41 +01:00
|
|
|
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
|
2026-02-11 21:36:50 +01:00
|
|
|
: 'None'}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
{/* Active Team */}
|
|
|
|
|
<div className="mb-6">
|
2026-02-09 12:11:54 +01:00
|
|
|
<div className="flex items-center justify-between mb-3">
|
2026-02-17 20:48:42 +01:00
|
|
|
<h2 className="text-lg font-semibold text-text-primary">
|
2026-02-09 12:11:54 +01:00
|
|
|
{isActive ? 'Active Team' : 'Final Team'}
|
|
|
|
|
</h2>
|
|
|
|
|
{alive.length > 1 && (
|
|
|
|
|
<select
|
|
|
|
|
value={teamSort}
|
|
|
|
|
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
2026-02-09 12:11:54 +01:00
|
|
|
>
|
|
|
|
|
<option value="route">Route Order</option>
|
|
|
|
|
<option value="level">Catch Level</option>
|
|
|
|
|
<option value="species">Species Name</option>
|
|
|
|
|
<option value="dex">National Dex</option>
|
|
|
|
|
</select>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-05 15:28:50 +01:00
|
|
|
{alive.length === 0 ? (
|
2026-02-17 20:48:42 +01:00
|
|
|
<p className="text-text-tertiary text-sm">
|
2026-02-16 20:39:41 +01:00
|
|
|
No pokemon caught yet — head to encounters to start building your team!
|
2026-02-05 15:28:50 +01:00
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
|
|
|
{alive.map((enc) => (
|
2026-02-05 18:36:08 +01:00
|
|
|
<PokemonCard
|
|
|
|
|
key={enc.id}
|
|
|
|
|
encounter={enc}
|
2026-02-07 13:12:56 +01:00
|
|
|
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
2026-02-05 18:36:08 +01:00
|
|
|
/>
|
2026-02-05 15:28:50 +01:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Graveyard */}
|
|
|
|
|
{dead.length > 0 && (
|
|
|
|
|
<div className="mb-6">
|
2026-02-17 20:48:42 +01:00
|
|
|
<h2 className="text-lg font-semibold text-text-primary mb-3">Graveyard</h2>
|
2026-02-05 15:28:50 +01:00
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
|
|
|
{dead.map((enc) => (
|
2026-02-05 18:36:08 +01:00
|
|
|
<PokemonCard
|
|
|
|
|
key={enc.id}
|
|
|
|
|
encounter={enc}
|
|
|
|
|
showFaintLevel
|
2026-02-07 13:12:56 +01:00
|
|
|
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
2026-02-05 18:36:08 +01:00
|
|
|
/>
|
2026-02-05 15:28:50 +01:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Quick Actions */}
|
2026-02-07 13:12:56 +01:00
|
|
|
<div className="mt-8 flex gap-3">
|
|
|
|
|
{isActive && (
|
|
|
|
|
<>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/runs/${runId}/encounters`}
|
2026-02-17 20:48:42 +01:00
|
|
|
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-accent-400 focus:ring-offset-2 transition-colors"
|
2026-02-07 13:12:56 +01:00
|
|
|
>
|
|
|
|
|
Log Encounter
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowEndRun(true)}
|
2026-02-17 20:48:42 +01:00
|
|
|
className="px-4 py-2 border border-border-default rounded-lg font-medium hover:bg-surface-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 transition-colors"
|
2026-02-07 13:12:56 +01:00
|
|
|
>
|
|
|
|
|
End Run
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-02-05 15:28:50 +01:00
|
|
|
</div>
|
2026-02-05 18:36:08 +01:00
|
|
|
|
|
|
|
|
{/* Status Change Modal */}
|
|
|
|
|
{selectedEncounter && (
|
|
|
|
|
<StatusChangeModal
|
|
|
|
|
encounter={selectedEncounter}
|
|
|
|
|
onUpdate={(data) => {
|
|
|
|
|
updateEncounter.mutate(data, {
|
|
|
|
|
onSuccess: () => setSelectedEncounter(null),
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
onClose={() => setSelectedEncounter(null)}
|
|
|
|
|
isPending={updateEncounter.isPending}
|
2026-02-07 20:05:07 +01:00
|
|
|
region={run?.game.region}
|
2026-02-08 21:47:35 +01:00
|
|
|
onCreateEncounter={(data) => {
|
|
|
|
|
createEncounter.mutate(data)
|
|
|
|
|
}}
|
2026-02-05 18:36:08 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-07 13:12:56 +01:00
|
|
|
|
|
|
|
|
{/* End Run Modal */}
|
|
|
|
|
{showEndRun && (
|
|
|
|
|
<EndRunModal
|
|
|
|
|
onConfirm={(status) => {
|
2026-02-16 20:39:41 +01:00
|
|
|
updateRun.mutate({ status }, { onSuccess: () => setShowEndRun(false) })
|
2026-02-07 13:12:56 +01:00
|
|
|
}}
|
|
|
|
|
onClose={() => setShowEndRun(false)}
|
|
|
|
|
isPending={updateRun.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-05 15:28:50 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|