Seed all Pokemon species and add admin pagination

- Update fetch_pokeapi.py to import all 1025 Pokemon species instead of
  only those appearing in encounters
- Add paginated response for /pokemon endpoint with total count
- Add pagination controls to AdminPokemon page (First/Prev/Next/Last)
- Show current page and total count in admin UI
- Add bean for Pokemon forms support task
- Update UX improvements bean with route grouping polish item

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-06 11:19:05 +01:00
parent 2aa60f0ace
commit fce6756cc2
12 changed files with 10566 additions and 27 deletions

View File

@@ -0,0 +1,31 @@
---
# nuzlocke-tracker-f44d
title: Add Pokemon forms support to seeding
status: todo
type: task
created_at: 2026-02-06T10:11:23Z
updated_at: 2026-02-06T10:11:23Z
---
The current seeding only fetches base Pokemon species. It should also include alternate forms (Alolan, Galarian, Mega, regional variants, etc.) which have different types and stats.
## Scope
1. **Data model evaluation**: Determine if Pokemon forms need a separate table or can be handled with additional fields on the existing Pokemon model (e.g., `form_name`, `base_pokemon_id`)
2. **PokeAPI structure**: Investigate how forms are represented in PokeAPI data:
- `pokemon-form` endpoint
- `pokemon` endpoint (forms like `pikachu-alola` have separate entries)
- Relationship between species and forms
3. **Seed data updates**: Update `fetch_pokeapi.py` to:
- Fetch all forms for Pokemon that appear in encounter tables
- Include form-specific data (types, sprites)
- Handle form naming consistently
4. **Frontend considerations**: Ensure Pokemon selector in encounter modal can distinguish forms when relevant
## Questions to resolve
- Should forms be stored as separate Pokemon records or as variants of a base Pokemon?
- How do encounter tables reference forms vs base species in PokeAPI?
- Which games have form-specific encounters that need to be supported?

View File

@@ -11,7 +11,8 @@ The current encounter tracking and run dashboard UX is clunky. Do a holistic UX
Areas to evaluate: Areas to evaluate:
- Encounter logging flow (too many clicks? modal vs inline?) - Encounter logging flow (too many clicks? modal vs inline?)
- Route list readability and navigation (long lists, no grouping) - Route list readability and navigation (long lists)
- Route grouping UX polish (auto-expand unvisited groups, remember expand state, visual hierarchy for parent vs child)
- Run dashboard information density - Run dashboard information density
- Mobile usability - Mobile usability
- Navigation between run dashboard and encounters - Navigation between run dashboard and encounters

View File

