Add nullable region field to evolutions for regional form filtering

Regional evolutions (e.g., Pikachu → Alolan Raichu) only occur in specific
regions. This adds a nullable region column so the app can filter evolutions
by the game's region. When a regional evolution exists for a given trigger/item,
the non-regional counterpart is automatically hidden.

Full-stack: migration, model, schemas, API with region query param, seeder,
Go fetch tool, frontend types/API/hook/components, and admin form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 20:05:07 +01:00
parent 23a7b6ad53
commit a65efa22da
17 changed files with 147 additions and 11 deletions

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-o18w
title: Add region field to evolutions
status: completed
type: feature
priority: normal
created_at: 2026-02-07T18:55:07Z
updated_at: 2026-02-07T18:57:25Z
---
Add nullable region field to evolutions for regional form evolution filtering (e.g., Pikachu → Alolan Raichu only in Alola). Full-stack: DB migration, model, schemas, API, seeder, Go tool, frontend types/API/hook/components.

View File

@@ -0,0 +1,29 @@
"""add region to evolutions
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-02-07 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f6a7b8c9d0e1'
down_revision: Union[str, Sequence[str], None] = 'e5f6a7b8c9d0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'evolutions',
sa.Column('region', sa.String(30), nullable=True),
)
def downgrade() -> None:
op.drop_column('evolutions', 'region')

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
@@ -121,18 +121,40 @@ async def get_pokemon(
@router.get("/pokemon/{pokemon_id}/evolutions", response_model=list[EvolutionResponse]) @router.get("/pokemon/{pokemon_id}/evolutions", response_model=list[EvolutionResponse])
async def get_pokemon_evolutions( async def get_pokemon_evolutions(
pokemon_id: int, session: AsyncSession = Depends(get_session) pokemon_id: int,
region: str | None = Query(None),
session: AsyncSession = Depends(get_session),
): ):
pokemon = await session.get(Pokemon, pokemon_id) pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None: if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found") raise HTTPException(status_code=404, detail="Pokemon not found")
result = await session.execute( query = (
select(Evolution) select(Evolution)
.where(Evolution.from_pokemon_id == pokemon_id) .where(Evolution.from_pokemon_id == pokemon_id)
.options(joinedload(Evolution.to_pokemon)) .options(joinedload(Evolution.to_pokemon))
) )
return result.scalars().unique().all() if region is not None:
query = query.where(
or_(Evolution.region.is_(None), Evolution.region == region)
)
result = await session.execute(query)
evolutions = result.scalars().unique().all()
if region is not None:
# Regional evolutions replace the non-regional one that shares the
# same trigger + item (e.g. Pikachu + thunder-stone → Alolan Raichu
# replaces Pikachu + thunder-stone → Raichu in Alola).
regional_keys = {
(e.trigger, e.item) for e in evolutions if e.region is not None
}
if regional_keys:
evolutions = [
e for e in evolutions
if e.region is not None or (e.trigger, e.item) not in regional_keys
]
return evolutions
@router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse) @router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse)

View File

@@ -15,6 +15,7 @@ class Evolution(Base):
item: Mapped[str | None] = mapped_column(String(50)) # e.g. thunder-stone item: Mapped[str | None] = mapped_column(String(50)) # e.g. thunder-stone
held_item: Mapped[str | None] = mapped_column(String(50)) held_item: Mapped[str | None] = mapped_column(String(50))
condition: Mapped[str | None] = mapped_column(String(200)) # catch-all for other conditions condition: Mapped[str | None] = mapped_column(String(200)) # catch-all for other conditions
region: Mapped[str | None] = mapped_column(String(30))
from_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[from_pokemon_id]) from_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[from_pokemon_id])
to_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[to_pokemon_id]) to_pokemon: Mapped["Pokemon"] = relationship(foreign_keys=[to_pokemon_id])

View File

@@ -28,6 +28,7 @@ class EvolutionResponse(CamelModel):
item: str | None item: str | None
held_item: str | None held_item: str | None
condition: str | None condition: str | None
region: str | None
class RouteEncounterResponse(CamelModel): class RouteEncounterResponse(CamelModel):
@@ -106,6 +107,7 @@ class EvolutionAdminResponse(CamelModel):
item: str | None item: str | None
held_item: str | None held_item: str | None
condition: str | None condition: str | None
region: str | None
class PaginatedEvolutionResponse(CamelModel): class PaginatedEvolutionResponse(CamelModel):
@@ -123,6 +125,7 @@ class EvolutionCreate(CamelModel):
item: str | None = None item: str | None = None
held_item: str | None = None held_item: str | None = None
condition: str | None = None condition: str | None = None
region: str | None = None
class EvolutionUpdate(CamelModel): class EvolutionUpdate(CamelModel):
@@ -133,3 +136,4 @@ class EvolutionUpdate(CamelModel):
item: str | None = None item: str | None = None
held_item: str | None = None held_item: str | None = None
condition: str | None = None condition: str | None = None
region: str | None = None

View File

@@ -1,5 +1,42 @@
{ {
"remove": [], "remove": [],
"add": [], "add": [
{
"from_dex": 25,
"to_dex": 10100,
"trigger": "use-item",
"item": "thunder-stone",
"region": "alola"
},
{
"from_dex": 102,
"to_dex": 10101,
"trigger": "use-item",
"item": "leaf-stone",
"region": "alola"
},
{
"from_dex": 77,
"to_dex": 10162,
"trigger": "level-up",
"min_level": 40,
"region": "galar"
},
{
"from_dex": 263,
"to_dex": 10176,
"trigger": "level-up",
"min_level": 20,
"region": "galar"
},
{
"from_dex": 10176,
"to_dex": 10177,
"trigger": "level-up",
"min_level": 35,
"condition": "nighttime",
"region": "galar"
}
],
"modify": [] "modify": []
} }

View File

@@ -184,6 +184,7 @@ async def upsert_evolutions(
item=evo.get("item"), item=evo.get("item"),
held_item=evo.get("held_item"), held_item=evo.get("held_item"),
condition=evo.get("condition"), condition=evo.get("condition"),
region=evo.get("region"),
) )
session.add(evolution) session.add(evolution)
count += 1 count += 1

View File

@@ -24,6 +24,7 @@ export function deleteEncounter(id: number): Promise<void> {
return api.del(`/encounters/${id}`) return api.del(`/encounters/${id}`)
} }
export function fetchEvolutions(pokemonId: number): Promise<Evolution[]> { export function fetchEvolutions(pokemonId: number, region?: string): Promise<Evolution[]> {
return api.get(`/pokemon/${pokemonId}/evolutions`) const params = region ? `?region=${encodeURIComponent(region)}` : ''
return api.get(`/pokemon/${pokemonId}/evolutions${params}`)
} }

View File

@@ -10,6 +10,7 @@ interface StatusChangeModalProps {
}) => void }) => void
onClose: () => void onClose: () => void
isPending: boolean isPending: boolean
region?: string
} }
const typeColors: Record<string, string> = { const typeColors: Record<string, string> = {
@@ -60,6 +61,7 @@ export function StatusChangeModal({
onUpdate, onUpdate,
onClose, onClose,
isPending, isPending,
region,
}: StatusChangeModalProps) { }: StatusChangeModalProps) {
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
encounter encounter
@@ -72,7 +74,8 @@ export function StatusChangeModal({
const activePokemonId = currentPokemon?.id ?? pokemon.id const activePokemonId = currentPokemon?.id ?? pokemon.id
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions( const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
showEvolve ? activePokemonId : null showEvolve ? activePokemonId : null,
region,
) )
const handleConfirmDeath = () => { const handleConfirmDeath = () => {

View File

@@ -29,6 +29,7 @@ export function EvolutionFormModal({
const [item, setItem] = useState(evolution?.item ?? '') const [item, setItem] = useState(evolution?.item ?? '')
const [heldItem, setHeldItem] = useState(evolution?.heldItem ?? '') const [heldItem, setHeldItem] = useState(evolution?.heldItem ?? '')
const [condition, setCondition] = useState(evolution?.condition ?? '') const [condition, setCondition] = useState(evolution?.condition ?? '')
const [region, setRegion] = useState(evolution?.region ?? '')
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -41,6 +42,7 @@ export function EvolutionFormModal({
item: item || null, item: item || null,
heldItem: heldItem || null, heldItem: heldItem || null,
condition: condition || null, condition: condition || null,
region: region || null,
}) })
} }
@@ -119,6 +121,16 @@ export function EvolutionFormModal({
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">Region</label>
<input
type="text"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="e.g. alola (blank = all regions)"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</FormModal> </FormModal>
) )
} }

View File

@@ -43,10 +43,10 @@ export function useDeleteEncounter(runId: number) {
}) })
} }
export function useEvolutions(pokemonId: number | null) { export function useEvolutions(pokemonId: number | null, region?: string) {
return useQuery({ return useQuery({
queryKey: ['evolutions', pokemonId], queryKey: ['evolutions', pokemonId, region],
queryFn: () => fetchEvolutions(pokemonId!), queryFn: () => fetchEvolutions(pokemonId!, region),
enabled: pokemonId !== null, enabled: pokemonId !== null,
}) })
} }

View File

@@ -242,6 +242,7 @@ export function RunDashboard() {
}} }}
onClose={() => setSelectedEncounter(null)} onClose={() => setSelectedEncounter(null)}
isPending={updateEncounter.isPending} isPending={updateEncounter.isPending}
region={run?.game.region}
/> />
)} )}

View File

@@ -748,6 +748,7 @@ export function RunEncounters() {
}} }}
onClose={() => setSelectedTeamEncounter(null)} onClose={() => setSelectedTeamEncounter(null)}
isPending={updateEncounter.isPending} isPending={updateEncounter.isPending}
region={run?.game.region}
/> />
)} )}

View File

@@ -86,6 +86,7 @@ export interface EvolutionAdmin {
item: string | null item: string | null
heldItem: string | null heldItem: string | null
condition: string | null condition: string | null
region: string | null
} }
export interface PaginatedEvolutions { export interface PaginatedEvolutions {
@@ -103,6 +104,7 @@ export interface CreateEvolutionInput {
item?: string | null item?: string | null
heldItem?: string | null heldItem?: string | null
condition?: string | null condition?: string | null
region?: string | null
} }
export interface UpdateEvolutionInput { export interface UpdateEvolutionInput {
@@ -113,4 +115,5 @@ export interface UpdateEvolutionInput {
item?: string | null item?: string | null
heldItem?: string | null heldItem?: string | null
condition?: string | null condition?: string | null
region?: string | null
} }

View File

@@ -93,6 +93,7 @@ export interface Evolution {
item: string | null item: string | null
heldItem: string | null heldItem: string | null
condition: string | null condition: string | null
region: string | null
} }
export interface CreateRunInput { export interface CreateRunInput {

View File

@@ -165,6 +165,7 @@ type EvolutionOverrides struct {
Item *string `json:"item"` Item *string `json:"item"`
HeldItem *string `json:"held_item"` HeldItem *string `json:"held_item"`
Condition *string `json:"condition"` Condition *string `json:"condition"`
Region *string `json:"region"`
} `json:"add"` } `json:"add"`
Modify []struct { Modify []struct {
FromDex int `json:"from_dex"` FromDex int `json:"from_dex"`
@@ -213,6 +214,7 @@ func applyEvolutionOverrides(evolutions []EvolutionOutput, overridesPath string)
Item: addition.Item, Item: addition.Item,
HeldItem: addition.HeldItem, HeldItem: addition.HeldItem,
Condition: addition.Condition, Condition: addition.Condition,
Region: addition.Region,
}) })
} }
@@ -252,6 +254,12 @@ func applyEvolutionOverrides(evolutions []EvolutionOutput, overridesPath string)
} else if value == nil { } else if value == nil {
e.Condition = nil e.Condition = nil
} }
case "region":
if s, ok := value.(string); ok {
e.Region = &s
} else if value == nil {
e.Region = nil
}
} }
} }
} }

View File

@@ -27,6 +27,7 @@ type EvolutionOutput struct {
Item *string `json:"item"` Item *string `json:"item"`
HeldItem *string `json:"held_item"` HeldItem *string `json:"held_item"`
Condition *string `json:"condition"` Condition *string `json:"condition"`
Region *string `json:"region"`
} }
type RouteOutput struct { type RouteOutput struct {