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, useBulkRandomize } from '../hooks/useEncounters' import { usePokemonFamilies } from '../hooks/usePokemon' import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses' import { EncounterModal, EncounterMethodBadge, StatCard, PokemonCard, StatusChangeModal, EndRunModal, RuleBadges, ShinyBox, ShinyEncounterModal, TypeBadge, } from '../components' import { BossDefeatModal } from '../components/BossDefeatModal' import type { Route, RouteWithChildren, RunStatus, EncounterDetail, EncounterStatus, CreateEncounterInput, BossBattle, BossPokemon, } 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 } /** Whether any child in this group has a pinwheelZone set. */ function groupHasZones(group: RouteWithChildren): boolean { return group.children.some((c) => c.pinwheelZone != null) } /** Get the effective zone for a route (null treated as 0). */ function effectiveZone(route: Route): number { return route.pinwheelZone ?? 0 } /** * Get encounters grouped by zone within a route group. * Returns a Map from zone number to the encounter in that zone. */ function getZoneEncounters( group: RouteWithChildren, encounterByRoute: Map, ): Map { const zoneMap = new Map() for (const child of group.children) { const enc = encounterByRoute.get(child.id) if (enc) { zoneMap.set(effectiveZone(child), enc) } } return zoneMap } /** Count distinct zones in a group. */ function countDistinctZones(group: RouteWithChildren): number { const zones = new Set(group.children.map(effectiveZone)) return zones.size } function matchVariant(labels: string[], starterName?: string | null): string | null { if (!starterName || labels.length === 0) return null const lower = starterName.toLowerCase() const matches = labels.filter((l) => l.toLowerCase().includes(lower)) return matches.length === 1 ? matches[0] : null } function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; starterName?: string | null }) { const variantLabels = useMemo(() => { const labels = new Set() for (const bp of pokemon) { if (bp.conditionLabel) labels.add(bp.conditionLabel) } return [...labels].sort() }, [pokemon]) const hasVariants = variantLabels.length > 0 const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName]) const showPills = hasVariants && autoMatch === null const [selectedVariant, setSelectedVariant] = useState( autoMatch ?? (hasVariants ? variantLabels[0] : null), ) const displayed = useMemo(() => { if (!hasVariants) return pokemon return pokemon.filter( (bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null, ) }, [pokemon, hasVariants, selectedVariant]) return (
{showPills && (
{variantLabels.map((label) => ( ))}
)}
{[...displayed] .sort((a, b) => a.order - b.order) .map((bp) => (
{bp.pokemon.spriteUrl ? ( {bp.pokemon.name} ) : (
)} Lvl {bp.level}
))}
) } interface RouteGroupProps { group: RouteWithChildren encounterByRoute: Map isExpanded: boolean onToggleExpand: () => void onRouteClick: (route: Route) => void filter: 'all' | RouteStatus pinwheelClause: boolean } function RouteGroup({ group, encounterByRoute, isExpanded, onToggleExpand, onRouteClick, filter, pinwheelClause, }: RouteGroupProps) { const groupEncounter = getGroupEncounter(group, encounterByRoute) const usePinwheel = pinwheelClause && groupHasZones(group) const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null // For pinwheel groups, determine status from all zone statuses let groupStatus: RouteStatus if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) { // Use the first encounter's status as representative for the header groupStatus = groupEncounter ? groupEncounter.status : 'none' } else { groupStatus = groupEncounter ? groupEncounter.status : 'none' } const si = statusIndicator[groupStatus] // For groups, check if it matches the filter if (filter !== 'all') { if (usePinwheel) { // Show group if any zone matches the filter const anyChildMatches = group.children.some((child) => { const enc = encounterByRoute.get(child.id) return getRouteStatus(enc) === filter }) // Also check children without encounters (for 'none' filter) if (!anyChildMatches) return null } else if (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] let isDisabled: boolean if (usePinwheel && zoneEncounters) { // Zone-aware: only lock if this child's zone already has an encounter const myZone = effectiveZone(child) isDisabled = zoneEncounters.has(myZone) && !childEncounter } else { // Classic: whole group shares one encounter 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 bulkRandomize = useBulkRandomize(runIdNum) const updateRun = useUpdateRun(runIdNum) const { data: familiesData } = usePokemonFamilies() const { data: bosses } = useGameBosses(run?.gameId ?? null) const { data: bossResults } = useBossResults(runIdNum) const createBossResult = useCreateBossResult(runIdNum) const [selectedRoute, setSelectedRoute] = useState(null) const [selectedBoss, setSelectedBoss] = useState(null) const [editingEncounter, setEditingEncounter] = useState(null) const [selectedTeamEncounter, setSelectedTeamEncounter] = useState(null) const [showEndRun, setShowEndRun] = useState(false) const [showShinyModal, setShowShinyModal] = useState(false) const [expandedBosses, setExpandedBosses] = useState>(new Set()) 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]) // Split encounters into normal (non-shiny) and shiny const { normalEncounters, shinyEncounters } = useMemo(() => { if (!run) return { normalEncounters: [], shinyEncounters: [] } const normal: EncounterDetail[] = [] const shiny: EncounterDetail[] = [] for (const enc of run.encounters) { if (enc.isShiny) { shiny.push(enc) } else { normal.push(enc) } } return { normalEncounters: normal, shinyEncounters: shiny } }, [run]) // Map routeId → encounter for quick lookup (normal encounters only) const encounterByRoute = useMemo(() => { const map = new Map() for (const enc of normalEncounters) { map.set(enc.routeId, enc) } return map }, [normalEncounters]) // Build set of duped Pokemon IDs (for duplicates clause) const dupedPokemonIds = useMemo(() => { const dupesClauseOn = run?.rules?.duplicatesClause ?? true if (!dupesClauseOn || !familiesData) return undefined // Build pokemonId → family members map const pokemonToFamily = new Map() for (const family of familiesData.families) { for (const id of family) { pokemonToFamily.set(id, family) } } const duped = new Set() for (const enc of normalEncounters) { if (enc.status !== 'caught') continue const pokemonId = enc.currentPokemonId ?? enc.pokemonId // Add the pokemon itself and all family members duped.add(pokemonId) duped.add(enc.pokemonId) const family = pokemonToFamily.get(pokemonId) if (family) { for (const memberId of family) { duped.add(memberId) } } // Also check original pokemon's family const origFamily = pokemonToFamily.get(enc.pokemonId) if (origFamily) { for (const memberId of origFamily) { duped.add(memberId) } } } return duped.size > 0 ? duped : undefined }, [run, normalEncounters, familiesData]) // Find starter Pokemon name for auto-matching variant boss teams // Note: enc.route from the run detail doesn't include encounterMethods // (it's computed only in the game routes endpoint), so we look up the // route from the separately-fetched routes data instead. const starterName = useMemo(() => { if (!routes) return null const routeMap = new Map(routes.map((r) => [r.id, r])) for (const enc of normalEncounters) { const route = routeMap.get(enc.routeId) if (route?.encounterMethods.includes('starter')) { return enc.pokemon.name } } return null }, [normalEncounters, routes]) // Boss battle data const defeatedBossIds = useMemo(() => { const set = new Set() if (bossResults) { for (const r of bossResults) { if (r.result === 'won') set.add(r.bossBattleId) } } return set }, [bossResults]) const sortedBosses = useMemo(() => { if (!bosses) return [] return [...bosses].sort((a, b) => a.order - b.order) }, [bosses]) const nextBoss = useMemo(() => { return sortedBosses.find((b) => !defeatedBossIds.has(b.id)) ?? null }, [sortedBosses, defeatedBossIds]) const currentLevelCap = useMemo(() => { if (!nextBoss) { // All defeated — no cap (or use last boss's level) return sortedBosses.length > 0 ? sortedBosses[sortedBosses.length - 1].levelCap : null } return nextBoss.levelCap }, [nextBoss, sortedBosses]) // Pre-compute which bosses get a section divider rendered AFTER them // (when the next boss in order has a different section) const sectionDividerAfterBoss = useMemo(() => { const map = new Map() for (let i = 0; i < sortedBosses.length - 1; i++) { const current = sortedBosses[i] const next = sortedBosses[i + 1] if (next.section != null && current.section !== next.section) { map.set(current.id, next.section) } } return map }, [sortedBosses]) // Map afterRouteId → BossBattle[] for interleaving const bossesAfterRoute = useMemo(() => { const map = new Map() if (!bosses) return map for (const boss of bosses) { if (boss.afterRouteId != null) { const list = map.get(boss.afterRouteId) ?? [] list.push(boss) map.set(boss.afterRouteId, list) } } return map }, [bosses]) // 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
) } const pinwheelClause = run.rules?.pinwheelClause ?? true // Count completed locations (zone-aware when pinwheel clause is on) let completedCount = 0 let totalLocations = 0 for (const r of organizedRoutes) { if (r.children.length > 0) { const usePinwheel = pinwheelClause && groupHasZones(r) if (usePinwheel) { const distinctZones = countDistinctZones(r) const zoneEncs = getZoneEncounters(r, encounterByRoute) totalLocations += distinctZones completedCount += zoneEncs.size } else { totalLocations += 1 if (getGroupEncounter(r, encounterByRoute) !== null) { completedCount += 1 } } } else { totalLocations += 1 if (encounterByRoute.has(r.id)) { completedCount += 1 } } } const isActive = run.status === 'active' const alive = normalEncounters.filter( (e) => e.status === 'caught' && e.faintLevel === null, ) const dead = normalEncounters.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: CreateEncounterInput) => { createEncounter.mutate(data, { onSuccess: () => { setSelectedRoute(null) setEditingEncounter(null) setShowShinyModal(false) }, }) } 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) { const usePinwheel = pinwheelClause && groupHasZones(r) if (usePinwheel) { // Show group if any child/zone matches the filter return r.children.some((child) => { const enc = encounterByRoute.get(child.id) return getRouteStatus(enc) === filter }) } // Classic: single status for whole 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.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '} {new Date(run.startedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', })}

