Add sort dropdown to RunEncounters (the encounters page with the expandable team section) and move all useMemo hooks before early returns in both RunDashboard and RunEncounters to fix React hook ordering violations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { useParams, Link } from 'react-router-dom'
|
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
|
import { useGameRoutes } from '../hooks/useGames'
|
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
|
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
|
import type { RunStatus, EncounterDetail } from '../types'
|
|
|
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
|
|
|
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
|
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':
|
|
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
}
|
|
|
|
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',
|
|
}
|
|
|
|
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 createEncounter = useCreateEncounter(runIdNum)
|
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
|
const updateRun = useUpdateRun(runIdNum)
|
|
const [selectedEncounter, setSelectedEncounter] =
|
|
useState<EncounterDetail | null>(null)
|
|
const [showEndRun, setShowEndRun] = useState(false)
|
|
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
|
|
|
const encounters = run?.encounters ?? []
|
|
const alive = useMemo(
|
|
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort),
|
|
[encounters, teamSort],
|
|
)
|
|
const dead = useMemo(
|
|
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), teamSort),
|
|
[encounters, teamSort],
|
|
)
|
|
|
|
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 isActive = run.status === 'active'
|
|
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.charAt(0).toUpperCase() + run.game.region.slice(1)} · 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>
|
|
|
|
{/* 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
|
|
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">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{isActive ? 'Active Team' : 'Final Team'}
|
|
</h2>
|
|
{alive.length > 1 && (
|
|
<select
|
|
value={teamSort}
|
|
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<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>
|
|
{alive.length === 0 ? (
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
|
No pokemon caught yet — head to encounters to start building your
|
|
team!
|
|
</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}
|
|
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</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
|
|
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Actions */}
|
|
<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 */}
|
|
{selectedEncounter && (
|
|
<StatusChangeModal
|
|
encounter={selectedEncounter}
|
|
onUpdate={(data) => {
|
|
updateEncounter.mutate(data, {
|
|
onSuccess: () => setSelectedEncounter(null),
|
|
})
|
|
}}
|
|
onClose={() => setSelectedEncounter(null)}
|
|
isPending={updateEncounter.isPending}
|
|
region={run?.game.region}
|
|
onCreateEncounter={(data) => {
|
|
createEncounter.mutate(data)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* End Run Modal */}
|
|
{showEndRun && (
|
|
<EndRunModal
|
|
onConfirm={(status) => {
|
|
updateRun.mutate(
|
|
{ status },
|
|
{ onSuccess: () => setShowEndRun(false) },
|
|
)
|
|
}}
|
|
onClose={() => setShowEndRun(false)}
|
|
isPending={updateRun.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|