From 8f6d72a9c4ebd7e65a4757501a165174f0af8a27 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 8 Feb 2026 20:14:19 +0100 Subject: [PATCH] Add bulk import for evolutions, routes, and bosses Add three new bulk import endpoints that accept the same JSON format as their corresponding export endpoints, enabling round-trip compatibility: - POST /evolutions/bulk-import (upsert by from/to pokemon pair) - POST /games/{id}/routes/bulk-import (reuses seed loader for hierarchy) - POST /games/{id}/bosses/bulk-import (reuses seed loader with team data) Generalize BulkImportModal to support all entity types with configurable title, example, and result labels. Wire up Bulk Import buttons on AdminEvolutions, and AdminGameDetail routes/bosses tabs. Co-Authored-By: Claude Opus 4.6 --- ...counter-method-input-in-route-encounter.md | 27 ++++++++ ...import-for-evolutions-routes-and-bosses.md | 5 +- backend/src/app/api/bosses.py | 32 +++++++++ backend/src/app/api/evolutions.py | 64 +++++++++++++++++ backend/src/app/api/games.py | 68 +++++++++++++++++++ backend/src/app/schemas/pokemon.py | 57 ++++++++++++++++ frontend/src/api/admin.ts | 9 +++ .../src/components/admin/BulkImportModal.tsx | 25 +++---- frontend/src/hooks/useAdmin.ts | 37 ++++++++++ frontend/src/pages/admin/AdminEvolutions.tsx | 19 ++++++ frontend/src/pages/admin/AdminGameDetail.tsx | 41 +++++++++++ frontend/src/pages/admin/AdminPokemon.tsx | 4 +- 12 files changed, 373 insertions(+), 15 deletions(-) create mode 100644 .beans/nuzlocke-tracker-5o1v--improve-encounter-method-input-in-route-encounter.md diff --git a/.beans/nuzlocke-tracker-5o1v--improve-encounter-method-input-in-route-encounter.md b/.beans/nuzlocke-tracker-5o1v--improve-encounter-method-input-in-route-encounter.md new file mode 100644 index 0000000..010a164 --- /dev/null +++ b/.beans/nuzlocke-tracker-5o1v--improve-encounter-method-input-in-route-encounter.md @@ -0,0 +1,27 @@ +--- +# nuzlocke-tracker-5o1v +title: Improve encounter method input in route encounter form +status: todo +type: feature +created_at: 2026-02-08T19:06:10Z +updated_at: 2026-02-08T19:06:10Z +parent: nuzlocke-tracker-iu5b +--- + +Replace the free-text encounter method input in the route encounter form (RouteEncounterFormModal) with a smarter selector that leverages the known encounter methods already defined in the codebase. + +## Current behavior +- The encounter method field in RouteEncounterFormModal is a plain `` with a placeholder "e.g. Walking, Surfing, Fishing" +- Easy to introduce typos or inconsistent naming (e.g. "walking" vs "walk" vs "Grass") +- The app already has a well-defined set of encounter methods in `EncounterMethodBadge.tsx` with METHOD_CONFIG and METHOD_ORDER (starter, gift, fossil, trade, walk, headbutt, surf, rock-smash, old-rod, good-rod, super-rod) +- The backend stores this as a `String(30)` column, so it's not strictly enum-constrained + +## Desired behavior +- Replace the free-text input with a dropdown/select that lists the known encounter methods from METHOD_ORDER, using the human-readable labels from getMethodLabel() +- Include an "Other" option that reveals a text input for custom methods not in the predefined list +- When editing an existing encounter, pre-select the correct method +- Consider showing the colored badge preview next to each option for visual consistency with how methods appear elsewhere in the app + +## Files +- `frontend/src/components/admin/RouteEncounterFormModal.tsx` — replace the text input with new selector +- `frontend/src/components/EncounterMethodBadge.tsx` — export METHOD_CONFIG or add a helper to get the list of known methods \ No newline at end of file diff --git a/.beans/nuzlocke-tracker-ok1t--bulk-import-for-evolutions-routes-and-bosses.md b/.beans/nuzlocke-tracker-ok1t--bulk-import-for-evolutions-routes-and-bosses.md index 0aa7789..09868dc 100644 --- a/.beans/nuzlocke-tracker-ok1t--bulk-import-for-evolutions-routes-and-bosses.md +++ b/.beans/nuzlocke-tracker-ok1t--bulk-import-for-evolutions-routes-and-bosses.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-ok1t title: Bulk import for evolutions, routes, and bosses -status: todo +status: completed type: feature +priority: normal created_at: 2026-02-08T12:33:39Z -updated_at: 2026-02-08T12:33:39Z +updated_at: 2026-02-08T19:13:27Z parent: nuzlocke-tracker-iu5b --- diff --git a/backend/src/app/api/bosses.py b/backend/src/app/api/bosses.py index 5af567e..4ff9ee5 100644 --- a/backend/src/app/api/bosses.py +++ b/backend/src/app/api/bosses.py @@ -11,6 +11,8 @@ from app.models.boss_pokemon import BossPokemon from app.models.boss_result import BossResult from app.models.game import Game from app.models.nuzlocke_run import NuzlockeRun +from app.models.pokemon import Pokemon +from app.models.route import Route from app.schemas.boss import ( BossBattleCreate, BossBattleResponse, @@ -20,6 +22,8 @@ from app.schemas.boss import ( BossResultCreate, BossResultResponse, ) +from app.schemas.pokemon import BulkBossItem, BulkImportResult +from app.seeds.loader import upsert_bosses router = APIRouter() @@ -164,6 +168,34 @@ async def delete_boss( return Response(status_code=204) +@router.post("/games/{game_id}/bosses/bulk-import", response_model=BulkImportResult) +async def bulk_import_bosses( + game_id: int, + items: list[BulkBossItem], + session: AsyncSession = Depends(get_session), +): + vg_id = await _get_version_group_id(session, game_id) + + # Build pokeapi_id -> id mapping + result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id)) + dex_to_id = {row.pokeapi_id: row.id for row in result} + + # Build route name -> id mapping for after_route_name resolution + result = await session.execute( + select(Route.name, Route.id).where(Route.version_group_id == vg_id) + ) + route_name_to_id = {row.name: row.id for row in result} + + bosses_data = [item.model_dump() for item in items] + try: + count = await upsert_bosses(session, vg_id, bosses_data, dex_to_id, route_name_to_id) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to import bosses: {e}") + + await session.commit() + return BulkImportResult(created=count, updated=0, errors=[]) + + @router.put( "/games/{game_id}/bosses/{boss_id}/pokemon", response_model=BossBattleResponse, diff --git a/backend/src/app/api/evolutions.py b/backend/src/app/api/evolutions.py index 053a62b..8229b35 100644 --- a/backend/src/app/api/evolutions.py +++ b/backend/src/app/api/evolutions.py @@ -7,6 +7,8 @@ from app.core.database import get_session from app.models.evolution import Evolution from app.models.pokemon import Pokemon from app.schemas.pokemon import ( + BulkEvolutionItem, + BulkImportResult, EvolutionAdminResponse, EvolutionCreate, EvolutionUpdate, @@ -144,3 +146,65 @@ async def delete_evolution( await session.delete(evolution) await session.commit() + + +@router.post("/evolutions/bulk-import", response_model=BulkImportResult) +async def bulk_import_evolutions( + items: list[BulkEvolutionItem], + session: AsyncSession = Depends(get_session), +): + # Build pokeapi_id -> id mapping + result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id)) + dex_to_id = {row.pokeapi_id: row.id for row in result} + + created = 0 + updated = 0 + errors: list[str] = [] + + for item in items: + from_id = dex_to_id.get(item.from_pokeapi_id) + to_id = dex_to_id.get(item.to_pokeapi_id) + + if from_id is None: + errors.append(f"Pokemon with pokeapi_id {item.from_pokeapi_id} not found") + continue + if to_id is None: + errors.append(f"Pokemon with pokeapi_id {item.to_pokeapi_id} not found") + continue + + try: + # Check if evolution already exists + existing = await session.execute( + select(Evolution).where( + Evolution.from_pokemon_id == from_id, + Evolution.to_pokemon_id == to_id, + ) + ) + evolution = existing.scalar_one_or_none() + + if evolution is not None: + evolution.trigger = item.trigger + evolution.min_level = item.min_level + evolution.item = item.item + evolution.held_item = item.held_item + evolution.condition = item.condition + evolution.region = item.region + updated += 1 + else: + evolution = Evolution( + from_pokemon_id=from_id, + to_pokemon_id=to_id, + trigger=item.trigger, + min_level=item.min_level, + item=item.item, + held_item=item.held_item, + condition=item.condition, + region=item.region, + ) + session.add(evolution) + created += 1 + except Exception as e: + errors.append(f"Evolution {item.from_pokeapi_id} -> {item.to_pokeapi_id}: {e}") + + await session.commit() + return BulkImportResult(created=created, updated=updated, errors=errors) diff --git a/backend/src/app/api/games.py b/backend/src/app/api/games.py index e7203b1..eacbe30 100644 --- a/backend/src/app/api/games.py +++ b/backend/src/app/api/games.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload from app.core.database import get_session from app.models.boss_battle import BossBattle from app.models.game import Game +from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter from app.schemas.game import ( @@ -19,6 +20,8 @@ from app.schemas.game import ( RouteUpdate, RouteWithChildrenResponse, ) +from app.schemas.pokemon import BulkImportResult, BulkRouteItem +from app.seeds.loader import upsert_route_encounters, upsert_routes router = APIRouter() @@ -332,3 +335,68 @@ async def delete_route( await session.delete(route) await session.commit() + + +@router.post("/{game_id}/routes/bulk-import", response_model=BulkImportResult) +async def bulk_import_routes( + game_id: int, + items: list[BulkRouteItem], + session: AsyncSession = Depends(get_session), +): + vg_id = await _get_version_group_id(session, game_id) + + # Build pokeapi_id -> id mapping for encounter resolution + result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id)) + dex_to_id = {row.pokeapi_id: row.id for row in result} + + errors: list[str] = [] + + # Upsert routes using the seed loader (handles parent/child hierarchy) + routes_data = [item.model_dump() for item in items] + try: + route_name_to_id = await upsert_routes(session, vg_id, routes_data) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to import routes: {e}") + + # Upsert encounters for each route + encounter_count = 0 + for item in items: + route_id = route_name_to_id.get(item.name) + if route_id is None: + errors.append(f"Route '{item.name}' not found after upsert") + continue + + if item.encounters: + try: + count = await upsert_route_encounters( + session, route_id, [e.model_dump() for e in item.encounters], + dex_to_id, game_id, + ) + encounter_count += count + except Exception as e: + errors.append(f"Encounters for '{item.name}': {e}") + + for child in item.children: + child_id = route_name_to_id.get(child.name) + if child_id is None: + errors.append(f"Child route '{child.name}' not found after upsert") + continue + + if child.encounters: + try: + count = await upsert_route_encounters( + session, child_id, [e.model_dump() for e in child.encounters], + dex_to_id, game_id, + ) + encounter_count += count + except Exception as e: + errors.append(f"Encounters for '{child.name}': {e}") + + await session.commit() + + route_count = len(route_name_to_id) + return BulkImportResult( + created=route_count, + updated=encounter_count, + errors=errors, + ) diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index 6181b03..f99e24d 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -158,3 +158,60 @@ class EvolutionUpdate(CamelModel): held_item: str | None = None condition: str | None = None region: str | None = None + + +# --- Bulk import schemas (match export format, snake_case) --- + + +class BulkEvolutionItem(BaseModel): + from_pokeapi_id: int + to_pokeapi_id: int + trigger: str + min_level: int | None = None + item: str | None = None + held_item: str | None = None + condition: str | None = None + region: str | None = None + + +class BulkRouteEncounterItem(BaseModel): + pokeapi_id: int + method: str + encounter_rate: int + min_level: int + max_level: int + + +class BulkRouteChildItem(BaseModel): + name: str + order: int + pinwheel_zone: int | None = None + encounters: list[BulkRouteEncounterItem] = [] + + +class BulkRouteItem(BaseModel): + name: str + order: int + encounters: list[BulkRouteEncounterItem] = [] + children: list[BulkRouteChildItem] = [] + + +class BulkBossPokemonItem(BaseModel): + pokeapi_id: int + level: int + order: int + + +class BulkBossItem(BaseModel): + name: str + boss_type: str + specialty_type: str | None = None + badge_name: str | None = None + badge_image_url: str | None = None + level_cap: int + order: int + after_route_name: str | None = None + location: str + section: str | None = None + sprite_url: str | None = None + pokemon: list[BulkBossPokemonItem] = [] diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index cdb7091..1006e41 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -70,6 +70,15 @@ export const deletePokemon = (id: number) => export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => api.post('/pokemon/bulk-import', items) +export const bulkImportEvolutions = (items: unknown[]) => + api.post('/evolutions/bulk-import', items) + +export const bulkImportRoutes = (gameId: number, items: unknown[]) => + api.post(`/games/${gameId}/routes/bulk-import`, items) + +export const bulkImportBosses = (gameId: number, items: unknown[]) => + api.post(`/games/${gameId}/bosses/bulk-import`, items) + // Evolutions export const listEvolutions = (search?: string, limit = 50, offset = 0) => { const params = new URLSearchParams() diff --git a/frontend/src/components/admin/BulkImportModal.tsx b/frontend/src/components/admin/BulkImportModal.tsx index 758e4a1..3420cbd 100644 --- a/frontend/src/components/admin/BulkImportModal.tsx +++ b/frontend/src/components/admin/BulkImportModal.tsx @@ -2,16 +2,17 @@ import { type FormEvent, useState } from 'react' import type { BulkImportResult } from '../../types' interface BulkImportModalProps { - onSubmit: (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise + title: string + example?: string + onSubmit: (items: unknown[]) => Promise onClose: () => void + /** Label for the "created" count in the result summary */ + createdLabel?: string + /** Label for the "updated" count in the result summary */ + updatedLabel?: string } -const EXAMPLE = `[ - { "pokeapiId": 1, "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }, - { "pokeapiId": 4, "nationalDex": 4, "name": "Charmander", "types": ["Fire"] } -]` - -export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) { +export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: BulkImportModalProps) { const [json, setJson] = useState('') const [error, setError] = useState(null) const [result, setResult] = useState(null) @@ -27,13 +28,13 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) { items = JSON.parse(json) if (!Array.isArray(items)) throw new Error('Must be an array') } catch { - setError('Invalid JSON. Must be an array of pokemon objects.') + setError('Invalid JSON. Must be an array of objects.') return } setIsSubmitting(true) try { - const res = await onSubmit(items as Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[] }>) + const res = await onSubmit(items) setResult(res) } catch (err) { setError(err instanceof Error ? err.message : 'Import failed') @@ -47,7 +48,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
-

