Improve UX with merged run view, method badges, grouped encounters, and mobile nav

Merges the run dashboard into the encounters page as a unified view at /runs/:runId,
adds encounter method grouping in the modal and badges on route rows, improves the
home page with recent runs, adds mobile hamburger nav, and polishes empty states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 14:20:26 +01:00
parent 0beb287365
commit 5edda2dba9
14 changed files with 821 additions and 109 deletions

View File

@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
import { AdminLayout } from './components/admin'
import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages'
import { Home, NewRun, RunList, RunEncounters } from './pages'
import {
AdminGames,
AdminGameDetail,
@@ -17,8 +17,8 @@ function App() {
<Route index element={<Home />} />
<Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunDashboard />} />
<Route path="runs/:runId/encounters" element={<RunEncounters />} />
<Route path="runs/:runId" element={<RunEncounters />} />
<Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} />
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/games" replace />} />
<Route path="games" element={<AdminGames />} />

View File

@@ -0,0 +1,99 @@
const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
starter: {
label: 'Starter',
color:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
gift: {
label: 'Gift',
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
},
fossil: {
label: 'Fossil',
color:
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
trade: {
label: 'Trade',
color:
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
walk: {
label: 'Grass',
color:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
},
headbutt: {
label: 'Headbutt',
color: 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
},
surf: {
label: 'Surfing',
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
},
'rock-smash': {
label: 'Rock Smash',
color:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
'old-rod': {
label: 'Old Rod',
color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
},
'good-rod': {
label: 'Good Rod',
color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
},
'super-rod': {
label: 'Super Rod',
color:
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
},
}
/** Display order for encounter method groups */
export const METHOD_ORDER = [
'starter',
'gift',
'fossil',
'trade',
'walk',
'headbutt',
'surf',
'rock-smash',
'old-rod',
'good-rod',
'super-rod',
]
export function getMethodLabel(method: string): string {
return (
METHOD_CONFIG[method]?.label ??
method
.replace(/-/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
)
}
export function getMethodColor(method: string): string {
return METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}
export function EncounterMethodBadge({
method,
size = 'sm',
}: {
method: string
size?: 'sm' | 'xs'
}) {
const config = METHOD_CONFIG[method]
if (!config) return null
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
return (
<span
className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}
>
{config.label}
</span>
)
}

View File

@@ -1,5 +1,10 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import {
EncounterMethodBadge,
getMethodLabel,
METHOD_ORDER,
} from './EncounterMethodBadge'
import type {
Route,
EncounterDetail,
@@ -52,38 +57,22 @@ const statusOptions: { value: EncounterStatus; label: string; color: string }[]
},
]
const specialMethodStyles: Record<string, { label: string; color: string }> = {
starter: {
label: 'Starter',
color:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
gift: {
label: 'Gift',
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300',
},
fossil: {
label: 'Fossil',
color:
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
trade: {
label: 'Trade',
color:
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
}
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
function EncounterMethodBadge({ method }: { method: string }) {
const config = specialMethodStyles[method]
if (!config) return null
return (
<span
className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${config.color}`}
>
{config.label}
</span>
)
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
const groups = new Map<string, RouteEncounterDetail[]>()
for (const rp of pokemon) {
const list = groups.get(rp.encounterMethod) ?? []
list.push(rp)
groups.set(rp.encounterMethod, list)
}
return [...groups.entries()]
.sort(([a], [b]) => {
const ai = METHOD_ORDER.indexOf(a)
const bi = METHOD_ORDER.indexOf(b)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
.map(([method, pokemon]) => ({ method, pokemon }))
}
export function EncounterModal({
@@ -127,6 +116,12 @@ export function EncounterModal({
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
)
const groupedPokemon = useMemo(
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
[filteredPokemon],
)
const hasMultipleGroups = groupedPokemon.length > 1
const handleSubmit = () => {
if (isEditing && onUpdate) {
onUpdate({
@@ -206,38 +201,54 @@ export function EncounterModal({
className="w-full px-3 py-1.5 mb-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{filteredPokemon.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
selectedPokemon?.id === rp.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{rp.pokemon.spriteUrl ? (
<img
src={rp.pokemon.spriteUrl}
alt={rp.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
<div className="max-h-64 overflow-y-auto space-y-3">
{groupedPokemon.map(({ method, pokemon }, groupIdx) => (
<div key={method}>
{groupIdx > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 mb-3" />
)}
{hasMultipleGroups && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{getMethodLabel(method)}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
<EncounterMethodBadge method={rp.encounterMethod} />
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
</button>
<div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setSelectedPokemon(rp)}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
selectedPokemon?.id === rp.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{rp.pokemon.spriteUrl ? (
<img
src={rp.pokemon.spriteUrl}
alt={rp.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
</button>
))}
</div>
</div>
))}
</div>
</>

View File

@@ -1,6 +1,9 @@
import { useState } from 'react'
import { Link, Outlet } from 'react-router-dom'
export function Layout() {
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<nav className="bg-white dark:bg-gray-800 shadow-sm">
@@ -11,7 +14,8 @@ export function Layout() {
Nuzlocke Tracker
</Link>
</div>
<div className="flex items-center space-x-4">
{/* Desktop nav */}
<div className="hidden sm:flex items-center space-x-4">
<Link
to="/runs/new"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -31,8 +35,68 @@ export function Layout() {
Admin
</Link>
</div>
{/* Mobile hamburger */}
<div className="flex items-center sm:hidden">
<button
type="button"
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Toggle menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{menuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
</div>
</div>
</div>
{/* Mobile dropdown */}
{menuOpen && (
<div className="sm:hidden border-t border-gray-200 dark:border-gray-700">
<div className="px-2 pt-2 pb-3 space-y-1">
<Link
to="/runs/new"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
New Run
</Link>
<Link
to="/runs"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
My Runs
</Link>
<Link
to="/admin"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Admin
</Link>
</div>
</div>
)}
</nav>
<main>
<Outlet />

View File

@@ -1,3 +1,4 @@
export { EncounterMethodBadge } from './EncounterMethodBadge'
export { EncounterModal } from './EncounterModal'
export { EndRunModal } from './EndRunModal'
export { GameCard } from './GameCard'

View File

@@ -1,10 +1,125 @@
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 Home() {
const { data: runs, isLoading } = useRuns()
const activeRun = runs?.find((r) => r.status === 'active')
const recentRuns = runs?.slice(0, 5)
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8">
<h1 className="text-4xl font-bold mb-4">Nuzlocke Tracker</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
Track your Nuzlocke runs with ease
</p>
<div className="max-w-4xl mx-auto p-8">
<div className="text-center py-12">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Nuzlocke Tracker
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
Track your Nuzlocke runs with ease
</p>
<Link
to="/runs/new"
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg font-medium text-lg 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-8">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
{activeRun && (
<div className="mb-8">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
Continue Playing
</h2>
<Link
to={`/runs/${activeRun.id}`}
className="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow p-5 border-l-4 border-green-500"
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{activeRun.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Started{' '}
{new Date(activeRun.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div>
<span className="text-blue-600 dark:text-blue-400 font-medium text-sm">
Resume &rarr;
</span>
</div>
</Link>
</div>
)}
{recentRuns && recentRuns.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Recent Runs
</h2>
<Link
to="/runs"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View all
</Link>
</div>
<div className="space-y-2">
{recentRuns.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>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{run.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{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>
)}
{runs && runs.length === 0 && (
<p className="text-center text-gray-500 dark:text-gray-400">
No runs yet. Start your first Nuzlocke challenge above!
</p>
)}
</div>
)
}

View File

@@ -17,6 +17,10 @@ export function NewRun() {
const [runName, setRunName] = useState('')
const handleGameSelect = (game: Game) => {
if (selectedGame?.id === game.id) {
setSelectedGame(null)
return
}
setSelectedGame(game)
if (!runName || runName === `${selectedGame?.name} Nuzlocke`) {
setRunName(`${game.name} Nuzlocke`)

View File

@@ -176,7 +176,8 @@ export function RunDashboard() {
</h2>
{alive.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">
No pokemon caught yet
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">

View File

@@ -1,16 +1,40 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect, useCallback } 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 { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { EncounterModal } from '../components'
import {
EncounterModal,
EncounterMethodBadge,
StatCard,
PokemonCard,
StatusChangeModal,
EndRunModal,
RuleBadges,
} from '../components'
import type {
Route,
RouteWithChildren,
RunStatus,
EncounterDetail,
EncounterStatus,
} 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',
}
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`
}
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
@@ -189,6 +213,13 @@ function RouteGroup({
<div className="text-sm text-gray-700 dark:text-gray-300">
{child.name}
</div>
{!childEncounter && child.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{child.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)}
</div>
{childEncounter && (
<span className="text-xs text-gray-400 dark:text-gray-500">
@@ -218,12 +249,36 @@ export function RunEncounters() {
)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
const [editingEncounter, setEditingEncounter] =
useState<EncounterDetail | null>(null)
const [selectedTeamEncounter, setSelectedTeamEncounter] =
useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
const [showTeam, setShowTeam] = useState(true)
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
const storageKey = `expandedGroups-${runId}`
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => {
try {
const saved = localStorage.getItem(storageKey)
if (saved) return new Set(JSON.parse(saved) as number[])
} catch { /* ignore */ }
return new Set<number>()
})
const updateExpandedGroups = useCallback(
(updater: (prev: Set<number>) => Set<number>) => {
setExpandedGroups((prev) => {
const next = updater(prev)
localStorage.setItem(storageKey, JSON.stringify([...next]))
return next
})
},
[storageKey],
)
// Organize routes into hierarchical structure
const organizedRoutes = useMemo(() => {
@@ -231,6 +286,30 @@ export function RunEncounters() {
return organizeRoutes(routes)
}, [routes])
// Map routeId → encounter for quick lookup
const encounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>()
if (run) {
for (const enc of run.encounters) {
map.set(enc.routeId, enc)
}
}
return map
}, [run])
// Auto-expand the first unvisited group on initial load
useEffect(() => {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
const firstUnvisited = organizedRoutes.find(
(r) =>
r.children.length > 0 &&
getGroupEncounter(r, encounterByRoute) === null,
)
if (firstUnvisited) {
updateExpandedGroups(() => new Set([firstUnvisited.id]))
}
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading || loadingRoutes) {
return (
<div className="flex items-center justify-center py-16">
@@ -255,12 +334,6 @@ export function RunEncounters() {
)
}
// Map routeId → encounter for quick lookup
const encounterByRoute = new Map<number, EncounterDetail>()
for (const enc of run.encounters) {
encounterByRoute.set(enc.routeId, enc)
}
// Count completed locations (groups count as 1, standalone routes count as 1)
const completedCount = organizedRoutes.filter((r) => {
if (r.children.length > 0) {
@@ -273,8 +346,16 @@ export function RunEncounters() {
const totalLocations = organizedRoutes.length
const isActive = run.status === 'active'
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 toggleGroup = (groupId: number) => {
setExpandedGroups((prev) => {
updateExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(groupId)) {
next.delete(groupId)
@@ -347,21 +428,187 @@ export function RunEncounters() {
{/* Header */}
<div className="mb-6">
<Link
to={`/runs/${runId}`}
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"
>
&larr; {run.name}
&larr; All Runs
</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} &middot; {completedCount} / {totalLocations} locations
</p>
<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} &middot; {run.game.region} &middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div>
<div className="flex items-center gap-2">
{isActive && (
<button
onClick={() => setShowEndRun(true)}
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-full font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
End Run
</button>
)}
<span
className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${statusStyles[run.status]}`}
>
{run.status}
</span>
</div>
</div>
</div>
{/* Progress bar */}
{/* 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"
value={completedCount}
total={totalLocations}
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>
{/* Team Section */}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 mb-3 group"
>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-gray-400 dark:text-gray-500">
{alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showTeam && (
<>
{alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
)}
{dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Graveyard
</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
</>
)}
</>
)}
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Encounters
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
{completedCount} / {totalLocations} locations
</span>
</div>
<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"
@@ -401,7 +648,9 @@ export function RunEncounters() {
<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
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
{filteredRoutes.map((route) => {
@@ -439,7 +688,7 @@ export function RunEncounters() {
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{route.name}
</div>
{encounter && (
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
@@ -457,6 +706,12 @@ export function RunEncounters() {
: ' (dead)')}
</span>
</div>
) : route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
@@ -481,6 +736,34 @@ export function RunEncounters() {
isPending={createEncounter.isPending || updateEncounter.isPending}
/>
)}
{/* Status Change Modal (team pokemon) */}
{selectedTeamEncounter && (
<StatusChangeModal
encounter={selectedTeamEncounter}
onUpdate={(data) => {
updateEncounter.mutate(data, {
onSuccess: () => setSelectedTeamEncounter(null),
})
}}
onClose={() => setSelectedTeamEncounter(null)}
isPending={updateEncounter.isPending}
/>
)}
{/* End Run Modal */}
{showEndRun && (
<EndRunModal
onConfirm={(status) => {
updateRun.mutate(
{ status },
{ onSuccess: () => setShowEndRun(false) },
)
}}
onClose={() => setShowEndRun(false)}
isPending={updateRun.isPending}
/>
)}
</div>
)
}

View File

@@ -1,5 +1,4 @@
export { Home } from './Home'
export { NewRun } from './NewRun'
export { RunList } from './RunList'
export { RunDashboard } from './RunDashboard'
export { RunEncounters } from './RunEncounters'

View File

@@ -15,6 +15,7 @@ export interface Route {
gameId: number
order: number
parentRouteId: number | null
encounterMethods: string[]
}
export interface RouteWithChildren extends Route {