{isActive && run.rules?.shinyClause && ( )} {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 */}
{/* Level Cap Bar */} {run.rules?.levelCaps && sortedBosses.length > 0 && (
Level Cap: {currentLevelCap ?? '—'} {nextBoss && ( Next: {nextBoss.name} )} {!nextBoss && ( All bosses defeated! )}
{defeatedBossIds.size}/{sortedBosses.length} defeated
{/* Badge row — gym leaders only */} {sortedBosses.some((b) => b.bossType === 'gym_leader') && (
{sortedBosses .filter((b) => b.bossType === 'gym_leader') .map((boss) => { const earned = defeatedBossIds.has(boss.id) return (
{boss.badgeImageUrl ? ( {boss.badgeName ) : (
{boss.order}
)}
) })}
)}
)} {/* 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} /> ))}
)} )}
)} {/* Shiny Box */} {run.rules?.shinyClause && shinyEncounters.length > 0 && (
setSelectedTeamEncounter(enc) : undefined} />
)} {/* Progress bar */}

Encounters

{isActive && completedCount < totalLocations && ( )}
{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) => { // Collect all route IDs to check for boss cards after const routeIds: number[] = route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id] // Find boss battles positioned after this route (or any of its children) const bossesHere: BossBattle[] = [] for (const rid of routeIds) { const b = bossesAfterRoute.get(rid) if (b) bossesHere.push(...b) } const routeElement = route.children.length > 0 ? ( toggleGroup(route.id)} onRouteClick={handleRouteClick} filter={filter} pinwheelClause={pinwheelClause} /> ) : (() => { const encounter = encounterByRoute.get(route.id) const rs = getRouteStatus(encounter) const si = statusIndicator[rs] return ( ) })() return (
{routeElement} {/* Boss battle cards after this route */} {bossesHere.map((boss) => { const isDefeated = defeatedBossIds.has(boss.id) const sectionAfter = sectionDividerAfterBoss.get(boss.id) const bossTypeLabel: Record = { gym_leader: 'Gym Leader', elite_four: 'Elite Four', champion: 'Champion', rival: 'Rival', evil_team: 'Evil Team', other: 'Boss', } const bossTypeColors: Record = { gym_leader: 'border-yellow-400 dark:border-yellow-600', elite_four: 'border-purple-400 dark:border-purple-600', champion: 'border-red-400 dark:border-red-600', rival: 'border-blue-400 dark:border-blue-600', evil_team: 'border-gray-500 dark:border-gray-400', other: 'border-gray-400 dark:border-gray-500', } const isBossExpanded = expandedBosses.has(boss.id) const toggleBoss = () => { setExpandedBosses((prev) => { const next = new Set(prev) if (next.has(boss.id)) next.delete(boss.id) else next.add(boss.id) return next }) } return (
{boss.spriteUrl && ( {boss.name} )}
{boss.name} {bossTypeLabel[boss.bossType] ?? boss.bossType} {boss.specialtyType && ( )}

{boss.location} · Level Cap: {boss.levelCap}

e.stopPropagation()}> {isDefeated ? ( Defeated ✓ ) : isActive ? ( ) : null}
{/* Boss pokemon team */} {isBossExpanded && boss.pokemon.length > 0 && ( )}
{sectionAfter && (
{sectionAfter}
)}
) })}
) })}
{/* Encounter Modal */} {selectedRoute && ( { setSelectedRoute(null) setEditingEncounter(null) }} isPending={createEncounter.isPending || updateEncounter.isPending} /> )} {/* Shiny Encounter Modal */} {showShinyModal && routes && ( setShowShinyModal(false)} isPending={createEncounter.isPending} /> )} {/* Status Change Modal (team pokemon) */} {selectedTeamEncounter && ( { updateEncounter.mutate(data, { onSuccess: () => setSelectedTeamEncounter(null), }) }} onClose={() => setSelectedTeamEncounter(null)} isPending={updateEncounter.isPending} region={run?.game.region} /> )} {/* Boss Defeat Modal */} {selectedBoss && ( { createBossResult.mutate(data, { onSuccess: () => setSelectedBoss(null), }) }} onClose={() => setSelectedBoss(null)} isPending={createBossResult.isPending} hardcoreMode={run?.rules?.hardcoreMode} starterName={starterName} /> )} {/* End Run Modal */} {showEndRun && ( { updateRun.mutate( { status }, { onSuccess: () => setShowEndRun(false) }, ) }} onClose={() => setShowEndRun(false)} isPending={updateRun.isPending} /> )}
) }