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:
@@ -1,19 +1,64 @@
|
||||
---
|
||||
# nuzlocke-tracker-u7i9
|
||||
title: Combine sub-areas into single locations
|
||||
status: todo
|
||||
status: completed
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-02-05T14:27:13Z
|
||||
updated_at: 2026-02-05T14:27:13Z
|
||||
updated_at: 2026-02-06T09:56:11Z
|
||||
parent: nuzlocke-tracker-f5ob
|
||||
---
|
||||
|
||||
Some game locations have multiple encounter tables (e.g. Mount Moon 1F, Mount Moon B1F, Mount Moon B2F) but are treated as a single location for Nuzlocke first-encounter rules.
|
||||
Some game locations have multiple encounter tables (e.g. Mount Moon 1F, Mount Moon B1F, Mount Moon B2F) but are treated as a single location for Nuzlocke first-encounter rules.
|
||||
|
||||
Needs a concept of 'location groups' — a parent location that contains multiple sub-areas, each with their own encounter table. For Nuzlocke purposes, the first encounter in *any* sub-area of the group counts as that location's encounter.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
Added hierarchical route grouping so locations like Mt. Moon (with floors 1F, B1F, B2F) are treated as a single location for Nuzlocke first-encounter rules. The first encounter in ANY sub-area counts as that location's encounter.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Database Migration** (`c3d4e5f6a7b8_add_route_grouping.py`)
|
||||
- Added nullable `parent_route_id` column with self-referential FK (CASCADE delete)
|
||||
|
||||
2. **Backend Model** (`backend/src/app/models/route.py`)
|
||||
- Added `parent_route_id` field
|
||||
- Added self-referential `parent` and `children` relationships
|
||||
|
||||
3. **Backend Schemas** (`backend/src/app/schemas/game.py`)
|
||||
- Added `parent_route_id` to `RouteResponse`, `RouteCreate`, `RouteUpdate`
|
||||
- Added `RouteWithChildrenResponse` with nested children
|
||||
|
||||
4. **Backend API - Games** (`backend/src/app/api/games.py`)
|
||||
- Updated `list_game_routes` to support hierarchical response (default) or flat list (`?flat=true`)
|
||||
|
||||
5. **Backend API - Encounters** (`backend/src/app/api/encounters.py`)
|
||||
- Added validation: cannot create encounter on parent route (400 error)
|
||||
- Added validation: cannot create encounter if sibling already has one (409 error)
|
||||
|
||||
6. **Frontend Types** (`frontend/src/types/game.ts`)
|
||||
- Added `parentRouteId` to `Route` interface
|
||||
- Added `RouteWithChildren` interface
|
||||
|
||||
7. **Frontend Page** (`frontend/src/pages/RunEncounters.tsx`)
|
||||
- Added `organizeRoutes()` helper to build hierarchy from flat list
|
||||
- Added `getGroupEncounter()` to check if any child has an encounter
|
||||
- Updated progress counter to count groups (not individual sub-routes)
|
||||
- Added collapsible `RouteGroup` component
|
||||
- Sibling routes are disabled after one has an encounter
|
||||
|
||||
8. **Seed Data** (`backend/src/app/seeds/fetch_pokeapi.py`)
|
||||
- Updated to automatically detect grouped locations from PokeAPI's location/location-area structure
|
||||
- Parent routes have empty encounters; children have actual encounters
|
||||
- 70 parent routes with 315 child routes across all games
|
||||
|
||||
9. **Seed Loader** (`backend/src/app/seeds/loader.py`, `run.py`)
|
||||
- Updated `upsert_routes` to handle hierarchical structure with parent_route_id
|
||||
- Updated seed runner to process child route encounters
|
||||
|
||||
## Considerations
|
||||
- Data model: add a parent_route_id or location_group concept to the Route model
|
||||
- Seed data: identify which routes should be grouped (may need manual curation per game)
|
||||
- Encounter tracking: when logging an encounter in a sub-area, mark the whole group as visited
|
||||
- Route list UI: show grouped locations as collapsible sections
|
||||
- [x] Data model: add a parent_route_id or location_group concept to the Route model
|
||||
- [x] Seed data: identify which routes should be grouped (automatically derived from PokeAPI location/area structure)
|
||||
- [x] Encounter tracking: when logging an encounter in a sub-area, mark the whole group as visited
|
||||
- [x] Route list UI: show grouped locations as collapsible sections
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add parent_route_id for route grouping
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-02-06 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3d4e5f6a7b8'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'routes',
|
||||
sa.Column(
|
||||
'parent_route_id',
|
||||
sa.Integer(),
|
||||
sa.ForeignKey('routes.id', ondelete='CASCADE'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('routes', 'parent_route_id')
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
@@ -33,11 +33,45 @@ async def create_encounter(
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Validate route exists
|
||||
route = await session.get(Route, data.route_id)
|
||||
# Validate route exists and load its children
|
||||
result = await session.execute(
|
||||
select(Route)
|
||||
.where(Route.id == data.route_id)
|
||||
.options(selectinload(Route.children))
|
||||
)
|
||||
route = result.scalar_one_or_none()
|
||||
if route is None:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
|
||||
# Cannot create encounter on a parent route (routes with children)
|
||||
if route.children:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create encounter on a parent route. Use a child route instead.",
|
||||
)
|
||||
|
||||
# If this route has a parent, check if any sibling already has an encounter
|
||||
if route.parent_route_id is not None:
|
||||
# Get all sibling route IDs (routes with same parent, including this one)
|
||||
siblings_result = await session.execute(
|
||||
select(Route.id).where(Route.parent_route_id == route.parent_route_id)
|
||||
)
|
||||
sibling_ids = [r for r in siblings_result.scalars().all()]
|
||||
|
||||
# Check if any sibling already has an encounter in this run
|
||||
existing_encounter = await session.execute(
|
||||
select(Encounter)
|
||||
.where(
|
||||
Encounter.run_id == run_id,
|
||||
Encounter.route_id.in_(sibling_ids),
|
||||
)
|
||||
)
|
||||
if existing_encounter.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This location group already has an encounter. Only one encounter per location group is allowed.",
|
||||
)
|
||||
|
||||
# Validate pokemon exists
|
||||
pokemon = await session.get(Pokemon, data.pokemon_id)
|
||||
if pokemon is None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.schemas.game import (
|
||||
RouteReorderRequest,
|
||||
RouteResponse,
|
||||
RouteUpdate,
|
||||
RouteWithChildrenResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -42,10 +43,21 @@ async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
||||
return game
|
||||
|
||||
|
||||
@router.get("/{game_id}/routes", response_model=list[RouteResponse])
|
||||
@router.get(
|
||||
"/{game_id}/routes",
|
||||
response_model=list[RouteWithChildrenResponse] | list[RouteResponse],
|
||||
)
|
||||
async def list_game_routes(
|
||||
game_id: int, session: AsyncSession = Depends(get_session)
|
||||
game_id: int,
|
||||
flat: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
List routes for a game.
|
||||
|
||||
By default, returns a hierarchical structure with top-level routes containing
|
||||
nested children. Use `flat=True` to get a flat list of all routes.
|
||||
"""
|
||||
# Verify game exists
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
@@ -56,7 +68,36 @@ async def list_game_routes(
|
||||
.where(Route.game_id == game_id)
|
||||
.order_by(Route.order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
all_routes = result.scalars().all()
|
||||
|
||||
if flat:
|
||||
return all_routes
|
||||
|
||||
# Build hierarchical structure
|
||||
# Group children by parent_route_id
|
||||
children_by_parent: dict[int, list[Route]] = {}
|
||||
top_level_routes: list[Route] = []
|
||||
|
||||
for route in all_routes:
|
||||
if route.parent_route_id is None:
|
||||
top_level_routes.append(route)
|
||||
else:
|
||||
children_by_parent.setdefault(route.parent_route_id, []).append(route)
|
||||
|
||||
# Build response with nested children
|
||||
response = []
|
||||
for route in top_level_routes:
|
||||
route_dict = {
|
||||
"id": route.id,
|
||||
"name": route.name,
|
||||
"game_id": route.game_id,
|
||||
"order": route.order,
|
||||
"parent_route_id": route.parent_route_id,
|
||||
"children": children_by_parent.get(route.id, []),
|
||||
}
|
||||
response.append(route_dict)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# --- Admin endpoints ---
|
||||
|
||||
@@ -14,6 +14,9 @@ class Route(Base):
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
||||
order: Mapped[int] = mapped_column(SmallInteger)
|
||||
parent_route_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("routes.id", ondelete="CASCADE"), index=True, default=None
|
||||
)
|
||||
|
||||
game: Mapped["Game"] = relationship(back_populates="routes")
|
||||
route_encounters: Mapped[list["RouteEncounter"]] = relationship(
|
||||
@@ -21,5 +24,13 @@ class Route(Base):
|
||||
)
|
||||
encounters: Mapped[list["Encounter"]] = relationship(back_populates="route")
|
||||
|
||||
# Self-referential relationships for route grouping
|
||||
parent: Mapped["Route | None"] = relationship(
|
||||
back_populates="children", remote_side=[id]
|
||||
)
|
||||
children: Mapped[list["Route"]] = relationship(
|
||||
back_populates="parent", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Route(id={self.id}, name='{self.name}')>"
|
||||
|
||||
@@ -6,6 +6,7 @@ class RouteResponse(CamelModel):
|
||||
name: str
|
||||
game_id: int
|
||||
order: int
|
||||
parent_route_id: int | None = None
|
||||
|
||||
|
||||
class GameResponse(CamelModel):
|
||||
@@ -18,6 +19,10 @@ class GameResponse(CamelModel):
|
||||
release_year: int | None
|
||||
|
||||
|
||||
class RouteWithChildrenResponse(RouteResponse):
|
||||
children: list[RouteResponse] = []
|
||||
|
||||
|
||||
class GameDetailResponse(GameResponse):
|
||||
routes: list[RouteResponse] = []
|
||||
|
||||
@@ -46,11 +51,13 @@ class GameUpdate(CamelModel):
|
||||
class RouteCreate(CamelModel):
|
||||
name: str
|
||||
order: int
|
||||
parent_route_id: int | None = None
|
||||
|
||||
|
||||
class RouteUpdate(CamelModel):
|
||||
name: str | None = None
|
||||
order: int | None = None
|
||||
parent_route_id: int | None = None
|
||||
|
||||
|
||||
class RouteReorderItem(CamelModel):
|
||||
|
||||
+1861
-1805
File diff suppressed because it is too large
Load Diff
+3616
-3532
File diff suppressed because it is too large
Load Diff
+7634
-7501
File diff suppressed because it is too large
Load Diff
+3616
-3532
File diff suppressed because it is too large
Load Diff
+7618
-7485
File diff suppressed because it is too large
Load Diff
@@ -187,7 +187,11 @@ def aggregate_encounters(raw_encounters: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
||||
"""Process all locations for a specific game version."""
|
||||
"""Process all locations for a specific game version.
|
||||
|
||||
Creates hierarchical route structure where locations with multiple areas
|
||||
become parent routes with child routes for each area.
|
||||
"""
|
||||
print(f"\n--- Processing {version_name} ---")
|
||||
|
||||
region = load_resource("region", vg_info["region_id"])
|
||||
@@ -230,8 +234,38 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
||||
else:
|
||||
all_encounters.extend(encounters)
|
||||
|
||||
# Area-specific encounters become separate routes
|
||||
if area_specific:
|
||||
# If we have multiple area-specific encounters, create a parent route
|
||||
# with child routes for each area (hierarchical grouping)
|
||||
if area_specific and len(area_specific) > 1:
|
||||
# Create parent route (no encounters - just a logical grouping)
|
||||
parent_order = order
|
||||
order += 1
|
||||
|
||||
child_routes = []
|
||||
for area_suffix, area_encs in area_specific.items():
|
||||
aggregated = aggregate_encounters(area_encs)
|
||||
if aggregated:
|
||||
route_name = f"{display_name} ({area_suffix})"
|
||||
for enc in aggregated:
|
||||
all_pokemon_dex.add(enc["national_dex"])
|
||||
child_routes.append({
|
||||
"name": route_name,
|
||||
"order": order,
|
||||
"encounters": aggregated,
|
||||
})
|
||||
order += 1
|
||||
|
||||
# Only add parent if we have child routes
|
||||
if child_routes:
|
||||
routes.append({
|
||||
"name": display_name,
|
||||
"order": parent_order,
|
||||
"encounters": [], # Parent routes have no encounters
|
||||
"children": child_routes,
|
||||
})
|
||||
|
||||
elif area_specific:
|
||||
# Only one area-specific route - don't create parent/child, just use suffix
|
||||
for area_suffix, area_encs in area_specific.items():
|
||||
aggregated = aggregate_encounters(area_encs)
|
||||
if aggregated:
|
||||
@@ -245,6 +279,7 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
||||
})
|
||||
order += 1
|
||||
|
||||
# Non-area-specific encounters (or single area without suffix)
|
||||
if all_encounters:
|
||||
aggregated = aggregate_encounters(all_encounters)
|
||||
if aggregated:
|
||||
@@ -257,8 +292,13 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
|
||||
})
|
||||
order += 1
|
||||
|
||||
print(f" Routes with encounters: {len(routes)}")
|
||||
total_enc = sum(len(r["encounters"]) for r in routes)
|
||||
# Count routes including children
|
||||
total_routes = sum(1 + len(r.get("children", [])) for r in routes)
|
||||
print(f" Routes with encounters: {total_routes}")
|
||||
total_enc = sum(
|
||||
len(r["encounters"]) + sum(len(c["encounters"]) for c in r.get("children", []))
|
||||
for r in routes
|
||||
)
|
||||
print(f" Total encounter entries: {total_enc}")
|
||||
|
||||
return routes
|
||||
|
||||
@@ -66,20 +66,54 @@ async def upsert_routes(
|
||||
game_id: int,
|
||||
routes: list[dict],
|
||||
) -> dict[str, int]:
|
||||
"""Upsert route records for a game, return {name: id} mapping."""
|
||||
"""Upsert route records for a game, return {name: id} mapping.
|
||||
|
||||
Handles hierarchical routes: routes with 'children' are parent routes,
|
||||
and their children get parent_route_id set accordingly.
|
||||
"""
|
||||
# First pass: upsert all parent routes (without parent_route_id)
|
||||
for route in routes:
|
||||
stmt = insert(Route).values(
|
||||
name=route["name"],
|
||||
game_id=game_id,
|
||||
order=route["order"],
|
||||
parent_route_id=None, # Parent routes have no parent
|
||||
).on_conflict_do_update(
|
||||
constraint="uq_routes_game_name",
|
||||
set_={"order": route["order"]},
|
||||
set_={"order": route["order"], "parent_route_id": None},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
await session.flush()
|
||||
|
||||
# Get mapping of parent routes
|
||||
result = await session.execute(
|
||||
select(Route.name, Route.id).where(Route.game_id == game_id)
|
||||
)
|
||||
name_to_id = {row.name: row.id for row in result}
|
||||
|
||||
# Second pass: upsert child routes with parent_route_id
|
||||
for route in routes:
|
||||
children = route.get("children", [])
|
||||
if not children:
|
||||
continue
|
||||
|
||||
parent_id = name_to_id[route["name"]]
|
||||
for child in children:
|
||||
stmt = insert(Route).values(
|
||||
name=child["name"],
|
||||
game_id=game_id,
|
||||
order=child["order"],
|
||||
parent_route_id=parent_id,
|
||||
).on_conflict_do_update(
|
||||
constraint="uq_routes_game_name",
|
||||
set_={"order": child["order"], "parent_route_id": parent_id},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
await session.flush()
|
||||
|
||||
# Return full mapping including children
|
||||
result = await session.execute(
|
||||
select(Route.name, Route.id).where(Route.game_id == game_id)
|
||||
)
|
||||
|
||||
@@ -66,10 +66,24 @@ async def seed():
|
||||
print(f" Warning: route '{route['name']}' not found")
|
||||
continue
|
||||
|
||||
enc_count = await upsert_route_encounters(
|
||||
session, route_id, route["encounters"], dex_to_id
|
||||
)
|
||||
total_encounters += enc_count
|
||||
# Parent routes may have empty encounters
|
||||
if route["encounters"]:
|
||||
enc_count = await upsert_route_encounters(
|
||||
session, route_id, route["encounters"], dex_to_id
|
||||
)
|
||||
total_encounters += enc_count
|
||||
|
||||
# Handle child routes
|
||||
for child in route.get("children", []):
|
||||
child_id = route_map.get(child["name"])
|
||||
if child_id is None:
|
||||
print(f" Warning: child route '{child['name']}' not found")
|
||||
continue
|
||||
|
||||
enc_count = await upsert_route_encounters(
|
||||
session, child_id, child["encounters"], dex_to_id
|
||||
)
|
||||
total_encounters += enc_count
|
||||
|
||||
print(f" {game_slug}: {len(route_map)} routes")
|
||||
|
||||
|
||||
@@ -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