Bulk Import Pokemon

+

{title}

@@ -59,7 +60,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) { rows={12} value={json} onChange={(e) => setJson(e.target.value)} - placeholder={EXAMPLE} + placeholder={example} className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600" />
@@ -72,7 +73,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) { {result && (
-

Created: {result.created}, Updated: {result.updated}

+

{createdLabel}: {result.created}, {updatedLabel}: {result.updated}

{result.errors.length > 0 && (
    {result.errors.map((err, i) => ( diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 69d63db..ac29ac2 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -174,6 +174,43 @@ export function useBulkImportPokemon() { }) } +export function useBulkImportEvolutions() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items), + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ['evolutions'] }) + toast.success(`Import complete: ${result.created} created, ${result.updated} updated`) + }, + onError: (err) => toast.error(`Import failed: ${err.message}`), + }) +} + +export function useBulkImportRoutes(gameId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (items: unknown[]) => adminApi.bulkImportRoutes(gameId, items), + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ['games', gameId] }) + qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] }) + toast.success(`Import complete: ${result.created} routes, ${result.updated} encounters`) + }, + onError: (err) => toast.error(`Import failed: ${err.message}`), + }) +} + +export function useBulkImportBosses(gameId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (items: unknown[]) => adminApi.bulkImportBosses(gameId, items), + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] }) + toast.success(`Import complete: ${result.created} bosses imported`) + }, + onError: (err) => toast.error(`Import failed: ${err.message}`), + }) +} + // --- Evolution Queries & Mutations --- export function useEvolutionList(search?: string, limit = 50, offset = 0) { diff --git a/frontend/src/pages/admin/AdminEvolutions.tsx b/frontend/src/pages/admin/AdminEvolutions.tsx index d1c7e89..b7612ad 100644 --- a/frontend/src/pages/admin/AdminEvolutions.tsx +++ b/frontend/src/pages/admin/AdminEvolutions.tsx @@ -1,11 +1,13 @@ import { useState } from 'react' import { AdminTable, type Column } from '../../components/admin/AdminTable' +import { BulkImportModal } from '../../components/admin/BulkImportModal' import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal' import { useEvolutionList, useCreateEvolution, useUpdateEvolution, useDeleteEvolution, + useBulkImportEvolutions, } from '../../hooks/useAdmin' import { exportEvolutions } from '../../api/admin' import { downloadJson } from '../../utils/download' @@ -25,8 +27,10 @@ export function AdminEvolutions() { const createEvolution = useCreateEvolution() const updateEvolution = useUpdateEvolution() const deleteEvolution = useDeleteEvolution() + const bulkImport = useBulkImportEvolutions() const [showCreate, setShowCreate] = useState(false) + const [showBulkImport, setShowBulkImport] = useState(false) const [editing, setEditing] = useState(null) const columns: Column[] = [ @@ -71,6 +75,12 @@ export function AdminEvolutions() { > Export +
)} + {showBulkImport && ( + bulkImport.mutateAsync(items)} + onClose={() => setShowBulkImport(false)} + /> + )} + {showCreate && ( diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx index 3e2ad84..9cd6004 100644 --- a/frontend/src/pages/admin/AdminGameDetail.tsx +++ b/frontend/src/pages/admin/AdminGameDetail.tsx @@ -16,6 +16,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' +import { BulkImportModal } from '../../components/admin/BulkImportModal' import { RouteFormModal } from '../../components/admin/RouteFormModal' import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal' import { BossTeamEditor } from '../../components/admin/BossTeamEditor' @@ -27,11 +28,13 @@ import { useUpdateRoute, useDeleteRoute, useReorderRoutes, + useBulkImportRoutes, useReorderBosses, useCreateBossBattle, useUpdateBossBattle, useDeleteBossBattle, useSetBossTeam, + useBulkImportBosses, } from '../../hooks/useAdmin' import { exportGameRoutes, exportGameBosses } from '../../api/admin' import { downloadJson } from '../../utils/download' @@ -149,16 +152,20 @@ export function AdminGameDetail() { const updateRoute = useUpdateRoute(id) const deleteRoute = useDeleteRoute(id) const reorderRoutes = useReorderRoutes(id) + const bulkImportRoutes = useBulkImportRoutes(id) const { data: bosses } = useGameBosses(id) const createBoss = useCreateBossBattle(id) const updateBoss = useUpdateBossBattle(id) const deleteBoss = useDeleteBossBattle(id) const reorderBosses = useReorderBosses(id) + const bulkImportBosses = useBulkImportBosses(id) const [tab, setTab] = useState<'routes' | 'bosses'>('routes') const [showCreate, setShowCreate] = useState(false) + const [showBulkImportRoutes, setShowBulkImportRoutes] = useState(false) const [editing, setEditing] = useState(null) const [showCreateBoss, setShowCreateBoss] = useState(false) + const [showBulkImportBosses, setShowBulkImportBosses] = useState(false) const [editingBoss, setEditingBoss] = useState(null) const [editingTeam, setEditingTeam] = useState(null) @@ -265,6 +272,12 @@ export function AdminGameDetail() { > Export +
+ {showBulkImportRoutes && ( + bulkImportRoutes.mutateAsync(items)} + onClose={() => setShowBulkImportRoutes(false)} + /> + )} + {routes.length === 0 ? (
No routes yet. Add one to get started. @@ -365,6 +389,12 @@ export function AdminGameDetail() { > Export +
+ {showBulkImportBosses && ( + bulkImportBosses.mutateAsync(items)} + onClose={() => setShowBulkImportBosses(false)} + /> + )} + {!bosses || bosses.length === 0 ? (
No boss battles yet. Add one to get started. diff --git a/frontend/src/pages/admin/AdminPokemon.tsx b/frontend/src/pages/admin/AdminPokemon.tsx index da466f5..c8a7e0b 100644 --- a/frontend/src/pages/admin/AdminPokemon.tsx +++ b/frontend/src/pages/admin/AdminPokemon.tsx @@ -158,7 +158,9 @@ export function AdminPokemon() { {showBulkImport && ( bulkImport.mutateAsync(items)} + title="Bulk Import Pokemon" + example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`} + onSubmit={(items) => bulkImport.mutateAsync(items as Parameters[0])} onClose={() => setShowBulkImport(false)} /> )}