Files
nuzlocke-tracker/frontend/src/pages/RunEncounters.tsx

1560 lines
55 KiB
TypeScript
Raw Normal View History

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 { ConditionBadge } from '../components/ConditionBadge'
import type {
Route,
RouteWithChildren,
RunStatus,
EncounterDetail,
EncounterStatus,
CreateEncounterInput,
BossBattle,
BossPokemon,
} from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
return [...encounters].sort((a, b) => {
switch (key) {
case 'route':
return a.route.order - b.route.order
case 'level':
return (a.catchLevel ?? 0) - (b.catchLevel ?? 0)
case 'species': {
const nameA = (a.currentPokemon ?? a.pokemon).name
const nameB = (b.currentPokemon ?? b.pokemon).name
return nameA.localeCompare(nameB)
}
case 'dex':
return (
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
)
default:
return 0
}
})
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
}
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-900/10',
},
fainted: {
dot: 'bg-red-500',
label: 'Fainted',
bg: 'bg-red-900/10',
},
missed: {
dot: 'bg-gray-400',
label: 'Missed',
bg: 'bg-surface-0/10',
},
none: { dot: 'bg-surface-3', 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) : 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) : 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-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{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-surface-3 rounded-full" />
)}
<div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
</div>
</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-border-default 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-surface-2/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-text-primary flex items-center gap-2">
{group.name}
<span className="text-xs text-text-muted">({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-text-tertiary 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-text-muted shrink-0">{si.label}</span>
<svg
className={`w-4 h-4 text-text-tertiary 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-border-default bg-surface-1/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-surface-2/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-text-secondary">{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-text-muted">{childSi.label}</span>}
{isDisabled && <span className="text-xs text-text-muted 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 [teamSort, setTeamSort] = useState<TeamSortKey>('route')
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(
() =>
sortEncounters(
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null
),
teamSort
),
[normalEncounters, transferEncounters, shinyEncounters, teamSort]
)
const dead = useMemo(
() =>
sortEncounters(
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort
),
[normalEncounters, teamSort]
)
// 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-status-failed-bg p-4 text-status-failed">
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
const useAllPokemon = !!(run.rules?.egglocke || run.rules?.wonderlocke || run.rules?.randomizer)
// 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 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 | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
}
}) => {
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-text-tertiary hover:text-text-primary mb-2 inline-block"
>
&larr; All Runs
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-text-primary">{run.name}</h1>
<p className="text-text-tertiary 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-400 light:text-purple-700 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-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
>
&#10022; Log Shiny
</button>
)}
{isActive && (
<button
onClick={() => setShowEggModal(true)}
className="px-3 py-1 text-sm border border-green-600 text-status-active rounded-full font-medium 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-border-default rounded-full font-medium hover:bg-surface-2 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-900/20 border border-blue-800'
: 'bg-status-failed-bg border 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-200' : '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-text-link' : 'text-status-failed'
}`}
>
{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-accent-600 text-white hover:bg-accent-500 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-800">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-text-link uppercase tracking-wider">
Hall of Fame
</span>
<button
type="button"
onClick={() => setShowHofModal(true)}
className="text-xs text-blue-400 hover:text-accent-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-surface-3 flex items-center justify-center text-sm font-bold">
{dp.name[0]?.toUpperCase()}
</div>
)}
<span className="text-[10px] text-text-link capitalize mt-0.5">
{enc.nickname || dp.name}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs 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-surface-0 border border-border-default 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-text-primary">
Level Cap: {currentLevelCap ?? '—'}
</span>
{nextBoss && (
<span className="text-sm text-text-tertiary">Next: {nextBoss.name}</span>
)}
{!nextBoss && (
<span className="text-sm text-status-active">All bosses defeated!</span>
)}
</div>
<span className="text-xs text-text-muted">
{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-900/40 text-yellow-300'
: 'border-border-default text-text-tertiary'
}`}
>
{boss.order}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)}
{/* Rules */}
<div className="mb-6">
<h2 className="text-sm font-medium text-text-tertiary mb-2">Active Rules</h2>
<RuleBadges rules={run.rules} />
</div>
{/* Team Section */}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group"
>
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-text-tertiary 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 > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{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-text-tertiary 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-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-text-primary">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-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 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-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
{/* Route list */}
<div className="space-y-1">
{filteredRoutes.length === 0 && (
<p className="text-text-tertiary 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-surface-2/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-text-primary">{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-text-tertiary 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-text-muted 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',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: '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-900/10' : 'bg-surface-1'
} 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-text-tertiary 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="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div>
<p className="text-xs text-text-tertiary">
{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-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 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-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
{/* Encounter Modal */}
{selectedRoute && (
<EncounterModal
route={selectedRoute}
gameId={run!.gameId}
runId={runIdNum}
namingScheme={run!.namingScheme}
isGenlocke={!!run!.genlocke}
existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds}
onSubmit={handleCreate}
onUpdate={handleUpdate}
onClose={() => {
setSelectedRoute(null)
setEditingEncounter(null)
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
/>
)}
{/* 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>
)
}