import { useState, useMemo, useEffect, useCallback } 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 { EncounterModal, EncounterMethodBadge, StatCard, PokemonCard, StatusChangeModal, EndRunModal, RuleBadges, } from '../components' import type { Route, RouteWithChildren, RunStatus, EncounterDetail, EncounterStatus, } from '../types' const statusStyles: Record = { 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 { 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: '' }, } /** * Organize flat routes into hierarchical structure. * Routes with parentRouteId are grouped under their parent. */ function organizeRoutes(routes: Route[]): RouteWithChildren[] { const childrenByParent = new Map() const topLevel: Route[] = [] for (const route of routes) { if (route.parentRouteId === null) { topLevel.push(route) } else { const children = childrenByParent.get(route.parentRouteId) ?? [] children.push(route) childrenByParent.set(route.parentRouteId, children) } } return topLevel.map((route) => ({ ...route, children: childrenByParent.get(route.id) ?? [], })) } /** * Check if any child route in a group has an encounter. * Returns the encounter if found, null otherwise. */ function getGroupEncounter( group: RouteWithChildren, encounterByRoute: Map, ): EncounterDetail | null { for (const child of group.children) { const enc = encounterByRoute.get(child.id) if (enc) return enc } return null } interface RouteGroupProps { group: RouteWithChildren encounterByRoute: Map isExpanded: boolean onToggleExpand: () => void onRouteClick: (route: Route) => void filter: 'all' | RouteStatus } function RouteGroup({ group, encounterByRoute, isExpanded, onToggleExpand, onRouteClick, filter, }: RouteGroupProps) { const groupEncounter = getGroupEncounter(group, encounterByRoute) const groupStatus = groupEncounter ? groupEncounter.status : 'none' const si = statusIndicator[groupStatus] // For groups, check if it matches the filter if (filter !== 'all' && groupStatus !== filter) { return null } const hasGroupEncounter = groupEncounter !== null return (
{/* Group header */} {/* Expanded children */} {isExpanded && (
{group.children.map((child) => { const childEncounter = encounterByRoute.get(child.id) const childStatus = getRouteStatus(childEncounter) const childSi = statusIndicator[childStatus] const isDisabled = hasGroupEncounter && !childEncounter return ( ) })}
)}
) } 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 updateRun = useUpdateRun(runIdNum) const [selectedRoute, setSelectedRoute] = useState(null) const [editingEncounter, setEditingEncounter] = useState(null) const [selectedTeamEncounter, setSelectedTeamEncounter] = useState(null) const [showEndRun, setShowEndRun] = useState(false) const [showTeam, setShowTeam] = useState(true) const [filter, setFilter] = useState<'all' | RouteStatus>('all') const storageKey = `expandedGroups-${runId}` const [expandedGroups, setExpandedGroups] = useState>(() => { try { const saved = localStorage.getItem(storageKey) if (saved) return new Set(JSON.parse(saved) as number[]) } catch { /* ignore */ } return new Set() }) const updateExpandedGroups = useCallback( (updater: (prev: Set) => Set) => { setExpandedGroups((prev) => { const next = updater(prev) localStorage.setItem(storageKey, JSON.stringify([...next])) return next }) }, [storageKey], ) // Organize routes into hierarchical structure const organizedRoutes = useMemo(() => { if (!routes) return [] return organizeRoutes(routes) }, [routes]) // Map routeId → encounter for quick lookup const encounterByRoute = useMemo(() => { const map = new Map() 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 (
) } if (error || !run) { return (
Failed to load run.
Back to runs
) } // Count completed locations (groups count as 1, standalone routes count as 1) const completedCount = organizedRoutes.filter((r) => { if (r.children.length > 0) { // It's a group - check if any child has an encounter return getGroupEncounter(r, encounterByRoute) !== null } // Standalone route return encounterByRoute.has(r.id) }).length 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) => { updateExpandedGroups((prev) => { const next = new Set(prev) if (next.has(groupId)) { next.delete(groupId) } else { next.add(groupId) } return next }) } 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 deathCause?: string } }) => { updateEncounter.mutate(data, { onSuccess: () => { setSelectedRoute(null) setEditingEncounter(null) }, }) } // Filter routes const filteredRoutes = organizedRoutes.filter((r) => { if (filter === 'all') return true if (r.children.length > 0) { // It's a group const groupEnc = getGroupEncounter(r, encounterByRoute) return getRouteStatus(groupEnc ?? undefined) === filter } // Standalone route const enc = encounterByRoute.get(r.id) return getRouteStatus(enc) === filter }) return (
{/* Header */}
← All Runs

{run.name}

{run.game.name} · {run.game.region} · Started{' '} {new Date(run.startedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', })}

{isActive && ( )} {run.status}
{/* Completion Banner */} {!isActive && (
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}

{run.status === 'completed' ? 'Victory!' : 'Defeat'}

{run.completedAt && ( <> Ended{' '} {new Date(run.completedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', })} {' \u00b7 '} Duration: {formatDuration(run.startedAt, run.completedAt)} )}

)} {/* Stats */}
{/* Rules */}

Active Rules

{/* Team Section */} {(alive.length > 0 || dead.length > 0) && (
{showTeam && ( <> {alive.length > 0 && (
{alive.map((enc) => ( setSelectedTeamEncounter(enc) : undefined} /> ))}
)} {dead.length > 0 && ( <>

Graveyard

{dead.map((enc) => ( setSelectedTeamEncounter(enc) : undefined} /> ))}
)} )}
)} {/* Progress bar */}

Encounters

{completedCount} / {totalLocations} locations
0 ? (completedCount / totalLocations) * 100 : 0}%`, }} />
{/* Filter tabs */}
{( [ { 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 }) => ( ))}
{/* Route list */}
{filteredRoutes.length === 0 && (

{filter === 'all' ? 'Click a route to log your first encounter' : 'No routes match this filter — try a different one'}

)} {filteredRoutes.map((route) => { // Render as group if it has children if (route.children.length > 0) { return ( toggleGroup(route.id)} onRouteClick={handleRouteClick} filter={filter} /> ) } // Standalone route (no children) const encounter = encounterByRoute.get(route.id) const rs = getRouteStatus(encounter) const si = statusIndicator[rs] return ( ) })}
{/* Encounter Modal */} {selectedRoute && ( { setSelectedRoute(null) setEditingEncounter(null) }} isPending={createEncounter.isPending || updateEncounter.isPending} /> )} {/* Status Change Modal (team pokemon) */} {selectedTeamEncounter && ( { updateEncounter.mutate(data, { onSuccess: () => setSelectedTeamEncounter(null), }) }} onClose={() => setSelectedTeamEncounter(null)} isPending={updateEncounter.isPending} /> )} {/* End Run Modal */} {showEndRun && ( { updateRun.mutate( { status }, { onSuccess: () => setShowEndRun(false) }, ) }} onClose={() => setShowEndRun(false)} isPending={updateRun.isPending} /> )}
) }