Add Pokemon detail card with tabbed encounter/evolution views

Pokemon edit modal now shows three tabs (Details, Evolutions, Encounters)
instead of a single long form. Evolution chain entries are clickable to
open the EvolutionFormModal for direct editing. Encounter locations link
to admin route detail pages. Create mode shows only the form (no tabs).

Backend adds GET /pokemon/{id}/encounter-locations (grouped by game) and
GET /pokemon/{id}/evolution-chain (BFS family discovery). Extracts
formatEvolutionMethod to shared utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 14:03:43 +01:00
parent f09b8213fd
commit a01d01c565
10 changed files with 482 additions and 94 deletions

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-dyzh
title: Click-to-edit pattern across admin tables
status: todo
status: completed
type: feature
priority: high
created_at: 2026-02-08T12:32:53Z
updated_at: 2026-02-08T12:35:03Z
updated_at: 2026-02-08T12:45:17Z
parent: nuzlocke-tracker-iu5b
blocking:
- nuzlocke-tracker-fxi7

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-fxi7
title: Pokemon detail card with encounters and evolution chain
status: todo
status: completed
type: feature
priority: high
created_at: 2026-02-08T12:33:05Z
updated_at: 2026-02-08T12:33:05Z
updated_at: 2026-02-08T12:53:13Z
parent: nuzlocke-tracker-iu5b
---
@@ -16,12 +16,28 @@ When viewing/editing a Pokemon in the admin panel, show contextual information a
1. Editable fields (name, types, dex number, sprite, etc.)
2. **Encounter locations**: A list of routes/games where this pokemon appears as a route encounter. Grouped by game, showing route name + encounter method + levels.
3. **Evolution chain**: Visual display of the pokemon's evolution family — predecessors and successors with triggers (level, item, trade, etc.)
- Encounter locations and evolution chain are read-only informational sections
- Encounter locations link to the route detail page in admin for quick navigation
- Evolution chain entries are clickable to open the EvolutionFormModal for direct editing
## Backend support
- Encounters by pokemon: May need a new endpoint or can query route_encounters filtered by pokemon_id
- Evolution chain: Can reuse existing /pokemon/{id}/evolutions endpoint, but may need a 'full chain' variant that shows the complete family tree (not just direct evolutions from this pokemon)
## Implementation
### Tabbed modal (edit mode)
In edit mode, the PokemonFormModal uses three tabs instead of a single scrolling view:
- **Details** — the form fields (PokeAPI ID, name, types, etc.) with Save/Delete/Cancel footer
- **Evolutions** — clickable evolution chain rows that open a stacked EvolutionFormModal for direct editing
- **Encounters** — encounter locations grouped by game, with route names linking to admin route detail pages
In create mode, no tabs are shown (just the form fields).
### Backend endpoints
- `GET /pokemon/{id}/encounter-locations` — returns encounters grouped by game with route/game names eagerly loaded
- `GET /pokemon/{id}/evolution-chain` — BFS to find full evolution family, returns all edges with from/to Pokemon
### Frontend
- New types: `PokemonEncounterLocationItem`, `PokemonEncounterLocation`
- New API functions: `fetchPokemonEncounterLocations`, `fetchPokemonEvolutionChain`
- New hooks: `usePokemonEncounterLocations`, `usePokemonEvolutionChain`
- Extracted `formatEvolutionMethod` to shared `utils/formatEvolution.ts`
## Notes
- This helps the admin quickly verify data completeness — 'is this pokemon assigned to the right routes?' and 'are its evolutions set up correctly?'

View File

