Add Pokemon detail card with tabbed encounter/evolution views

Pokemon edit modal now shows three tabs (Details, Evolutions, Encounters)
instead of a single long form. Evolution chain entries are clickable to
open the EvolutionFormModal for direct editing. Encounter locations link
to admin route detail pages. Create mode shows only the form (no tabs).

Backend adds GET /pokemon/{id}/encounter-locations (grouped by game) and
GET /pokemon/{id}/evolution-chain (BFS family discovery). Extracts
formatEvolutionMethod to shared utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 14:03:43 +01:00
parent f09b8213fd
commit a01d01c565
10 changed files with 482 additions and 94 deletions

View File

@@ -8,13 +8,17 @@ from app.models.evolution import Evolution
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.models.game import Game
from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
EvolutionAdminResponse,
EvolutionResponse,
FamiliesResponse,
PaginatedPokemonResponse,
PokemonCreate,
PokemonEncounterLocationItem,
PokemonEncounterLocationResponse,
PokemonResponse,
PokemonUpdate,
RouteEncounterCreate,
@@ -174,6 +178,103 @@ async def get_pokemon_forms(
return result.scalars().all()
@router.get(
"/pokemon/{pokemon_id}/encounter-locations",
response_model=list[PokemonEncounterLocationResponse],
)
async def get_pokemon_encounter_locations(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.pokemon_id == pokemon_id)
.options(joinedload(RouteEncounter.route), joinedload(RouteEncounter.game))
.order_by(RouteEncounter.game_id, RouteEncounter.route_id)
)
encounters = result.scalars().unique().all()
grouped: dict[int, PokemonEncounterLocationResponse] = {}
for enc in encounters:
if enc.game_id not in grouped:
grouped[enc.game_id] = PokemonEncounterLocationResponse(
game_id=enc.game_id,
game_name=enc.game.name,
encounters=[],
)
grouped[enc.game_id].encounters.append(
PokemonEncounterLocationItem(
route_id=enc.route_id,
route_name=enc.route.name,
encounter_method=enc.encounter_method,
encounter_rate=enc.encounter_rate,
min_level=enc.min_level,
max_level=enc.max_level,
)
)
return list(grouped.values())
@router.get(
"/pokemon/{pokemon_id}/evolution-chain",
response_model=list[EvolutionAdminResponse],
)
async def get_pokemon_evolution_chain(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
from collections import deque
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
# Load all evolutions to build adjacency
result = await session.execute(select(Evolution))
evolutions = result.scalars().all()
adj: dict[int, set[int]] = {}
for evo in evolutions:
adj.setdefault(evo.from_pokemon_id, set()).add(evo.to_pokemon_id)
adj.setdefault(evo.to_pokemon_id, set()).add(evo.from_pokemon_id)
# BFS from pokemon_id to find family members
family: set[int] = set()
queue = deque([pokemon_id])
while queue:
current = queue.popleft()
if current in family:
continue
family.add(current)
for neighbor in adj.get(current, set()):
if neighbor not in family:
queue.append(neighbor)
# Filter evolutions to only those in the family
family_evo_ids = [
evo.id for evo in evolutions
if evo.from_pokemon_id in family and evo.to_pokemon_id in family
]
if not family_evo_ids:
return []
# Reload with eager-loaded relationships
result = await session.execute(
select(Evolution)
.where(Evolution.id.in_(family_evo_ids))
.options(
joinedload(Evolution.from_pokemon),
joinedload(Evolution.to_pokemon),
)
.order_by(Evolution.from_pokemon_id, Evolution.to_pokemon_id)
)
return result.scalars().unique().all()
@router.get("/pokemon/{pokemon_id}/evolutions", response_model=list[EvolutionResponse])
async def get_pokemon_evolutions(
pokemon_id: int,

View File

@@ -50,6 +50,21 @@ class RouteEncounterDetailResponse(RouteEncounterResponse):
pokemon: PokemonResponse
class PokemonEncounterLocationItem(CamelModel):
route_id: int
route_name: str
encounter_method: str
encounter_rate: int
min_level: int
max_level: int
class PokemonEncounterLocationResponse(CamelModel):
game_id: int
game_name: str
encounters: list[PokemonEncounterLocationItem]
# --- Admin schemas ---