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:
@@ -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.
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export function RunDashboard() {
|
|||||||
}}
|
}}
|
||||||
onClose={() => setSelectedEncounter(null)}
|
onClose={() => setSelectedEncounter(null)}
|
||||||
isPending={updateEncounter.isPending}
|
isPending={updateEncounter.isPending}
|
||||||
|
region={run?.game.region}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ export function RunEncounters() {
|
|||||||
}}
|
}}
|
||||||
onClose={() => setSelectedTeamEncounter(null)}
|
onClose={() => setSelectedTeamEncounter(null)}
|
||||||
isPending={updateEncounter.isPending}
|
isPending={updateEncounter.isPending}
|
||||||
|
region={run?.game.region}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user