From a01d01c5651271495ad5fa55e0ceadfc29cad094 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 8 Feb 2026 14:03:43 +0100 Subject: [PATCH] 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 --- ...ick-to-edit-pattern-across-admin-tables.md | 4 +- ...tail-card-with-encounters-and-evolution.md | 28 +- backend/src/app/api/pokemon.py | 101 ++++++ backend/src/app/schemas/pokemon.py | 15 + frontend/src/api/pokemon.ts | 9 + frontend/src/components/StatusChangeModal.tsx | 23 +- .../src/components/admin/PokemonFormModal.tsx | 341 ++++++++++++++---- frontend/src/hooks/usePokemon.ts | 18 +- frontend/src/types/admin.ts | 16 + frontend/src/utils/formatEvolution.ts | 21 ++ 10 files changed, 482 insertions(+), 94 deletions(-) create mode 100644 frontend/src/utils/formatEvolution.ts diff --git a/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md b/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md index b3e1814..b954377 100644 --- a/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md +++ b/.beans/nuzlocke-tracker-dyzh--click-to-edit-pattern-across-admin-tables.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-dyzh title: Click-to-edit pattern across admin tables -status: todo +status: completed type: feature priority: high created_at: 2026-02-08T12:32:53Z -updated_at: 2026-02-08T12:35:03Z +updated_at: 2026-02-08T12:45:17Z parent: nuzlocke-tracker-iu5b blocking: - nuzlocke-tracker-fxi7 diff --git a/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md b/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md index e5d3562..e87901b 100644 --- a/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md +++ b/.beans/nuzlocke-tracker-fxi7--pokemon-detail-card-with-encounters-and-evolution.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-fxi7 title: Pokemon detail card with encounters and evolution chain -status: todo +status: completed type: feature priority: high created_at: 2026-02-08T12:33:05Z -updated_at: 2026-02-08T12:33:05Z +updated_at: 2026-02-08T12:53:13Z parent: nuzlocke-tracker-iu5b --- @@ -16,12 +16,28 @@ When viewing/editing a Pokemon in the admin panel, show contextual information a 1. Editable fields (name, types, dex number, sprite, etc.) 2. **Encounter locations**: A list of routes/games where this pokemon appears as a route encounter. Grouped by game, showing route name + encounter method + levels. 3. **Evolution chain**: Visual display of the pokemon's evolution family — predecessors and successors with triggers (level, item, trade, etc.) -- Encounter locations and evolution chain are read-only informational sections - Encounter locations link to the route detail page in admin for quick navigation +- Evolution chain entries are clickable to open the EvolutionFormModal for direct editing -## Backend support -- Encounters by pokemon: May need a new endpoint or can query route_encounters filtered by pokemon_id -- Evolution chain: Can reuse existing /pokemon/{id}/evolutions endpoint, but may need a 'full chain' variant that shows the complete family tree (not just direct evolutions from this pokemon) +## Implementation + +### Tabbed modal (edit mode) +In edit mode, the PokemonFormModal uses three tabs instead of a single scrolling view: +- **Details** — the form fields (PokeAPI ID, name, types, etc.) with Save/Delete/Cancel footer +- **Evolutions** — clickable evolution chain rows that open a stacked EvolutionFormModal for direct editing +- **Encounters** — encounter locations grouped by game, with route names linking to admin route detail pages + +In create mode, no tabs are shown (just the form fields). + +### Backend endpoints +- `GET /pokemon/{id}/encounter-locations` — returns encounters grouped by game with route/game names eagerly loaded +- `GET /pokemon/{id}/evolution-chain` — BFS to find full evolution family, returns all edges with from/to Pokemon + +### Frontend +- New types: `PokemonEncounterLocationItem`, `PokemonEncounterLocation` +- New API functions: `fetchPokemonEncounterLocations`, `fetchPokemonEvolutionChain` +- New hooks: `usePokemonEncounterLocations`, `usePokemonEvolutionChain` +- Extracted `formatEvolutionMethod` to shared `utils/formatEvolution.ts` ## Notes - This helps the admin quickly verify data completeness — 'is this pokemon assigned to the right routes?' and 'are its evolutions set up correctly?' \ No newline at end of file diff --git a/backend/src/app/api/pokemon.py b/backend/src/app/api/pokemon.py index b9df80f..4a2112b 100644 --- a/backend/src/app/api/pokemon.py +++ b/backend/src/app/api/pokemon.py @@ -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, diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index bd33186..6181b03 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -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 --- diff --git a/frontend/src/api/pokemon.ts b/frontend/src/api/pokemon.ts index f64e129..07b35ea 100644 --- a/frontend/src/api/pokemon.ts +++ b/frontend/src/api/pokemon.ts @@ -1,5 +1,6 @@ import { api } from './client' import type { Pokemon } from '../types/game' +import type { EvolutionAdmin, PokemonEncounterLocation } from '../types/admin' export function getPokemon(id: number): Promise { return api.get(`/pokemon/${id}`) @@ -8,3 +9,11 @@ export function getPokemon(id: number): Promise { export function fetchPokemonFamilies(): Promise<{ families: number[][] }> { return api.get('/pokemon/families') } + +export function fetchPokemonEncounterLocations(pokemonId: number): Promise { + return api.get(`/pokemon/${pokemonId}/encounter-locations`) +} + +export function fetchPokemonEvolutionChain(pokemonId: number): Promise { + return api.get(`/pokemon/${pokemonId}/evolution-chain`) +} diff --git a/frontend/src/components/StatusChangeModal.tsx b/frontend/src/components/StatusChangeModal.tsx index c10da00..8fa1a98 100644 --- a/frontend/src/components/StatusChangeModal.tsx +++ b/frontend/src/components/StatusChangeModal.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { EncounterDetail, UpdateEncounterInput } from '../types' import { useEvolutions, useForms } from '../hooks/useEncounters' import { TypeBadge } from './TypeBadge' +import { formatEvolutionMethod } from '../utils/formatEvolution' interface StatusChangeModalProps { encounter: EncounterDetail @@ -14,28 +15,6 @@ interface StatusChangeModalProps { region?: string } -function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string { - const parts: string[] = [] - if (evo.trigger === 'level-up' && evo.minLevel) { - parts.push(`Level ${evo.minLevel}`) - } else if (evo.trigger === 'level-up') { - parts.push('Level up') - } else if (evo.trigger === 'use-item' && evo.item) { - parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) - } else if (evo.trigger === 'trade') { - parts.push('Trade') - } else { - parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) - } - if (evo.heldItem) { - parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`) - } - if (evo.condition) { - parts.push(evo.condition) - } - return parts.join(', ') -} - export function StatusChangeModal({ encounter, onUpdate, diff --git a/frontend/src/components/admin/PokemonFormModal.tsx b/frontend/src/components/admin/PokemonFormModal.tsx index 744785d..e165957 100644 --- a/frontend/src/components/admin/PokemonFormModal.tsx +++ b/frontend/src/components/admin/PokemonFormModal.tsx @@ -1,6 +1,11 @@ -import { type FormEvent, useState } from 'react' -import { FormModal } from './FormModal' -import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types' +import { type FormEvent, useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { EvolutionFormModal } from './EvolutionFormModal' +import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types' +import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon' +import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin' +import { formatEvolutionMethod } from '../../utils/formatEvolution' interface PokemonFormModalProps { pokemon?: Pokemon @@ -11,12 +16,34 @@ interface PokemonFormModalProps { isDeleting?: boolean } +type Tab = 'details' | 'evolutions' | 'encounters' + export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) { const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? '')) const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? '')) const [name, setName] = useState(pokemon?.name ?? '') const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '') const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '') + const [activeTab, setActiveTab] = useState('details') + const [editingEvolution, setEditingEvolution] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(false) + + const isEdit = !!pokemon + const pokemonId = pokemon?.id ?? null + const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId) + const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId) + + const queryClient = useQueryClient() + const updateEvolution = useUpdateEvolution() + const deleteEvolution = useDeleteEvolution() + + useEffect(() => { + setConfirmingDelete(false) + }, [onDelete]) + + const invalidateChain = () => { + queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] }) + } const handleSubmit = (e: FormEvent) => { e.preventDefault() @@ -33,68 +60,256 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD }) } + const tabs: { key: Tab; label: string }[] = [ + { key: 'details', label: 'Details' }, + { key: 'evolutions', label: 'Evolutions' }, + { key: 'encounters', label: 'Encounters' }, + ] + + const tabClass = (tab: Tab) => + `px-3 py-1.5 text-sm font-medium rounded-t-md border-b-2 transition-colors ${ + activeTab === tab + ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' + : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' + }` + return ( - -
- - setPokeapiId(e.target.value)} - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> + <> +
+
+
+ {/* Header */} +
+