@@ -8,13 +8,17 @@ from app.models.evolution import Evolution
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.models.game import Game
from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
EvolutionAdminResponse,
EvolutionResponse,
FamiliesResponse,
PaginatedPokemonResponse,
PokemonCreate,
PokemonEncounterLocationItem,
PokemonEncounterLocationResponse,
PokemonResponse,
PokemonUpdate,
RouteEncounterCreate,
@@ -174,6 +178,103 @@ async def get_pokemon_forms(
return result.scalars().all()
@router.get(
"/pokemon/{pokemon_id}/encounter-locations",
response_model=list[PokemonEncounterLocationResponse],
)
async def get_pokemon_encounter_locations(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.pokemon_id == pokemon_id)
.options(joinedload(RouteEncounter.route), joinedload(RouteEncounter.game))
.order_by(RouteEncounter.game_id, RouteEncounter.route_id)
)
encounters = result.scalars().unique().all()
grouped: dict[int, PokemonEncounterLocationResponse] = {}
for enc in encounters:
if enc.game_id not in grouped:
grouped[enc.game_id] = PokemonEncounterLocationResponse(
game_id=enc.game_id,
game_name=enc.game.name,
encounters=[],
)
grouped[enc.game_id].encounters.append(
PokemonEncounterLocationItem(
route_id=enc.route_id,
route_name=enc.route.name,
encounter_method=enc.encounter_method,
encounter_rate=enc.encounter_rate,
min_level=enc.min_level,
max_level=enc.max_level,
)
)
return list(grouped.values())
@router.get(
"/pokemon/{pokemon_id}/evolution-chain",
response_model=list[EvolutionAdminResponse],
)
async def get_pokemon_evolution_chain(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
from collections import deque
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
# Load all evolutions to build adjacency
result = await session.execute(select(Evolution))
evolutions = result.scalars().all()
adj: dict[int, set[int]] = {}
for evo in evolutions:
adj.setdefault(evo.from_pokemon_id, set()).add(evo.to_pokemon_id)
adj.setdefault(evo.to_pokemon_id, set()).add(evo.from_pokemon_id)
# BFS from pokemon_id to find family members
family: set[int] = set()
queue = deque([pokemon_id])
while queue:
current = queue.popleft()
if current in family:
continue
family.add(current)
for neighbor in adj.get(current, set()):
if neighbor not in family:
queue.append(neighbor)
# Filter evolutions to only those in the family
family_evo_ids = [
evo.id for evo in evolutions
if evo.from_pokemon_id in family and evo.to_pokemon_id in family
]
if not family_evo_ids:
return []
# Reload with eager-loaded relationships
result = await session.execute(
select(Evolution)
.where(Evolution.id.in_(family_evo_ids))
.options(
joinedload(Evolution.from_pokemon),
joinedload(Evolution.to_pokemon),
)
.order_by(Evolution.from_pokemon_id, Evolution.to_pokemon_id)
)
return result.scalars().unique().all()
@router.get("/pokemon/{pokemon_id}/evolutions", response_model=list[EvolutionResponse])
async def get_pokemon_evolutions(
pokemon_id: int,

View File

@@ -50,6 +50,21 @@ class RouteEncounterDetailResponse(RouteEncounterResponse):
pokemon: PokemonResponse
class PokemonEncounterLocationItem(CamelModel):
route_id: int
route_name: str
encounter_method: str
encounter_rate: int
min_level: int
max_level: int
class PokemonEncounterLocationResponse(CamelModel):
game_id: int
game_name: str
encounters: list[PokemonEncounterLocationItem]
# --- Admin schemas ---

View File

@@ -1,5 +1,6 @@
import { api } from './client'
import type { Pokemon } from '../types/game'
import type { EvolutionAdmin, PokemonEncounterLocation } from '../types/admin'
export function getPokemon(id: number): Promise<Pokemon> {
return api.get(`/pokemon/${id}`)
@@ -8,3 +9,11 @@ export function getPokemon(id: number): Promise<Pokemon> {
export function fetchPokemonFamilies(): Promise<{ families: number[][] }> {
return api.get('/pokemon/families')
}
export function fetchPokemonEncounterLocations(pokemonId: number): Promise<PokemonEncounterLocation[]> {
return api.get(`/pokemon/${pokemonId}/encounter-locations`)
}
export function fetchPokemonEvolutionChain(pokemonId: number): Promise<EvolutionAdmin[]> {
return api.get(`/pokemon/${pokemonId}/evolution-chain`)
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { EncounterDetail, UpdateEncounterInput } from '../types'
import { useEvolutions, useForms } from '../hooks/useEncounters'
import { TypeBadge } from './TypeBadge'
import { formatEvolutionMethod } from '../utils/formatEvolution'
interface StatusChangeModalProps {
encounter: EncounterDetail
@@ -14,28 +15,6 @@ interface StatusChangeModalProps {
region?: string
}
function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
const parts: string[] = []
if (evo.trigger === 'level-up' && evo.minLevel) {
parts.push(`Level ${evo.minLevel}`)
} else if (evo.trigger === 'level-up') {
parts.push('Level up')
} else if (evo.trigger === 'use-item' && evo.item) {
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
} else if (evo.trigger === 'trade') {
parts.push('Trade')
} else {
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
}
if (evo.heldItem) {
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
}
if (evo.condition) {
parts.push(evo.condition)
}
return parts.join(', ')
}
export function StatusChangeModal({
encounter,
onUpdate,

View File

@@ -1,6 +1,11 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
import { type FormEvent, useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { EvolutionFormModal } from './EvolutionFormModal'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types'
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
import { formatEvolutionMethod } from '../../utils/formatEvolution'
interface PokemonFormModalProps {
pokemon?: Pokemon
@@ -11,12 +16,34 @@ interface PokemonFormModalProps {
isDeleting?: boolean
}
type Tab = 'details' | 'evolutions' | 'encounters'
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) {
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
const [name, setName] = useState(pokemon?.name ?? '')
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
const [activeTab, setActiveTab] = useState<Tab>('details')
const [editingEvolution, setEditingEvolution] = useState<EvolutionAdmin | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const isEdit = !!pokemon
const pokemonId = pokemon?.id ?? null
const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId)
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
const queryClient = useQueryClient()
const updateEvolution = useUpdateEvolution()
const deleteEvolution = useDeleteEvolution()
useEffect(() => {
setConfirmingDelete(false)
}, [onDelete])
const invalidateChain = () => {
queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] })
}
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
@@ -33,68 +60,256 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
})
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'details', label: 'Details' },
{ key: 'evolutions', label: 'Evolutions' },
{ key: 'encounters', label: 'Encounters' },
]
const tabClass = (tab: Tab) =>
`px-3 py-1.5 text-sm font-medium rounded-t-md border-b-2 transition-colors ${
activeTab === tab
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`
return (
<FormModal
title={pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
>
<div>
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
<input
type="number"
required
min={1}
value={pokeapiId}
onChange={(e) => setPokeapiId(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
<>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
{isEdit && (
<div className="flex gap-1 mt-2">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={tabClass(tab.key)}
>
{tab.label}
</button>
))}
</div>
)}
</div>
{/* Details tab (form) */}
{activeTab === 'details' && (
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<div>
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
<input
type="number"
required
min={1}
value={pokeapiId}
onChange={(e) => setPokeapiId(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">National Dex #</label>
<input
type="number"
required
min={1}
value={nationalDex}
onChange={(e) => setNationalDex(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
<input
type="text"
required
value={types}
onChange={(e) => setTypes(e.target.value)}
placeholder="Fire, Flying"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Sprite URL</label>
<input
type="text"
value={spriteUrl}
onChange={(e) => setSpriteUrl(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3 shrink-0">
{onDelete && (
<button
type="button"
disabled={isDeleting}
onClick={() => {
if (confirmingDelete) {
onDelete()
} else {
setConfirmingDelete(true)
}
}}
onBlur={() => setConfirmingDelete(false)}
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
</button>
)}
<div className="flex-1" />
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
)}
{/* Evolutions tab */}
{activeTab === 'evolutions' && (
<div className="flex flex-col min-h-0 flex-1">
<div className="px-6 py-4 overflow-y-auto">
{evolutionsLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
)}
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
)}
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
<div className="space-y-1">
{evolutionChain.map((evo) => (
<button
key={evo.id}
type="button"
onClick={() => setEditingEvolution(evo)}
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
>
{evo.fromPokemon.name} {evo.toPokemon.name}{' '}
<span className="text-gray-400 dark:text-gray-500">
({formatEvolutionMethod(evo)})
</span>
</button>
))}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Close
</button>
</div>
</div>
)}
{/* Encounters tab */}
{activeTab === 'encounters' && (
<div className="flex flex-col min-h-0 flex-1">
<div className="px-6 py-4 overflow-y-auto">
{encountersLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
)}
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
)}
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
<div className="space-y-3">
{encounterLocations.map((game) => (
<div key={game.gameId}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{game.gameName}
</div>
<div className="space-y-0.5 pl-2">
{game.encounters.map((enc, i) => (
<div key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
<Link
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{enc.routeName}
</Link>
<span className="text-gray-400 dark:text-gray-500">
{enc.encounterMethod}, Lv. {enc.minLevel}{enc.maxLevel}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Close
</button>
</div>
</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">National Dex #</label>
<input
type="number"
required
min={1}
value={nationalDex}
onChange={(e) => setNationalDex(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
{editingEvolution && (
<EvolutionFormModal
evolution={editingEvolution}
onSubmit={(data) =>
updateEvolution.mutate(
{ id: editingEvolution.id, data: data as UpdateEvolutionInput },
{
onSuccess: () => {
setEditingEvolution(null)
invalidateChain()
},
},
)
}
onClose={() => setEditingEvolution(null)}
isSubmitting={updateEvolution.isPending}
onDelete={() =>
deleteEvolution.mutate(editingEvolution.id, {
onSuccess: () => {
setEditingEvolution(null)
invalidateChain()
},
})
}
isDeleting={deleteEvolution.isPending}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
<input
type="text"
required
value={types}
onChange={(e) => setTypes(e.target.value)}
placeholder="Fire, Flying"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Sprite URL</label>
<input
type="text"
value={spriteUrl}
onChange={(e) => setSpriteUrl(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</FormModal>
)}
</>
)
}

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { getPokemon, fetchPokemonFamilies } from '../api/pokemon'
import { getPokemon, fetchPokemonFamilies, fetchPokemonEncounterLocations, fetchPokemonEvolutionChain } from '../api/pokemon'
export function usePokemon(id: number | null) {
return useQuery({
@@ -16,3 +16,19 @@ export function usePokemonFamilies() {
staleTime: Infinity,
})
}
export function usePokemonEncounterLocations(pokemonId: number | null) {
return useQuery({
queryKey: ['pokemon', pokemonId, 'encounter-locations'],
queryFn: () => fetchPokemonEncounterLocations(pokemonId!),
enabled: pokemonId !== null,
})
}
export function usePokemonEvolutionChain(pokemonId: number | null) {
return useQuery({
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
queryFn: () => fetchPokemonEvolutionChain(pokemonId!),
enabled: pokemonId !== null,
})
}

View File

@@ -121,6 +121,22 @@ export interface UpdateEvolutionInput {
region?: string | null
}
// Pokemon encounter locations (detail card)
export interface PokemonEncounterLocationItem {
routeId: number
routeName: string
encounterMethod: string
encounterRate: number
minLevel: number
maxLevel: number
}
export interface PokemonEncounterLocation {
gameId: number
gameName: string
encounters: PokemonEncounterLocationItem[]
}
// Boss battles admin
export interface CreateBossBattleInput {
name: string

View File

@@ -0,0 +1,21 @@
export function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
const parts: string[] = []
if (evo.trigger === 'level-up' && evo.minLevel) {
parts.push(`Level ${evo.minLevel}`)
} else if (evo.trigger === 'level-up') {
parts.push('Level up')
} else if (evo.trigger === 'use-item' && evo.item) {
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
} else if (evo.trigger === 'trade') {
parts.push('Trade')
} else {
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
}
if (evo.heldItem) {
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
}
if (evo.condition) {
parts.push(evo.condition)
}
return parts.join(', ')
}