Add hierarchical route grouping for multi-area locations
Locations like Mt. Moon (with 1F, B1F, B2F floors) are now grouped so only one encounter can be logged per location group, enforcing Nuzlocke first-encounter rules correctly. - Add parent_route_id column with self-referential FK to routes table - Add parent/children relationships on Route model - Update games API to return hierarchical route structure - Add validation in encounters API to prevent parent route encounters and duplicate encounters within sibling routes (409 conflict) - Update frontend with collapsible RouteGroup component - Auto-derive route groups from PokeAPI location/location-area structure - Regenerate seed data with 70 parent routes and 315 child routes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,9 @@ export function getGame(id: number): Promise<GameDetail> {
|
||||
}
|
||||
|
||||
export function getGameRoutes(gameId: number): Promise<Route[]> {
|
||||
return api.get(`/games/${gameId}/routes`)
|
||||
// Use flat=true to get all routes in a flat list
|
||||
// The frontend organizes them into hierarchy based on parentRouteId
|
||||
return api.get(`/games/${gameId}/routes?flat=true`)
|
||||
}
|
||||
|
||||
export function getRoutePokemon(routeId: number): Promise<RouteEncounterDetail[]> {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useRun } from '../hooks/useRuns'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||
import { EncounterModal } from '../components'
|
||||
import type { Route, EncounterDetail, EncounterStatus } from '../types'
|
||||
import type {
|
||||
Route,
|
||||
RouteWithChildren,
|
||||
EncounterDetail,
|
||||
EncounterStatus,
|
||||
} from '../types'
|
||||
|
||||
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
||||
|
||||
@@ -35,6 +40,175 @@ const statusIndicator: Record<
|
||||
none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize flat routes into hierarchical structure.
|
||||
* Routes with parentRouteId are grouped under their parent.
|
||||
*/
|
||||
function organizeRoutes(routes: Route[]): RouteWithChildren[] {
|
||||
const childrenByParent = new Map<number, Route[]>()
|
||||
const topLevel: Route[] = []
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.parentRouteId === null) {
|
||||
topLevel.push(route)
|
||||
} else {
|
||||
const children = childrenByParent.get(route.parentRouteId) ?? []
|
||||
children.push(route)
|
||||
childrenByParent.set(route.parentRouteId, children)
|
||||
}
|
||||
}
|
||||
|
||||
return topLevel.map((route) => ({
|
||||
...route,
|
||||
children: childrenByParent.get(route.id) ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any child route in a group has an encounter.
|
||||
* Returns the encounter if found, null otherwise.
|
||||
*/
|
||||
function getGroupEncounter(
|
||||
group: RouteWithChildren,
|
||||
encounterByRoute: Map<number, EncounterDetail>,
|
||||
): EncounterDetail | null {
|
||||
for (const child of group.children) {
|
||||
const enc = encounterByRoute.get(child.id)
|
||||
if (enc) return enc
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface RouteGroupProps {
|
||||
group: RouteWithChildren
|
||||
encounterByRoute: Map<number, EncounterDetail>
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onRouteClick: (route: Route) => void
|
||||
filter: 'all' | RouteStatus
|
||||
}
|
||||
|
||||
function RouteGroup({
|
||||
group,
|
||||
encounterByRoute,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onRouteClick,
|
||||
filter,
|
||||
}: RouteGroupProps) {
|
||||
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
||||
const groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
||||
const si = statusIndicator[groupStatus]
|
||||
|
||||
// For groups, check if it matches the filter
|
||||
if (filter !== 'all' && groupStatus !== filter) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasGroupEncounter = groupEncounter !== null
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Group header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{group.name}
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
({group.children.length} areas)
|
||||
</span>
|
||||
</div>
|
||||
{groupEncounter && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{groupEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={groupEncounter.pokemon.spriteUrl}
|
||||
alt={groupEncounter.pokemon.name}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
)}
|
||||
<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]
|
||||
const isDisabled = hasGroupEncounter && !childEncounter
|
||||
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => !isDisabled && onRouteClick(child)}
|
||||
disabled={isDisabled}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${
|
||||
isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||
} ${childSi.bg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{child.name}
|
||||
</div>
|
||||
</div>
|
||||
{childEncounter && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{childSi.label}
|
||||
</span>
|
||||
)}
|
||||
{isDisabled && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
(locked)
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunEncounters() {
|
||||
const { runId } = useParams<{ runId: string }>()
|
||||
const runIdNum = Number(runId)
|
||||
@@ -49,6 +223,13 @@ export function RunEncounters() {
|
||||
const [editingEncounter, setEditingEncounter] =
|
||||
useState<EncounterDetail | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
||||
|
||||
// Organize routes into hierarchical structure
|
||||
const organizedRoutes = useMemo(() => {
|
||||
if (!routes) return []
|
||||
return organizeRoutes(routes)
|
||||
}, [routes])
|
||||
|
||||
if (isLoading || loadingRoutes) {
|
||||
return (
|
||||
@@ -80,19 +261,29 @@ export function RunEncounters() {
|
||||
encounterByRoute.set(enc.routeId, enc)
|
||||
}
|
||||
|
||||
const allRoutes = routes ?? []
|
||||
const completedCount = allRoutes.filter((r) =>
|
||||
encounterByRoute.has(r.id),
|
||||
).length
|
||||
// Count completed locations (groups count as 1, standalone routes count as 1)
|
||||
const completedCount = organizedRoutes.filter((r) => {
|
||||
if (r.children.length > 0) {
|
||||
// It's a group - check if any child has an encounter
|
||||
return getGroupEncounter(r, encounterByRoute) !== null
|
||||
}
|
||||
// Standalone route
|
||||
return encounterByRoute.has(r.id)
|
||||
}).length
|
||||
|
||||
// Filter routes
|
||||
const filteredRoutes =
|
||||
filter === 'all'
|
||||
? allRoutes
|
||||
: allRoutes.filter((r) => {
|
||||
const enc = encounterByRoute.get(r.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
const totalLocations = organizedRoutes.length
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setExpandedGroups((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)
|
||||
@@ -136,6 +327,21 @@ export function RunEncounters() {
|
||||
})
|
||||
}
|
||||
|
||||
// Filter routes
|
||||
const filteredRoutes = organizedRoutes.filter((r) => {
|
||||
if (filter === 'all') return true
|
||||
|
||||
if (r.children.length > 0) {
|
||||
// It's a group
|
||||
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
||||
return getRouteStatus(groupEnc ?? undefined) === filter
|
||||
}
|
||||
|
||||
// Standalone route
|
||||
const enc = encounterByRoute.get(r.id)
|
||||
return getRouteStatus(enc) === filter
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
{/* Header */}
|
||||
@@ -150,7 +356,7 @@ export function RunEncounters() {
|
||||
Encounters
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{run.game.name} · {completedCount} / {allRoutes.length} routes
|
||||
{run.game.name} · {completedCount} / {totalLocations} locations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +366,7 @@ export function RunEncounters() {
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${allRoutes.length > 0 ? (completedCount / allRoutes.length) * 100 : 0}%`,
|
||||
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -199,6 +405,22 @@ export function RunEncounters() {
|
||||
</p>
|
||||
)}
|
||||
{filteredRoutes.map((route) => {
|
||||
// Render as group if it has children
|
||||
if (route.children.length > 0) {
|
||||
return (
|
||||
<RouteGroup
|
||||
key={route.id}
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
filter={filter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Standalone route (no children)
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const rs = getRouteStatus(encounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface Route {
|
||||
name: string
|
||||
gameId: number
|
||||
order: number
|
||||
parentRouteId: number | null
|
||||
}
|
||||
|
||||
export interface RouteWithChildren extends Route {
|
||||
children: Route[]
|
||||
}
|
||||
|
||||
export interface Pokemon {
|
||||
|
||||
Reference in New Issue
Block a user