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:
@@ -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?
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user