Files
nuzlocke-tracker/frontend/src/pages/RunEncounters.tsx
Julian Tabel 0a519e356e
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s
feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Add user authentication with login/signup/protected routes, boss pokemon
detail fields and result team tracking, moves and abilities selector
components and API, run ownership and visibility controls, and various
UI improvements across encounters, run list, and journal pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:41:38 +01:00

1808 lines
68 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 {
CustomRulesDisplay,
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 { JournalSection } from '../components/journal'
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
}
/** Count boss pokemon for the effective variant (or all if no variants). */
function getBossTeamSize(pokemon: BossPokemon[], starterName?: string | null): number {
const labels = [
...new Set(pokemon.filter((bp) => bp.conditionLabel).map((bp) => bp.conditionLabel!)),
]
if (labels.length === 0) return pokemon.length
const matched = matchVariant(labels, starterName)
const variant = matched ?? labels[0] ?? null
return pokemon.filter((bp) => bp.conditionLabel === variant || bp.conditionLabel === null).length
}
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) => {
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
return (
<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" />
{bp.ability && (
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
)}
{bp.heldItem && (
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
)}
{moves.length > 0 && (
<div className="text-[9px] text-text-muted leading-tight">
{moves.map((m) => m!.name).join(', ')}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
interface RouteGroupProps {
group: RouteWithChildren
encounterByRoute: Map<number, EncounterDetail>
giftEncounterByRoute: Map<number, EncounterDetail>
isExpanded: boolean
onToggleExpand: () => void
onRouteClick: (route: Route) => void
filter: 'all' | RouteStatus
pinwheelClause: boolean
}
function RouteGroup({
group,
encounterByRoute,
giftEncounterByRoute,
isExpanded,
onToggleExpand,
onRouteClick,
filter,
pinwheelClause,
}: RouteGroupProps) {
const groupEncounter = getGroupEncounter(group, encounterByRoute)
const usePinwheel = pinwheelClause && groupHasZones(group)
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
// Find first gift encounter in the group (for display)
let groupGiftEncounter: EncounterDetail | null = null
for (const child of group.children) {
const gift = giftEncounterByRoute.get(child.id)
if (gift) {
groupGiftEncounter = gift
break
}
}
const displayEncounter = groupEncounter ?? groupGiftEncounter
// For pinwheel groups, determine status from all zone statuses
let groupStatus: RouteStatus
if (usePinwheel && zoneEncounters && zoneEncounters.size > 0) {
groupStatus = displayEncounter ? displayEncounter.status : 'none'
} else {
groupStatus = displayEncounter ? displayEncounter.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) ?? giftEncounterByRoute.get(child.id)
return getRouteStatus(enc) === 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>
{groupGiftEncounter && (
<>
{groupGiftEncounter.pokemon.spriteUrl && (
<img
src={groupGiftEncounter.pokemon.spriteUrl}
alt={groupGiftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
)}
{!groupEncounter && groupGiftEncounter && (
<div className="flex items-center gap-2 mt-0.5">
{groupGiftEncounter.pokemon.spriteUrl && (
<img
src={groupGiftEncounter.pokemon.spriteUrl}
alt={groupGiftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{groupGiftEncounter.nickname ?? groupGiftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</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 giftEncounter = giftEncounterByRoute.get(child.id)
const displayEncounter = childEncounter ?? giftEncounter
const childStatus = getRouteStatus(displayEncounter)
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>
{giftEncounter && !childEncounter && (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div>
)}
{!displayEncounter && 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 rulesAllowedTypes = run?.rules?.allowedTypes ?? []
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
rulesAllowedTypes.length ? rulesAllowedTypes : undefined
)
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 [activeTab, setActiveTab] = useState<'encounters' | 'journal'>('encounters')
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])
const giftClauseOn = run?.rules?.giftClause ?? false
// Map routeId → encounter for quick lookup (normal encounters only).
// When gift clause is on, gift-origin encounters are excluded so they
// don't lock the route for a regular encounter.
const encounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>()
for (const enc of normalEncounters) {
if (giftClauseOn && enc.origin === 'gift') continue
map.set(enc.routeId, enc)
}
return map
}, [normalEncounters, giftClauseOn])
// Separate map for gift encounters (only populated when gift clause is on)
const giftEncounterByRoute = useMemo(() => {
const map = new Map<number, EncounterDetail>()
if (!giftClauseOn) return map
for (const enc of normalEncounters) {
if (enc.origin === 'gift') map.set(enc.routeId, enc)
}
return map
}, [normalEncounters, giftClauseOn])
// 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])
// Map encounter ID to encounter detail for team display
const encounterById = useMemo(() => {
const map = new Map<number, EncounterDetail>()
if (run) {
for (const enc of run.encounters) {
map.set(enc.id, enc)
}
}
return map
}, [run])
// Map boss battle ID to result for team snapshot
const bossResultByBattleId = useMemo(() => {
const map = new Map<number, (typeof bossResults)[number]>()
if (bossResults) {
for (const r of bossResults) {
map.set(r.bossBattleId, r)
}
}
return map
}, [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 (check both regular and gift encounters for status)
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) ?? giftEncounterByRoute.get(child.id)
return getRouteStatus(enc) === filter
})
}
// Classic: single status for whole group
const groupEnc = getGroupEncounter(r, encounterByRoute)
if (groupEnc) return getRouteStatus(groupEnc) === filter
// Check gift encounters if no regular encounter in group
for (const child of r.children) {
const gift = giftEncounterByRoute.get(child.id)
if (gift) return getRouteStatus(gift) === filter
}
return filter === 'none'
}
// Standalone route
const enc = encounterByRoute.get(r.id) ?? giftEncounterByRoute.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-14 z-30 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 className="text-text-muted">
{' '}
({getBossTeamSize(nextBoss.pokemon, starterName)} Pokémon)
</span>
</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} />
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
</div>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-border">
<button
type="button"
onClick={() => setActiveTab('encounters')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === 'encounters'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
Encounters
</button>
<button
type="button"
onClick={() => setActiveTab('journal')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === 'journal'
? 'border-blue-500 text-blue-500'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
Journal
</button>
</div>
{/* Journal Tab */}
{activeTab === 'journal' && (
<JournalSection runId={runIdNum} bossResults={bossResults} bosses={bosses} />
)}
{/* Encounters Tab */}
{activeTab === 'encounters' && (
<>
{/* 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}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
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>
{giftEncounter && (
<>
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</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} />
)}
{/* Player team snapshot */}
{isDefeated && (() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name}
</span>
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
</div>
)
})}
</div>
</div>
)
})()}
</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}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/>
)}
</>
)}
{/* 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}
aliveEncounters={alive}
onSubmit={(data) => {
createBossResult.mutate(data, {
onSuccess: () => setSelectedBoss(null),
})
}}
onClose={() => setSelectedBoss(null)}
isPending={createBossResult.isPending}
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>
)
}