2026-02-07 14:20:26 +01:00
|
|
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
2026-02-09 09:47:28 +01:00
|
|
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
2026-02-07 14:20:26 +01:00
|
|
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
2026-02-09 09:47:28 +01:00
|
|
|
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
2026-02-05 15:28:50 +01:00
|
|
|
import { useGameRoutes } from '../hooks/useGames'
|
2026-02-08 13:14:43 +01:00
|
|
|
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
2026-02-07 21:08:25 +01:00
|
|
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
2026-02-08 11:16:13 +01:00
|
|
|
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
|
2026-02-07 14:20:26 +01:00
|
|
|
import {
|
2026-02-08 22:25:47 +01:00
|
|
|
EggEncounterModal,
|
2026-02-07 14:20:26 +01:00
|
|
|
EncounterModal,
|
|
|
|
|
EncounterMethodBadge,
|
|
|
|
|
StatCard,
|
|
|
|
|
PokemonCard,
|
|
|
|
|
StatusChangeModal,
|
|
|
|
|
EndRunModal,
|
|
|
|
|
RuleBadges,
|
2026-02-07 21:08:25 +01:00
|
|
|
ShinyBox,
|
|
|
|
|
ShinyEncounterModal,
|
2026-02-08 15:23:59 +01:00
|
|
|
TypeBadge,
|
2026-02-07 14:20:26 +01:00
|
|
|
} from '../components'
|
2026-02-08 11:16:13 +01:00
|
|
|
import { BossDefeatModal } from '../components/BossDefeatModal'
|
2026-02-06 11:07:45 +01:00
|
|
|
import type {
|
|
|
|
|
Route,
|
|
|
|
|
RouteWithChildren,
|
2026-02-07 14:20:26 +01:00
|
|
|
RunStatus,
|
2026-02-06 11:07:45 +01:00
|
|
|
EncounterDetail,
|
|
|
|
|
EncounterStatus,
|
2026-02-07 21:08:25 +01:00
|
|
|
CreateEncounterInput,
|
2026-02-08 11:16:13 +01:00
|
|
|
BossBattle,
|
2026-02-08 21:20:30 +01:00
|
|
|
BossPokemon,
|
2026-02-06 11:07:45 +01:00
|
|
|
} from '../types'
|
2026-02-05 15:28:50 +01:00
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
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`
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
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: '' },
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 20:22:36 +01:00
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 21:33:28 +01:00
|
|
|
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 }) {
|
2026-02-08 21:20:30 +01:00
|
|
|
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
|
2026-02-08 21:33:28 +01:00
|
|
|
const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName])
|
|
|
|
|
const showPills = hasVariants && autoMatch === null
|
2026-02-08 21:20:30 +01:00
|
|
|
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
2026-02-08 21:33:28 +01:00
|
|
|
autoMatch ?? (hasVariants ? variantLabels[0] : null),
|
2026-02-08 21:20:30 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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">
|
2026-02-08 21:33:28 +01:00
|
|
|
{showPills && (
|
2026-02-08 21:20:30 +01:00
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
interface RouteGroupProps {
|
|
|
|
|
group: RouteWithChildren
|
|
|
|
|
encounterByRoute: Map<number, EncounterDetail>
|
|
|
|
|
isExpanded: boolean
|
|
|
|
|
onToggleExpand: () => void
|
|
|
|
|
onRouteClick: (route: Route) => void
|
|
|
|
|
filter: 'all' | RouteStatus
|
2026-02-07 20:22:36 +01:00
|
|
|
pinwheelClause: boolean
|
2026-02-06 11:07:45 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RouteGroup({
|
|
|
|
|
group,
|
|
|
|
|
encounterByRoute,
|
|
|
|
|
isExpanded,
|
|
|
|
|
onToggleExpand,
|
|
|
|
|
onRouteClick,
|
|
|
|
|
filter,
|
2026-02-07 20:22:36 +01:00
|
|
|
pinwheelClause,
|
2026-02-06 11:07:45 +01:00
|
|
|
}: RouteGroupProps) {
|
|
|
|
|
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
2026-02-07 20:22:36 +01:00
|
|
|
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'
|
|
|
|
|
}
|
2026-02-06 11:07:45 +01:00
|
|
|
const si = statusIndicator[groupStatus]
|
|
|
|
|
|
|
|
|
|
// For groups, check if it matches the filter
|
2026-02-07 20:22:36 +01:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-06 11:07:45 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}
|
2026-02-08 12:18:12 +01:00
|
|
|
className="w-10 h-10"
|
2026-02-06 11:07:45 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<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]
|
2026-02-07 20:22:36 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-02-06 11:07:45 +01:00
|
|
|
|
|
|
|
|
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>
|
2026-02-07 14:20:26 +01:00
|
|
|
{!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>
|
|
|
|
|
)}
|
2026-02-06 11:07:45 +01:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
export function RunEncounters() {
|
|
|
|
|
const { runId } = useParams<{ runId: string }>()
|
2026-02-09 09:47:28 +01:00
|
|
|
const navigate = useNavigate()
|
2026-02-05 15:28:50 +01:00
|
|
|
const runIdNum = Number(runId)
|
|
|
|
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
2026-02-09 09:47:28 +01:00
|
|
|
const advanceLeg = useAdvanceLeg()
|
2026-02-05 15:28:50 +01:00
|
|
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
|
|
|
|
run?.gameId ?? null,
|
|
|
|
|
)
|
|
|
|
|
const createEncounter = useCreateEncounter(runIdNum)
|
|
|
|
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
2026-02-08 13:14:43 +01:00
|
|
|
const bulkRandomize = useBulkRandomize(runIdNum)
|
2026-02-07 14:20:26 +01:00
|
|
|
const updateRun = useUpdateRun(runIdNum)
|
2026-02-07 21:08:25 +01:00
|
|
|
const { data: familiesData } = usePokemonFamilies()
|
2026-02-08 11:16:13 +01:00
|
|
|
const { data: bosses } = useGameBosses(run?.gameId ?? null)
|
|
|
|
|
const { data: bossResults } = useBossResults(runIdNum)
|
|
|
|
|
const createBossResult = useCreateBossResult(runIdNum)
|
2026-02-05 15:28:50 +01:00
|
|
|
|
|
|
|
|
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
2026-02-08 11:16:13 +01:00
|
|
|
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
|
2026-02-05 15:28:50 +01:00
|
|
|
const [editingEncounter, setEditingEncounter] =
|
|
|
|
|
useState<EncounterDetail | null>(null)
|
2026-02-07 14:20:26 +01:00
|
|
|
const [selectedTeamEncounter, setSelectedTeamEncounter] =
|
|
|
|
|
useState<EncounterDetail | null>(null)
|
|
|
|
|
const [showEndRun, setShowEndRun] = useState(false)
|
2026-02-07 21:08:25 +01:00
|
|
|
const [showShinyModal, setShowShinyModal] = useState(false)
|
2026-02-08 22:25:47 +01:00
|
|
|
const [showEggModal, setShowEggModal] = useState(false)
|
2026-02-08 12:03:11 +01:00
|
|
|
const [expandedBosses, setExpandedBosses] = useState<Set<number>>(new Set())
|
2026-02-07 14:20:26 +01:00
|
|
|
const [showTeam, setShowTeam] = useState(true)
|
2026-02-05 15:28:50 +01:00
|
|
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
2026-02-07 14:20:26 +01:00
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
)
|
2026-02-06 11:07:45 +01:00
|
|
|
|
|
|
|
|
// Organize routes into hierarchical structure
|
|
|
|
|
const organizedRoutes = useMemo(() => {
|
|
|
|
|
if (!routes) return []
|
|
|
|
|
return organizeRoutes(routes)
|
|
|
|
|
}, [routes])
|
2026-02-05 15:28:50 +01:00
|
|
|
|
2026-02-07 21:08:25 +01:00
|
|
|
// Split encounters into normal (non-shiny) and shiny
|
|
|
|
|
const { normalEncounters, shinyEncounters } = useMemo(() => {
|
|
|
|
|
if (!run) return { normalEncounters: [], shinyEncounters: [] }
|
|
|
|
|
const normal: EncounterDetail[] = []
|
|
|
|
|
const shiny: EncounterDetail[] = []
|
|
|
|
|
for (const enc of run.encounters) {
|
|
|
|
|
if (enc.isShiny) {
|
|
|
|
|
shiny.push(enc)
|
|
|
|
|
} else {
|
|
|
|
|
normal.push(enc)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { normalEncounters: normal, shinyEncounters: shiny }
|
|
|
|
|
}, [run])
|
|
|
|
|
|
|
|
|
|
// Map routeId → encounter for quick lookup (normal encounters only)
|
2026-02-07 14:20:26 +01:00
|
|
|
const encounterByRoute = useMemo(() => {
|
|
|
|
|
const map = new Map<number, EncounterDetail>()
|
2026-02-07 21:08:25 +01:00
|
|
|
for (const enc of normalEncounters) {
|
|
|
|
|
map.set(enc.routeId, enc)
|
2026-02-07 14:20:26 +01:00
|
|
|
}
|
|
|
|
|
return map
|
2026-02-07 21:08:25 +01:00
|
|
|
}, [normalEncounters])
|
|
|
|
|
|
2026-02-09 10:05:03 +01:00
|
|
|
// 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])
|
|
|
|
|
|
2026-02-07 21:08:25 +01:00
|
|
|
// 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>()
|
2026-02-09 10:05:03 +01:00
|
|
|
|
|
|
|
|
// Seed with retired Pokemon IDs from prior genlocke legs
|
|
|
|
|
if (retiredPokemonIds) {
|
|
|
|
|
for (const id of retiredPokemonIds) {
|
|
|
|
|
duped.add(id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 21:08:25 +01:00
|
|
|
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
|
2026-02-09 10:05:03 +01:00
|
|
|
}, [run, normalEncounters, familiesData, retiredPokemonIds])
|
2026-02-07 14:20:26 +01:00
|
|
|
|
2026-02-08 21:33:28 +01:00
|
|
|
// 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])
|
|
|
|
|
|
2026-02-08 11:16:13 +01:00
|
|
|
// 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])
|
|
|
|
|
|
2026-02-08 14:55:26 +01:00
|
|
|
// 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])
|
|
|
|
|
|
2026-02-08 11:16:13 +01:00
|
|
|
// 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])
|
|
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
// 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
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 20:22:36 +01:00
|
|
|
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) {
|
2026-02-06 11:07:45 +01:00
|
|
|
if (r.children.length > 0) {
|
2026-02-07 20:22:36 +01:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-06 11:07:45 +01:00
|
|
|
}
|
2026-02-07 20:22:36 +01:00
|
|
|
}
|
2026-02-06 11:07:45 +01:00
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
const isActive = run.status === 'active'
|
2026-02-07 21:08:25 +01:00
|
|
|
const alive = normalEncounters.filter(
|
2026-02-07 14:20:26 +01:00
|
|
|
(e) => e.status === 'caught' && e.faintLevel === null,
|
|
|
|
|
)
|
2026-02-07 21:08:25 +01:00
|
|
|
const dead = normalEncounters.filter(
|
2026-02-07 14:20:26 +01:00
|
|
|
(e) => e.status === 'caught' && e.faintLevel !== null,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
const toggleGroup = (groupId: number) => {
|
2026-02-07 14:20:26 +01:00
|
|
|
updateExpandedGroups((prev) => {
|
2026-02-06 11:07:45 +01:00
|
|
|
const next = new Set(prev)
|
|
|
|
|
if (next.has(groupId)) {
|
|
|
|
|
next.delete(groupId)
|
|
|
|
|
} else {
|
|
|
|
|
next.add(groupId)
|
|
|
|
|
}
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-05 15:28:50 +01:00
|
|
|
|
|
|
|
|
const handleRouteClick = (route: Route) => {
|
|
|
|
|
const existing = encounterByRoute.get(route.id)
|
|
|
|
|
if (existing) {
|
|
|
|
|
setEditingEncounter(existing)
|
|
|
|
|
} else {
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
}
|
|
|
|
|
setSelectedRoute(route)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 21:08:25 +01:00
|
|
|
const handleCreate = (data: CreateEncounterInput) => {
|
2026-02-05 15:28:50 +01:00
|
|
|
createEncounter.mutate(data, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setSelectedRoute(null)
|
|
|
|
|
setEditingEncounter(null)
|
2026-02-07 21:08:25 +01:00
|
|
|
setShowShinyModal(false)
|
2026-02-08 22:25:47 +01:00
|
|
|
setShowEggModal(false)
|
2026-02-05 15:28:50 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleUpdate = (data: {
|
|
|
|
|
id: number
|
2026-02-05 18:36:08 +01:00
|
|
|
data: {
|
|
|
|
|
nickname?: string
|
|
|
|
|
status?: EncounterStatus
|
|
|
|
|
faintLevel?: number
|
|
|
|
|
deathCause?: string
|
|
|
|
|
}
|
2026-02-05 15:28:50 +01:00
|
|
|
}) => {
|
|
|
|
|
updateEncounter.mutate(data, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setSelectedRoute(null)
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
// Filter routes
|
|
|
|
|
const filteredRoutes = organizedRoutes.filter((r) => {
|
|
|
|
|
if (filter === 'all') return true
|
|
|
|
|
|
|
|
|
|
if (r.children.length > 0) {
|
2026-02-07 20:22:36 +01:00
|
|
|
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
|
2026-02-06 11:07:45 +01:00
|
|
|
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
|
|
|
|
return getRouteStatus(groupEnc ?? undefined) === filter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Standalone route
|
|
|
|
|
const enc = encounterByRoute.get(r.id)
|
|
|
|
|
return getRouteStatus(enc) === filter
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<Link
|
2026-02-07 14:20:26 +01:00
|
|
|
to="/runs"
|
2026-02-05 15:28:50 +01:00
|
|
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
|
|
|
|
>
|
2026-02-07 14:20:26 +01:00
|
|
|
← All Runs
|
2026-02-05 15:28:50 +01:00
|
|
|
</Link>
|
2026-02-07 14:20:26 +01:00
|
|
|
<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">
|
2026-02-07 21:29:14 +01:00
|
|
|
{run.game.name} · {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
2026-02-07 14:20:26 +01:00
|
|
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
})}
|
|
|
|
|
</p>
|
2026-02-09 09:47:28 +01:00
|
|
|
{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} — {run.genlocke.genlockeName}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-02-07 14:20:26 +01:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-02-07 21:08:25 +01:00
|
|
|
{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"
|
|
|
|
|
>
|
|
|
|
|
✦ Log Shiny
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-02-08 22:25:47 +01:00
|
|
|
{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"
|
|
|
|
|
>
|
|
|
|
|
🥚 Log Egg
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-02-07 14:20:26 +01:00
|
|
|
{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>
|
2026-02-05 15:28:50 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
{/* 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'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-02-09 09:47:28 +01:00
|
|
|
<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>
|
2026-02-07 14:20:26 +01:00
|
|
|
</div>
|
2026-02-09 09:47:28 +01:00
|
|
|
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
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>
|
|
|
|
|
)}
|
2026-02-07 14:20:26 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Stats */}
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
|
|
|
|
<StatCard
|
|
|
|
|
label="Encounters"
|
2026-02-07 21:08:25 +01:00
|
|
|
value={normalEncounters.length}
|
2026-02-07 14:20:26 +01:00
|
|
|
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>
|
|
|
|
|
|
2026-02-08 11:16:13 +01:00
|
|
|
{/* 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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
{/* Rules */}
|
2026-02-05 15:28:50 +01:00
|
|
|
<div className="mb-6">
|
2026-02-07 14:20:26 +01:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-07 21:08:25 +01:00
|
|
|
{/* Shiny Box */}
|
|
|
|
|
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<ShinyBox
|
|
|
|
|
encounters={shinyEncounters}
|
|
|
|
|
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
{/* Progress bar */}
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
2026-02-08 13:14:43 +01:00
|
|
|
<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>
|
2026-02-07 14:20:26 +01:00
|
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
{completedCount} / {totalLocations} locations
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-02-05 15:28:50 +01:00
|
|
|
<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={{
|
2026-02-06 11:07:45 +01:00
|
|
|
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
2026-02-05 15:28:50 +01:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</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">
|
2026-02-07 14:20:26 +01:00
|
|
|
{filter === 'all'
|
|
|
|
|
? 'Click a route to log your first encounter'
|
|
|
|
|
: 'No routes match this filter — try a different one'}
|
2026-02-05 15:28:50 +01:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{filteredRoutes.map((route) => {
|
2026-02-08 11:16:13 +01:00
|
|
|
// 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]
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
return (
|
2026-02-08 11:16:13 +01:00
|
|
|
<button
|
2026-02-06 11:07:45 +01:00
|
|
|
key={route.id}
|
2026-02-08 11:16:13 +01:00
|
|
|
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}
|
2026-02-08 12:18:12 +01:00
|
|
|
className="w-10 h-10"
|
2026-02-08 11:16:13 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<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>
|
2026-02-06 11:07:45 +01:00
|
|
|
)
|
2026-02-08 11:16:13 +01:00
|
|
|
})()
|
2026-02-05 15:28:50 +01:00
|
|
|
|
|
|
|
|
return (
|
2026-02-08 11:16:13 +01:00
|
|
|
<div key={route.id}>
|
|
|
|
|
{routeElement}
|
|
|
|
|
{/* Boss battle cards after this route */}
|
|
|
|
|
{bossesHere.map((boss) => {
|
|
|
|
|
const isDefeated = defeatedBossIds.has(boss.id)
|
2026-02-08 14:55:26 +01:00
|
|
|
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
|
2026-02-08 11:16:13 +01:00
|
|
|
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',
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 12:03:11 +01:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 11:16:13 +01:00
|
|
|
return (
|
2026-02-08 14:55:26 +01:00
|
|
|
<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`}
|
|
|
|
|
>
|
2026-02-08 12:03:11 +01:00
|
|
|
<div
|
|
|
|
|
className="flex items-start justify-between cursor-pointer select-none"
|
|
|
|
|
onClick={toggleBoss}
|
|
|
|
|
>
|
2026-02-08 11:16:13 +01:00
|
|
|
<div className="flex items-center gap-3">
|
2026-02-08 12:03:11 +01:00
|
|
|
<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>
|
2026-02-08 11:16:13 +01:00
|
|
|
{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>
|
2026-02-08 15:23:59 +01:00
|
|
|
{boss.specialtyType && (
|
|
|
|
|
<TypeBadge type={boss.specialtyType} />
|
|
|
|
|
)}
|
2026-02-08 11:16:13 +01:00
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
{boss.location} · Level Cap: {boss.levelCap}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-08 12:03:11 +01:00
|
|
|
<div onClick={(e) => e.stopPropagation()}>
|
2026-02-08 11:16:13 +01:00
|
|
|
{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 ✓
|
|
|
|
|
</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 */}
|
2026-02-08 12:03:11 +01:00
|
|
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
2026-02-08 21:33:28 +01:00
|
|
|
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
2026-02-05 15:28:50 +01:00
|
|
|
)}
|
2026-02-08 14:55:26 +01:00
|
|
|
</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>
|
|
|
|
|
)}
|
2026-02-07 14:20:26 +01:00
|
|
|
</div>
|
2026-02-08 11:16:13 +01:00
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-02-05 15:28:50 +01:00
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Encounter Modal */}
|
|
|
|
|
{selectedRoute && (
|
|
|
|
|
<EncounterModal
|
|
|
|
|
route={selectedRoute}
|
2026-02-08 12:18:12 +01:00
|
|
|
gameId={run!.gameId}
|
2026-02-05 15:28:50 +01:00
|
|
|
existing={editingEncounter ?? undefined}
|
2026-02-07 21:08:25 +01:00
|
|
|
dupedPokemonIds={dupedPokemonIds}
|
2026-02-09 10:05:03 +01:00
|
|
|
retiredPokemonIds={retiredPokemonIds}
|
2026-02-05 15:28:50 +01:00
|
|
|
onSubmit={handleCreate}
|
|
|
|
|
onUpdate={handleUpdate}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setSelectedRoute(null)
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
}}
|
|
|
|
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-07 14:20:26 +01:00
|
|
|
|
2026-02-07 21:08:25 +01:00
|
|
|
{/* Shiny Encounter Modal */}
|
|
|
|
|
{showShinyModal && routes && (
|
|
|
|
|
<ShinyEncounterModal
|
|
|
|
|
routes={routes}
|
2026-02-08 12:18:12 +01:00
|
|
|
gameId={run!.gameId}
|
2026-02-07 21:08:25 +01:00
|
|
|
onSubmit={handleCreate}
|
|
|
|
|
onClose={() => setShowShinyModal(false)}
|
|
|
|
|
isPending={createEncounter.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-08 22:25:47 +01:00
|
|
|
{/* Egg Encounter Modal */}
|
|
|
|
|
{showEggModal && routes && (
|
|
|
|
|
<EggEncounterModal
|
|
|
|
|
routes={routes}
|
|
|
|
|
onSubmit={handleCreate}
|
|
|
|
|
onClose={() => setShowEggModal(false)}
|
|
|
|
|
isPending={createEncounter.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
{/* Status Change Modal (team pokemon) */}
|
|
|
|
|
{selectedTeamEncounter && (
|
|
|
|
|
<StatusChangeModal
|
|
|
|
|
encounter={selectedTeamEncounter}
|
|
|
|
|
onUpdate={(data) => {
|
|
|
|
|
updateEncounter.mutate(data, {
|
|
|
|
|
onSuccess: () => setSelectedTeamEncounter(null),
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
onClose={() => setSelectedTeamEncounter(null)}
|
|
|
|
|
isPending={updateEncounter.isPending}
|
2026-02-07 20:05:07 +01:00
|
|
|
region={run?.game.region}
|
2026-02-08 21:47:35 +01:00
|
|
|
onCreateEncounter={(data) => {
|
|
|
|
|
createEncounter.mutate(data)
|
|
|
|
|
}}
|
2026-02-07 14:20:26 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-08 11:16:13 +01:00
|
|
|
{/* Boss Defeat Modal */}
|
|
|
|
|
{selectedBoss && (
|
|
|
|
|
<BossDefeatModal
|
|
|
|
|
boss={selectedBoss}
|
|
|
|
|
onSubmit={(data) => {
|
|
|
|
|
createBossResult.mutate(data, {
|
|
|
|
|
onSuccess: () => setSelectedBoss(null),
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
onClose={() => setSelectedBoss(null)}
|
|
|
|
|
isPending={createBossResult.isPending}
|
2026-02-08 12:03:11 +01:00
|
|
|
hardcoreMode={run?.rules?.hardcoreMode}
|
2026-02-08 21:33:28 +01:00
|
|
|
starterName={starterName}
|
2026-02-08 11:16:13 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-07 14:20:26 +01:00
|
|
|
{/* End Run Modal */}
|
|
|
|
|
{showEndRun && (
|
|
|
|
|
<EndRunModal
|
|
|
|
|
onConfirm={(status) => {
|
|
|
|
|
updateRun.mutate(
|
|
|
|
|
{ status },
|
|
|
|
|
{ onSuccess: () => setShowEndRun(false) },
|
|
|
|
|
)
|
|
|
|
|
}}
|
|
|
|
|
onClose={() => setShowEndRun(false)}
|
|
|
|
|
isPending={updateRun.isPending}
|
2026-02-09 09:47:28 +01:00
|
|
|
genlockeContext={run.genlocke}
|
2026-02-07 14:20:26 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-05 15:28:50 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|