@@ -12,6 +12,7 @@ from app.schemas.pokemon import (
BulkImportItem, BulkImportItem,
BulkImportResult, BulkImportResult,
EvolutionResponse, EvolutionResponse,
PaginatedPokemonResponse,
PokemonCreate, PokemonCreate,
PokemonResponse, PokemonResponse,
PokemonUpdate, PokemonUpdate,
@@ -23,21 +24,35 @@ from app.schemas.pokemon import (
router = APIRouter() router = APIRouter()
@router.get("/pokemon", response_model=list[PokemonResponse]) @router.get("/pokemon", response_model=PaginatedPokemonResponse)
async def list_pokemon( async def list_pokemon(
search: str | None = Query(None), search: str | None = Query(None),
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
query = select(Pokemon) # Build base query with optional search filter
base_query = select(Pokemon)
if search: if search:
query = query.where( base_query = base_query.where(
func.lower(Pokemon.name).contains(search.lower()) func.lower(Pokemon.name).contains(search.lower())
) )
query = query.order_by(Pokemon.national_dex).offset(offset).limit(limit)
result = await session.execute(query) # Get total count
return result.scalars().all() count_query = select(func.count()).select_from(base_query.subquery())
total = (await session.execute(count_query)).scalar() or 0
# Get paginated items
items_query = base_query.order_by(Pokemon.national_dex).offset(offset).limit(limit)
result = await session.execute(items_query)
items = result.scalars().all()
return PaginatedPokemonResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
@router.post("/pokemon/bulk-import", response_model=BulkImportResult) @router.post("/pokemon/bulk-import", response_model=BulkImportResult)

View File

@@ -11,6 +11,13 @@ class PokemonResponse(CamelModel):
sprite_url: str | None sprite_url: str | None
class PaginatedPokemonResponse(CamelModel):
items: list[PokemonResponse]
total: int
limit: int
offset: int
class EvolutionResponse(CamelModel): class EvolutionResponse(CamelModel):
id: int id: int
from_pokemon_id: int from_pokemon_id: int

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -304,14 +304,23 @@ def process_version(version_name: str, vg_info: dict) -> list[dict]:
return routes return routes
def fetch_pokemon_data(dex_numbers: set[int]) -> list[dict]: def fetch_all_pokemon() -> list[dict]:
"""Fetch Pokemon name/type data for all collected dex numbers.""" """Fetch all Pokemon species from the local PokeAPI data."""
print(f"\n--- Fetching {len(dex_numbers)} Pokemon ---") pokemon_dir = POKEAPI_DIR / "pokemon-species"
# Get all species IDs (directories with numeric names, excluding forms 10000+)
all_species = []
for entry in pokemon_dir.iterdir():
if entry.is_dir() and entry.name.isdigit():
dex = int(entry.name)
if dex < 10000: # Exclude alternate forms
all_species.append(dex)
all_species.sort()
print(f"\n--- Fetching {len(all_species)} Pokemon species ---")
pokemon_list = [] pokemon_list = []
dex_sorted = sorted(dex_numbers) for i, dex in enumerate(all_species, 1):
for i, dex in enumerate(dex_sorted, 1):
poke = load_resource("pokemon", dex) poke = load_resource("pokemon", dex)
types = [t["type"]["name"] for t in poke["types"]] types = [t["type"]["name"] for t in poke["types"]]
pokemon_list.append({ pokemon_list.append({
@@ -321,8 +330,8 @@ def fetch_pokemon_data(dex_numbers: set[int]) -> list[dict]:
"sprite_url": f"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{dex}.png", "sprite_url": f"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{dex}.png",
}) })
if i % 50 == 0 or i == len(dex_sorted): if i % 100 == 0 or i == len(all_species):
print(f" Fetched {i}/{len(dex_sorted)}") print(f" Fetched {i}/{len(all_species)}")
return sorted(pokemon_list, key=lambda x: x["national_dex"]) return sorted(pokemon_list, key=lambda x: x["national_dex"])
@@ -512,13 +521,16 @@ def main():
routes = process_version(ver_name, vg_info) routes = process_version(ver_name, vg_info)
write_json(f"{ver_name}.json", routes) write_json(f"{ver_name}.json", routes)
# Fetch all Pokemon data # Fetch all Pokemon species
pokemon = fetch_pokemon_data(all_pokemon_dex) pokemon = fetch_all_pokemon()
write_json("pokemon.json", pokemon) write_json("pokemon.json", pokemon)
print(f"\nWrote {len(pokemon)} Pokemon to pokemon.json") print(f"\nWrote {len(pokemon)} Pokemon to pokemon.json")
# Fetch evolution chains # Build set of all seeded dex numbers for evolution filtering
evolutions = fetch_evolution_data(all_pokemon_dex) all_seeded_dex = {p["national_dex"] for p in pokemon}
# Fetch evolution chains for all seeded Pokemon
evolutions = fetch_evolution_data(all_seeded_dex)
apply_evolution_overrides(evolutions) apply_evolution_overrides(evolutions)
write_json("evolutions.json", evolutions) write_json("evolutions.json", evolutions)
print(f"\nWrote {len(evolutions)} evolution pairs to evolutions.json") print(f"\nWrote {len(evolutions)} evolution pairs to evolutions.json")

View File

@@ -12,6 +12,7 @@ import type {
CreatePokemonInput, CreatePokemonInput,
UpdatePokemonInput, UpdatePokemonInput,
BulkImportResult, BulkImportResult,
PaginatedPokemon,
CreateRouteEncounterInput, CreateRouteEncounterInput,
UpdateRouteEncounterInput, UpdateRouteEncounterInput,
} from '../types' } from '../types'
@@ -45,7 +46,7 @@ export const listPokemon = (search?: string, limit = 50, offset = 0) => {
if (search) params.set('search', search) if (search) params.set('search', search)
params.set('limit', String(limit)) params.set('limit', String(limit))
params.set('offset', String(offset)) params.set('offset', String(offset))
return api.get<Pokemon[]>(`/pokemon?${params}`) return api.get<PaginatedPokemon>(`/pokemon?${params}`)
} }
export const createPokemon = (data: CreatePokemonInput) => export const createPokemon = (data: CreatePokemonInput) =>

View File

@@ -23,7 +23,8 @@ export function RouteEncounterFormModal({
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? '')) const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? '')) const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
const { data: pokemonOptions = [] } = usePokemonList(search || undefined) const { data } = usePokemonList(search || undefined)
const pokemonOptions = data?.items ?? []
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()

View File

@@ -14,10 +14,10 @@ import type {
// --- Queries --- // --- Queries ---
export function usePokemonList(search?: string) { export function usePokemonList(search?: string, limit = 50, offset = 0) {
return useQuery({ return useQuery({
queryKey: ['pokemon', { search }], queryKey: ['pokemon', { search, limit, offset }],
queryFn: () => adminApi.listPokemon(search), queryFn: () => adminApi.listPokemon(search, limit, offset),
}) })
} }

View File

@@ -12,9 +12,16 @@ import {
} from '../../hooks/useAdmin' } from '../../hooks/useAdmin'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types' import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
const PAGE_SIZE = 50
export function AdminPokemon() { export function AdminPokemon() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { data: pokemon = [], isLoading } = usePokemonList(search || undefined) const [page, setPage] = useState(0)
const offset = page * PAGE_SIZE
const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset)
const pokemon = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
const createPokemon = useCreatePokemon() const createPokemon = useCreatePokemon()
const updatePokemon = useUpdatePokemon() const updatePokemon = useUpdatePokemon()
const deletePokemon = useDeletePokemon() const deletePokemon = useDeletePokemon()
@@ -80,14 +87,20 @@ export function AdminPokemon() {
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4 flex items-center gap-4">
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => {
setSearch(e.target.value)
setPage(0) // Reset to first page on search
}}
placeholder="Search by name..." placeholder="Search by name..."
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/> />
<span className="text-sm text-gray-500 dark:text-gray-400">
{total} pokemon
</span>
</div> </div>
<AdminTable <AdminTable
@@ -98,6 +111,48 @@ export function AdminPokemon() {
keyFn={(p) => p.id} keyFn={(p) => p.id}
/> />
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(0)}
disabled={page === 0}
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
First
</button>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Prev
</button>
<span className="text-sm text-gray-600 dark:text-gray-300 px-2">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Next
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Last
</button>
</div>
</div>
)}
{showCreate && ( {showCreate && (
<PokemonFormModal <PokemonFormModal
onSubmit={(data) => onSubmit={(data) =>

View File

@@ -51,6 +51,13 @@ export interface BulkImportResult {
errors: string[] errors: string[]
} }
export interface PaginatedPokemon {
items: import('./game').Pokemon[]
total: number
limit: number
offset: number
}
export interface CreateRouteEncounterInput { export interface CreateRouteEncounterInput {
pokemonId: number pokemonId: number
encounterMethod: string encounterMethod: string