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)} /> )}