Files
nuzlocke-tracker/frontend/src/pages/RunEncounters.tsx
Julian Tabel 4e00e3cad8 Fix HoF display for transfers/shinies and hook ordering
Move alive and hofTeam into useMemo hooks above early returns to fix
React hook ordering violation. Include transfer and shiny encounters
in alive so they appear in the team section and can be selected for
the Hall of Fame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:45:29 +01:00

1543 lines
55 KiB
TypeScript

import { useState, useMemo, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes'
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 {
EggEncounterModal,
EncounterModal,
EncounterMethodBadge,
HofTeamModal,
TransferModal,
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<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 {
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<number, Route[]>()
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<number, EncounterDetail>,
): 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<number, EncounterDetail>,
): Map<number, EncounterDetail> {
const zoneMap = new Map<number, EncounterDetail>()
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<string>()
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<string | null>(
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 (
<div className="mt-2">
{showPills && (
<div className="flex gap-1 mb-2 flex-wrap">
{variantLabels.map((label) => (
<button
key={label}
type="button"
onClick={() => setSelectedVariant(label)}
className={`px-2 py-0.5 text-xs font-medium rounded-full transition-colors ${
selectedVariant === label
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{label}
</button>
))}
</div>
)}
<div className="flex gap-2 flex-wrap">
{[...displayed]
.sort((a, b) => a.order - b.order)
.map((bp) => (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : (
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
Lvl {bp.level}
</span>
</div>
))}
</div>
</div>
)
}
interface RouteGroupProps {
group: RouteWithChildren
encounterByRoute: Map<number, EncounterDetail>
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 (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Group header */}
<button
type="button"
onClick={onToggleExpand}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
{group.name}
<span className="text-xs text-gray-400 dark:text-gray-500">
({group.children.length} areas)
</span>
</div>
{groupEncounter && (
<div className="flex items-center gap-2 mt-0.5">
{groupEncounter.pokemon.spriteUrl && (
<img
src={groupEncounter.pokemon.spriteUrl}
alt={groupEncounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
{groupEncounter.status === 'caught' &&
groupEncounter.faintLevel !== null &&
(groupEncounter.deathCause
? `${groupEncounter.deathCause}`
: ' (dead)')}
</span>
</div>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{si.label}
</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? '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>
{/* Expanded children */}
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
{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 (
<button
key={child.id}
type="button"
onClick={() => !isDisabled && onRouteClick(child)}
disabled={isDisabled}
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${
isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
} ${childSi.bg}`}
>
<span
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
/>
<div className="flex-1 min-w-0">
<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">
{childSi.label}
</span>
)}
{isDisabled && (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
(locked)
</span>
)}
</button>
)
})}
</div>
)}
</div>
)
}
export function RunEncounters() {
const { runId } = useParams<{ runId: string }>()
const navigate = useNavigate()
const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false)
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<Route | null>(null)
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
const [editingEncounter, setEditingEncounter] =
useState<EncounterDetail | null>(null)
const [selectedTeamEncounter, setSelectedTeamEncounter] =
useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
const [showHofModal, setShowHofModal] = useState(false)
const [showShinyModal, setShowShinyModal] = useState(false)
const [showEggModal, setShowEggModal] = useState(false)
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
const [showTeam, setShowTeam] = useState(true)
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
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(() => {
if (!routes) return []
return organizeRoutes(routes)
}, [routes])
// Split encounters into normal (non-shiny) and shiny
const transferIdSet = useMemo(
() => new Set(run?.transferEncounterIds ?? []),
[run?.transferEncounterIds],
)
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
if (!run) return { normalEncounters: [], shinyEncounters: [], transferEncounters: [] }
const normal: EncounterDetail[] = []
const shiny: EncounterDetail[] = []
const transfer: EncounterDetail[] = []
for (const enc of run.encounters) {
if (transferIdSet.has(enc.id)) {
transfer.push(enc)
} else if (enc.isShiny) {
shiny.push(enc)
} else {
normal.push(enc)
}
}
return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer }
}, [run, transferIdSet])
// Map routeId → encounter for quick lookup (normal encounters only)
const encounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>()
for (const enc of normalEncounters) {
map.set(enc.routeId, enc)
}
return map
}, [normalEncounters])
// Build set of retired Pokemon IDs from genlocke context
const retiredPokemonIds = useMemo(() => {
const ids = run?.genlocke?.retiredPokemonIds
if (!ids || ids.length === 0) return undefined
return new Set(ids)
}, [run])
// 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<number, number[]>()
for (const family of familiesData.families) {
for (const id of family) {
pokemonToFamily.set(id, family)
}
}
const duped = new Set<number>()
// Seed with retired Pokemon IDs from prior genlocke legs
if (retiredPokemonIds) {
for (const id of retiredPokemonIds) {
duped.add(id)
}
}
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, retiredPokemonIds])
// 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<number>()
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<number, string>()
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<number, BossBattle[]>()
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
const alive = useMemo(
() => [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null,
),
[normalEncounters, transferEncounters, shinyEncounters],
)
// Resolve HoF team encounters from IDs
const hofTeam = useMemo(() => {
if (!run?.hofEncounterIds || run.hofEncounterIds.length === 0) return null
const idSet = new Set(run.hofEncounterIds)
return alive.filter((e) => idSet.has(e.id))
}, [run?.hofEncounterIds, alive])
if (isLoading || loadingRoutes) {
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.
</div>
<Link
to="/runs"
className="inline-block mt-4 text-blue-600 hover:underline"
>
Back to runs
</Link>
</div>
)
}
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 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)
setShowEggModal(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 (
<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"
>
&larr; 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} &middot; {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
{run.genlocke && (
<p className="text-sm text-purple-600 dark:text-purple-400 mt-1 font-medium">
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash; {run.genlocke.genlockeName}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isActive && run.rules?.shinyClause && (
<button
onClick={() => setShowShinyModal(true)}
className="px-3 py-1 text-sm border border-yellow-400 dark:border-yellow-600 text-yellow-600 dark:text-yellow-400 rounded-full font-medium hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors"
>
&#10022; Log Shiny
</button>
)}
{isActive && (
<button
onClick={() => setShowEggModal(true)}
className="px-3 py-1 text-sm border border-green-400 dark:border-green-600 text-green-600 dark:text-green-400 rounded-full font-medium hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
>
&#x1F95A; Log Egg
</button>
)}
{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>
{/* 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 justify-between">
<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'
? run.genlocke?.isFinalLeg
? 'Genlocke Complete!'
: 'Victory!'
: run.genlocke
? 'Genlocke Failed'
: '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>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
<button
onClick={() => {
if (hofTeam && hofTeam.length > 0) {
setShowTransferModal(true)
} else {
advanceLeg.mutate(
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder },
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
)
}
}}
disabled={advanceLeg.isPending}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
</button>
)}
</div>
{/* HoF Team Display */}
{run.status === 'completed' && (
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wider">
Hall of Fame
</span>
<button
type="button"
onClick={() => setShowHofModal(true)}
className="text-xs text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
{hofTeam ? 'Edit' : 'Select team'}
</button>
</div>
{hofTeam ? (
<div className="flex gap-2 flex-wrap">
{hofTeam.map((enc) => {
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={enc.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img src={dp.spriteUrl} alt={dp.name} className="w-12 h-12" />
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold">
{dp.name[0].toUpperCase()}
</div>
)}
<span className="text-[10px] text-blue-600 dark:text-blue-400 capitalize mt-0.5">
{enc.nickname || dp.name}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-blue-500/60 dark:text-blue-400/60 italic">
No HoF team selected yet
</p>
)}
</div>
)}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
label="Encounters"
value={normalEncounters.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>
{/* Level Cap Bar */}
{run.rules?.levelCaps && sortedBosses.length > 0 && (
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 mb-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Level Cap: {currentLevelCap ?? '—'}
</span>
{nextBoss && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Next: {nextBoss.name}
</span>
)}
{!nextBoss && (
<span className="text-sm text-green-600 dark:text-green-400">
All bosses defeated!
</span>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500">
{defeatedBossIds.size}/{sortedBosses.length} defeated
</span>
</div>
{/* Badge row — gym leaders only */}
{sortedBosses.some((b) => b.bossType === 'gym_leader') && (
<div className="flex gap-2 flex-wrap">
{sortedBosses
.filter((b) => b.bossType === 'gym_leader')
.map((boss) => {
const earned = defeatedBossIds.has(boss.id)
return (
<div
key={boss.id}
className={`flex flex-col items-center transition-opacity ${earned ? '' : 'opacity-30 grayscale'}`}
title={`${boss.badgeName ?? boss.name}${earned ? ' (earned)' : ''}`}
>
{boss.badgeImageUrl ? (
<img
src={boss.badgeImageUrl}
alt={boss.badgeName ?? boss.name}
className="w-6 h-6"
/>
) : (
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
earned
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
{boss.order}
</div>
)}
</div>
)
})}
</div>
)}
</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>
)}
{/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-600 dark:text-indigo-400 mb-2">
Transferred Pokemon
</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Encounters
</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)}
</div>
<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"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ 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 }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{label}
</button>
))}
</div>
{/* Route list */}
<div className="space-y-1">
{filteredRoutes.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
{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 ? (
<RouteGroup
key={route.id}
group={route}
encounterByRoute={encounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (() => {
const encounter = encounterByRoute.get(route.id)
const rs = getRouteStatus(encounter)
const si = statusIndicator[rs]
return (
<button
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
>
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{route.name}
</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (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">
{si.label}
</span>
</button>
)
})()
return (
<div key={route.id}>
{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<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
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 (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="w-10 h-10" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
{/* Encounter Modal */}
{selectedRoute && (
<EncounterModal
route={selectedRoute}
gameId={run!.gameId}
existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds}
onSubmit={handleCreate}
onUpdate={handleUpdate}
onClose={() => {
setSelectedRoute(null)
setEditingEncounter(null)
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
/>
)}
{/* Shiny Encounter Modal */}
{showShinyModal && routes && (
<ShinyEncounterModal
routes={routes}
gameId={run!.gameId}
onSubmit={handleCreate}
onClose={() => setShowShinyModal(false)}
isPending={createEncounter.isPending}
/>
)}
{/* Egg Encounter Modal */}
{showEggModal && routes && (
<EggEncounterModal
routes={routes}
onSubmit={handleCreate}
onClose={() => setShowEggModal(false)}
isPending={createEncounter.isPending}
/>
)}
{/* Status Change Modal (team pokemon) */}
{selectedTeamEncounter && (
<StatusChangeModal
encounter={selectedTeamEncounter}
onUpdate={(data) => {
updateEncounter.mutate(data, {
onSuccess: () => setSelectedTeamEncounter(null),
})
}}
onClose={() => setSelectedTeamEncounter(null)}
isPending={updateEncounter.isPending}
region={run?.game.region}
onCreateEncounter={(data) => {
createEncounter.mutate(data)
}}
/>
)}
{/* Boss Defeat Modal */}
{selectedBoss && (
<BossDefeatModal
boss={selectedBoss}
onSubmit={(data) => {
createBossResult.mutate(data, {
onSuccess: () => setSelectedBoss(null),
})
}}
onClose={() => setSelectedBoss(null)}
isPending={createBossResult.isPending}
hardcoreMode={run?.rules?.hardcoreMode}
starterName={starterName}
/>
)}
{/* End Run Modal */}
{showEndRun && (
<EndRunModal
onConfirm={(status) => {
updateRun.mutate(
{ status },
{
onSuccess: () => {
setShowEndRun(false)
if (status === 'completed') {
setShowHofModal(true)
}
},
},
)
}}
onClose={() => setShowEndRun(false)}
isPending={updateRun.isPending}
genlockeContext={run.genlocke}
/>
)}
{/* HoF Team Selection Modal */}
{showHofModal && (
<HofTeamModal
alive={alive}
onSubmit={(encounterIds) => {
updateRun.mutate(
{ hofEncounterIds: encounterIds },
{ onSuccess: () => setShowHofModal(false) },
)
}}
onSkip={() => setShowHofModal(false)}
isPending={updateRun.isPending}
/>
)}
{/* Transfer Modal */}
{showTransferModal && hofTeam && hofTeam.length > 0 && (
<TransferModal
hofTeam={hofTeam}
onSubmit={(encounterIds) => {
advanceLeg.mutate(
{
genlockeId: run!.genlocke!.genlockeId,
legOrder: run!.genlocke!.legOrder,
transferEncounterIds: encounterIds,
},
{
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
)
}}
onSkip={() => {
advanceLeg.mutate(
{
genlockeId: run!.genlocke!.genlockeId,
legOrder: run!.genlocke!.legOrder,
},
{
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
)
}}
isPending={advanceLeg.isPending}
/>
)}
</div>
)
}