{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}

+ {isEdit && ( +
+ {tabs.map((tab) => ( + + ))} +
+ )} +
+ + {/* Details tab (form) */} + {activeTab === 'details' && ( +
+
+
+ + setPokeapiId(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setNationalDex(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setTypes(e.target.value)} + placeholder="Fire, Flying" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+ + setSpriteUrl(e.target.value)} + placeholder="Optional" + className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + /> +
+
+
+ {onDelete && ( + + )} +
+ + +
+ + )} + + {/* Evolutions tab */} + {activeTab === 'evolutions' && ( +
+
+ {evolutionsLoading && ( +

Loading...

+ )} + {!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && ( +

No evolutions

+ )} + {!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && ( +
+ {evolutionChain.map((evo) => ( + + ))} +
+ )} +
+
+ +
+
+ )} + + {/* Encounters tab */} + {activeTab === 'encounters' && ( +
+
+ {encountersLoading && ( +

Loading...

+ )} + {!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && ( +

No encounters

+ )} + {!encountersLoading && encounterLocations && encounterLocations.length > 0 && ( +
+ {encounterLocations.map((game) => ( +
+
+ {game.gameName} +
+
+ {game.encounters.map((enc, i) => ( +
+ + {enc.routeName} + + + — {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel} + +
+ ))} +
+
+ ))} +
+ )} +
+
+ +
+
+ )} +
-
- - setNationalDex(e.target.value)} - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" + + {editingEvolution && ( + + updateEvolution.mutate( + { id: editingEvolution.id, data: data as UpdateEvolutionInput }, + { + onSuccess: () => { + setEditingEvolution(null) + invalidateChain() + }, + }, + ) + } + onClose={() => setEditingEvolution(null)} + isSubmitting={updateEvolution.isPending} + onDelete={() => + deleteEvolution.mutate(editingEvolution.id, { + onSuccess: () => { + setEditingEvolution(null) + invalidateChain() + }, + }) + } + isDeleting={deleteEvolution.isPending} /> -
-
- - setName(e.target.value)} - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> -
-
- - setTypes(e.target.value)} - placeholder="Fire, Flying" - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> -
-
- - setSpriteUrl(e.target.value)} - placeholder="Optional" - className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" - /> -
- + )} + ) } diff --git a/frontend/src/hooks/usePokemon.ts b/frontend/src/hooks/usePokemon.ts index 01794f3..2feb83a 100644 --- a/frontend/src/hooks/usePokemon.ts +++ b/frontend/src/hooks/usePokemon.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { getPokemon, fetchPokemonFamilies } from '../api/pokemon' +import { getPokemon, fetchPokemonFamilies, fetchPokemonEncounterLocations, fetchPokemonEvolutionChain } from '../api/pokemon' export function usePokemon(id: number | null) { return useQuery({ @@ -16,3 +16,19 @@ export function usePokemonFamilies() { staleTime: Infinity, }) } + +export function usePokemonEncounterLocations(pokemonId: number | null) { + return useQuery({ + queryKey: ['pokemon', pokemonId, 'encounter-locations'], + queryFn: () => fetchPokemonEncounterLocations(pokemonId!), + enabled: pokemonId !== null, + }) +} + +export function usePokemonEvolutionChain(pokemonId: number | null) { + return useQuery({ + queryKey: ['pokemon', pokemonId, 'evolution-chain'], + queryFn: () => fetchPokemonEvolutionChain(pokemonId!), + enabled: pokemonId !== null, + }) +} diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index de269d8..cecd8ea 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -121,6 +121,22 @@ export interface UpdateEvolutionInput { region?: string | null } +// Pokemon encounter locations (detail card) +export interface PokemonEncounterLocationItem { + routeId: number + routeName: string + encounterMethod: string + encounterRate: number + minLevel: number + maxLevel: number +} + +export interface PokemonEncounterLocation { + gameId: number + gameName: string + encounters: PokemonEncounterLocationItem[] +} + // Boss battles admin export interface CreateBossBattleInput { name: string diff --git a/frontend/src/utils/formatEvolution.ts b/frontend/src/utils/formatEvolution.ts new file mode 100644 index 0000000..c7c92b1 --- /dev/null +++ b/frontend/src/utils/formatEvolution.ts @@ -0,0 +1,21 @@ +export function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string { + const parts: string[] = [] + if (evo.trigger === 'level-up' && evo.minLevel) { + parts.push(`Level ${evo.minLevel}`) + } else if (evo.trigger === 'level-up') { + parts.push('Level up') + } else if (evo.trigger === 'use-item' && evo.item) { + parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) + } else if (evo.trigger === 'trade') { + parts.push('Trade') + } else { + parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) + } + if (evo.heldItem) { + parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`) + } + if (evo.condition) { + parts.push(evo.condition) + } + return parts.join(', ') +}