feat: make level field optional in boss defeat modal
All checks were successful
CI / backend-tests (pull_request) Successful in 29s
CI / frontend-tests (pull_request) Successful in 39s

Remove the level input from the boss defeat modal since the app doesn't
track levels elsewhere. Team selection is now just checkboxes without
requiring level entry.

- Remove level input UI from BossDefeatModal.tsx
- Add alembic migration to make boss_result_team.level nullable
- Update model and schemas to make level optional (defaults to null)
- Conditionally render level in boss result display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 10:16:08 +01:00
parent aee28cd7a1
commit 4d6e1dc5b2
7 changed files with 568 additions and 526 deletions

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-532i # nuzlocke-tracker-532i
title: 'UX: Make level field optional in boss defeat modal' title: 'UX: Make level field optional in boss defeat modal'
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-21T21:50:48Z created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-21T22:04:08Z updated_at: 2026-03-22T09:16:12Z
--- ---
## Problem ## Problem
@@ -22,8 +22,17 @@ When recording which team members beat a boss, users must manually enter a level
Remove the level field entirely from the UI and make it optional in the backend: Remove the level field entirely from the UI and make it optional in the backend:
- [ ] Remove level input from `BossDefeatModal.tsx` - [x] Remove level input from `BossDefeatModal.tsx`
- [ ] Make `level` column nullable in the database (alembic migration) - [x] Make `level` column nullable in the database (alembic migration)
- [ ] Update the API schema to make level optional (default to null) - [x] Update the API schema to make level optional (default to null)
- [ ] Update any backend validation that requires level - [x] Update any backend validation that requires level
- [ ] Verify boss result display still works without level data - [x] Verify boss result display still works without level data
## Summary of Changes
- Removed level input field from BossDefeatModal.tsx, simplifying team selection to just checkboxes
- Created alembic migration to make boss_result_team.level column nullable
- Updated SQLAlchemy model and Pydantic schemas to make level optional (defaults to null)
- Updated RunEncounters.tsx to conditionally render level only when present
- Updated frontend TypeScript types for BossResultTeamMember and BossResultTeamMemberInput

View File

@@ -0,0 +1,37 @@
"""make_boss_result_team_level_nullable
Revision ID: 903e0cdbfe5a
Revises: p7e8f9a0b1c2
Create Date: 2026-03-22 10:13:41.828406
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "903e0cdbfe5a"
down_revision: str | Sequence[str] | None = "p7e8f9a0b1c2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"boss_result_team",
"level",
existing_type=sa.SmallInteger(),
nullable=True,
)
def downgrade() -> None:
op.execute("UPDATE boss_result_team SET level = 1 WHERE level IS NULL")
op.alter_column(
"boss_result_team",
"level",
existing_type=sa.SmallInteger(),
nullable=False,
)

View File

@@ -14,7 +14,7 @@ class BossResultTeam(Base):
encounter_id: Mapped[int] = mapped_column( encounter_id: Mapped[int] = mapped_column(
ForeignKey("encounters.id", ondelete="CASCADE"), index=True ForeignKey("encounters.id", ondelete="CASCADE"), index=True
) )
level: Mapped[int] = mapped_column(SmallInteger) level: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
boss_result: Mapped[BossResult] = relationship(back_populates="team") boss_result: Mapped[BossResult] = relationship(back_populates="team")
encounter: Mapped[Encounter] = relationship() encounter: Mapped[Encounter] = relationship()

View File

@@ -57,7 +57,7 @@ class BossBattleResponse(CamelModel):
class BossResultTeamMemberResponse(CamelModel): class BossResultTeamMemberResponse(CamelModel):
id: int id: int
encounter_id: int encounter_id: int
level: int level: int | None
class BossResultResponse(CamelModel): class BossResultResponse(CamelModel):
@@ -120,7 +120,7 @@ class BossPokemonInput(CamelModel):
class BossResultTeamMemberInput(CamelModel): class BossResultTeamMemberInput(CamelModel):
encounter_id: int encounter_id: int
level: int level: int | None = None
class BossResultCreate(CamelModel): class BossResultCreate(CamelModel):

View File

@@ -23,10 +23,7 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null return matches.length === 1 ? (matches[0] ?? null) : null
} }
interface TeamSelection { type TeamSelection = number
encounterId: number
level: number
}
export function BossDefeatModal({ export function BossDefeatModal({
boss, boss,
@@ -36,26 +33,15 @@ export function BossDefeatModal({
isPending, isPending,
starterName, starterName,
}: BossDefeatModalProps) { }: BossDefeatModalProps) {
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map()) const [selectedTeam, setSelectedTeam] = useState<Set<TeamSelection>>(new Set())
const toggleTeamMember = (enc: EncounterDetail) => { const toggleTeamMember = (encounterId: number) => {
setSelectedTeam((prev) => { setSelectedTeam((prev) => {
const next = new Map(prev) const next = new Set(prev)
if (next.has(enc.id)) { if (next.has(encounterId)) {
next.delete(enc.id) next.delete(encounterId)
} else { } else {
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 }) next.add(encounterId)
}
return next
})
}
const updateLevel = (encounterId: number, level: number) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
const existing = next.get(encounterId)
if (existing) {
next.set(encounterId, { ...existing, level })
} }
return next return next
}) })
@@ -87,7 +73,9 @@ export function BossDefeatModal({
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values()) const team: BossResultTeamMemberInput[] = Array.from(selectedTeam).map((encounterId) => ({
encounterId,
}))
onSubmit({ onSubmit({
bossBattleId: boss.id, bossBattleId: boss.id,
result: 'won', result: 'won',
@@ -134,11 +122,17 @@ export function BossDefeatModal({
return ( return (
<div key={bp.id} className="flex flex-col items-center"> <div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? ( {bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" /> <img
src={bp.pokemon.spriteUrl}
alt={bp.pokemon.name}
className="w-10 h-10"
/>
) : ( ) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" /> <div className="w-10 h-10 bg-surface-3 rounded-full" />
)} )}
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span> <span className="text-xs text-text-tertiary capitalize">
{bp.pokemon.name}
</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span> <span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" /> <ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && ( {bp.ability && (
@@ -166,7 +160,6 @@ export function BossDefeatModal({
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{aliveEncounters.map((enc) => { {aliveEncounters.map((enc) => {
const isSelected = selectedTeam.has(enc.id) const isSelected = selectedTeam.has(enc.id)
const selection = selectedTeam.get(enc.id)
const displayPokemon = enc.currentPokemon ?? enc.pokemon const displayPokemon = enc.currentPokemon ?? enc.pokemon
return ( return (
<div <div
@@ -176,12 +169,12 @@ export function BossDefeatModal({
? 'border-accent-500 bg-accent-500/10' ? 'border-accent-500 bg-accent-500/10'
: 'border-border-default hover:bg-surface-2' : 'border-border-default hover:bg-surface-2'
}`} }`}
onClick={() => toggleTeamMember(enc)} onClick={() => toggleTeamMember(enc.id)}
> >
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => toggleTeamMember(enc)} onChange={() => toggleTeamMember(enc.id)}
className="sr-only" className="sr-only"
/> />
{displayPokemon.spriteUrl ? ( {displayPokemon.spriteUrl ? (
@@ -193,26 +186,9 @@ export function BossDefeatModal({
) : ( ) : (
<div className="w-8 h-8 bg-surface-3 rounded-full" /> <div className="w-8 h-8 bg-surface-3 rounded-full" />
)} )}
<div className="flex-1 min-w-0"> <p className="flex-1 min-w-0 text-xs font-medium truncate">
<p className="text-xs font-medium truncate"> {enc.nickname ?? displayPokemon.name}
{enc.nickname ?? displayPokemon.name} </p>
</p>
{isSelected && (
<input
type="number"
min={1}
max={100}
value={selection?.level ?? enc.catchLevel ?? 1}
onChange={(e) => {
e.stopPropagation()
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
}}
onClick={(e) => e.stopPropagation()}
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
placeholder="Lv"
/>
)}
</div>
</div> </div>
) )
})} })}

View File

@@ -1246,253 +1246,279 @@ export function RunEncounters() {
{/* Encounters Tab */} {/* Encounters Tab */}
{activeTab === 'encounters' && ( {activeTab === 'encounters' && (
<> <>
<div className="lg:flex lg:gap-6"> <div className="lg:flex lg:gap-6">
{/* Main content column */} {/* Main content column */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Team Section - Mobile/Tablet only */} {/* Team Section - Mobile/Tablet only */}
{(alive.length > 0 || dead.length > 0) && ( {(alive.length > 0 || dead.length > 0) && (
<div className="mb-6 lg:hidden"> <div className="mb-6 lg:hidden">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<button <button
type="button" type="button"
onClick={() => setShowTeam(!showTeam)} onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group" className="flex items-center gap-2 group"
> >
<h2 className="text-lg font-semibold text-text-primary"> <h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'} {isActive ? 'Team' : 'Final Team'}
</h2> </h2>
<span className="text-xs text-text-muted"> <span className="text-xs text-text-muted">
{alive.length} alive {alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''} {dead.length > 0 ? `, ${dead.length} dead` : ''}
</span> </span>
<svg <svg
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`} className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M19 9l-7 7-7-7" d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<>
{alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/> />
))} </svg>
</div> </button>
)} {showTeam && alive.length > 1 && (
{dead.length > 0 && ( <select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<> <>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3> {alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2"> <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{dead.map((enc) => ( {alive.map((enc) => (
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel onClick={
onClick={ isActive && canEdit
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined ? () => setSelectedTeamEncounter(enc)
} : undefined
/> }
))} />
</div> ))}
</div>
)}
{dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={
isActive && canEdit
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
</>
)}
</> </>
)} )}
</> </div>
)} )}
</div>
)}
{/* Shiny Box */} {/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && ( {run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<ShinyBox <ShinyBox
encounters={shinyEncounters} encounters={shinyEncounters}
onEncounterClick={ onEncounterClick={
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
} }
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
/> />
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && canEdit && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ key: 'all', label: 'All' },
{ key: 'none', label: 'Unvisited' },
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))} ))}
</div> </div>
</div>
)}
{/* Progress bar */} {/* Route list */}
<div className="mb-4"> <div className="space-y-1">
<div className="flex items-center justify-between mb-1"> {filteredRoutes.length === 0 && (
<div className="flex items-center gap-3"> <p className="text-text-tertiary text-sm py-4 text-center">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2> {filter === 'all'
{isActive && canEdit && completedCount < totalLocations && ( ? 'Click a route to log your first encounter'
<button : 'No routes match this filter — try a different one'}
type="button" </p>
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)} )}
</div> {filteredRoutes.map((route) => {
<span className="text-sm text-text-tertiary"> // Collect all route IDs to check for boss cards after
{completedCount} / {totalLocations} locations const routeIds: number[] =
</span> route.children.length > 0
</div> ? [route.id, ...route.children.map((c) => c.id)]
<div className="h-2 bg-surface-3 rounded-full overflow-hidden"> : [route.id]
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */} // Find boss battles positioned after this route (or any of its children)
<div className="flex gap-2 mb-4 flex-wrap"> const bossesHere: BossBattle[] = []
{( for (const rid of routeIds) {
[ const b = bossesAfterRoute.get(rid)
{ key: 'all', label: 'All' }, if (b) bossesHere.push(...b)
{ key: 'none', label: 'Unvisited' }, }
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
{/* Route list */} const routeElement =
<div className="space-y-1"> route.children.length > 0 ? (
{filteredRoutes.length === 0 && ( <RouteGroup
<p className="text-text-tertiary text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0
? [route.id, ...route.children.map((c) => c.id)]
: [route.id]
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
for (const rid of routeIds) {
const b = bossesAfterRoute.get(rid)
if (b) bossesHere.push(...b)
}
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
group={route}
encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={canEdit ? handleRouteClick : undefined}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs]
return (
<button
key={route.id} key={route.id}
type="button" group={route}
onClick={canEdit ? () => handleRouteClick(route) : undefined} encounterByRoute={encounterByRoute}
disabled={!canEdit} giftEncounterByRoute={giftEncounterByRoute}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`} isExpanded={expandedGroups.has(route.id)}
> onToggleExpand={() => toggleGroup(route.id)}
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} /> onRouteClick={canEdit ? handleRouteClick : undefined}
<div className="flex-1 min-w-0"> filter={filter}
<div className="text-sm font-medium text-text-primary">{route.name}</div> pinwheelClause={pinwheelClause}
{encounter ? ( />
<div className="flex items-center gap-2 mt-0.5"> ) : (
{encounter.pokemon.spriteUrl && ( (() => {
<img const encounter = encounterByRoute.get(route.id)
src={encounter.pokemon.spriteUrl} const giftEncounter = giftEncounterByRoute.get(route.id)
alt={encounter.pokemon.name} const displayEncounter = encounter ?? giftEncounter
className="w-10 h-10" const rs = getRouteStatus(displayEncounter)
/> const si = statusIndicator[rs]
)}
<span className="text-xs text-text-tertiary capitalize"> return (
{encounter.nickname ?? encounter.pokemon.name} <button
{encounter.status === 'caught' && key={route.id}
encounter.faintLevel !== null && type="button"
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')} onClick={canEdit ? () => handleRouteClick(route) : undefined}
</span> disabled={!canEdit}
{giftEncounter && ( className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`}
<> >
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">
{route.name}
</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
</span>
{giftEncounter && (
<>
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && ( {giftEncounter.pokemon.spriteUrl && (
<img <img
src={giftEncounter.pokemon.spriteUrl} src={giftEncounter.pokemon.spriteUrl}
@@ -1504,257 +1530,251 @@ export function RunEncounters() {
{giftEncounter.nickname ?? giftEncounter.pokemon.name} {giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span> <span className="text-text-muted ml-1">(gift)</span>
</span> </span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div>
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
</button>
)
})()
)
return (
<div key={route.id}>
{routeElement}
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: 'border-gray-500',
}
const isBossExpanded = expandedBosses.has(boss.id)
const toggleBoss = () => {
setExpandedBosses((prev) => {
const next = new Set(prev)
if (next.has(boss.id)) next.delete(boss.id)
else next.add(boss.id)
return next
})
}
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div> </div>
<p className="text-xs text-text-tertiary"> ) : (
{boss.location} &middot; Level Cap: {boss.levelCap} route.encounterMethods.length > 0 && (
</p> <div className="flex flex-wrap gap-1 mt-0.5">
</div> {route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <span className="text-xs text-text-muted shrink-0">{si.label}</span>
{isDefeated ? ( </button>
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800"> )
Defeated &#10003; })()
</span> )
) : isActive && canEdit ? (
<button return (
onClick={() => setSelectedBoss(boss)} <div key={route.id}>
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors" {routeElement}
> {/* Boss battle cards after this route */}
Battle {bossesHere.map((boss) => {
</button> const isDefeated = defeatedBossIds.has(boss.id)
) : null} const sectionAfter = sectionDividerAfterBoss.get(boss.id)
</div> const bossTypeLabel: Record<string, string> = {
</div> gym_leader: 'Gym Leader',
{/* Boss pokemon team */} elite_four: 'Elite Four',
{isBossExpanded && boss.pokemon.length > 0 && ( champion: 'Champion',
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} /> rival: 'Rival',
)} evil_team: 'Evil Team',
{/* Player team snapshot */} kahuna: 'Kahuna',
{isDefeated && totem: 'Totem',
(() => { other: 'Boss',
const result = bossResultByBattleId.get(boss.id) }
if (!result || result.team.length === 0) return null const bossTypeColors: Record<string, string> = {
return ( gym_leader: 'border-yellow-600',
<div className="mt-3 pt-3 border-t border-border-default"> elite_four: 'border-purple-600',
<p className="text-xs font-medium text-text-secondary mb-2"> champion: 'border-red-600',
Your Team rival: 'border-blue-600',
</p> evil_team: 'border-gray-400',
<div className="flex gap-2 flex-wrap"> kahuna: 'border-orange-600',
{result.team.map((tm: BossResultTeamMember) => { totem: 'border-teal-600',
const enc = encounterById.get(tm.encounterId) other: 'border-gray-500',
if (!enc) return null }
const dp = enc.currentPokemon ?? enc.pokemon
return ( const isBossExpanded = expandedBosses.has(boss.id)
<div key={tm.id} className="flex flex-col items-center"> const toggleBoss = () => {
{dp.spriteUrl ? ( setExpandedBosses((prev) => {
<img const next = new Set(prev)
src={dp.spriteUrl} if (next.has(boss.id)) next.delete(boss.id)
alt={dp.name} else next.add(boss.id)
className="w-10 h-10" return next
/> })
) : ( }
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)} return (
<span className="text-[10px] text-text-tertiary capitalize"> <div key={`boss-${boss.id}`}>
{enc.nickname ?? dp.name} <div
</span> className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
<span className="text-[10px] text-text-muted"> isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
Lv.{tm.level} } px-4 py-3`}
</span> >
</div> <div
) className="flex items-start justify-between cursor-pointer select-none"
})} onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
{boss.spriteUrl && (
<img
src={boss.spriteUrl}
alt={boss.name}
className="h-10 w-auto"
/>
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div> </div>
</div> </div>
) <div onClick={(e) => e.stopPropagation()}>
})()} {isDefeated ? (
</div> <span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
{sectionAfter && ( Defeated &#10003;
<div className="flex items-center gap-3 my-4"> </span>
<div className="flex-1 h-px bg-surface-3" /> ) : isActive && canEdit ? (
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider"> <button
{sectionAfter} onClick={() => setSelectedBoss(boss)}
</span> className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
<div className="flex-1 h-px bg-surface-3" /> >
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Player team snapshot */}
{isDefeated &&
(() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">
Your Team
</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm: BossResultTeamMember) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img
src={dp.spriteUrl}
alt={dp.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name}
</span>
{tm.level != null && (
<span className="text-[10px] text-text-muted">
Lv.{tm.level}
</span>
)}
</div>
)
})}
</div>
</div>
)
})()}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div> </div>
)} )
</div> })}
)
})}
</div>
)
})}
</div>
</div>
{/* Team Sidebar - Desktop only */}
{(alive.length > 0 || dead.length > 0) && (
<div className="hidden lg:block w-64 shrink-0">
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
<div className="bg-surface-1 border border-border-default rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length}/{alive.length + dead.length}
</span>
</div>
{alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="w-full text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-0 text-text-primary mb-3"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
{alive.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div> </div>
)} )
{dead.length > 0 && ( })}
<> </div>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3> </div>
<div className="grid grid-cols-2 gap-2">
{dead.map((enc) => ( {/* Team Sidebar - Desktop only */}
{(alive.length > 0 || dead.length > 0) && (
<div className="hidden lg:block w-64 shrink-0">
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
<div className="bg-surface-1 border border-border-default rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length}/{alive.length + dead.length}
</span>
</div>
{alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="w-full text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-0 text-text-primary mb-3"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
{alive.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel
onClick={ onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
} }
/> />
))} ))}
</div> </div>
</> )}
)} {dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-2 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={
isActive && canEdit
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
</>
)}
</div>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div>
{/* Encounter Modal */} {/* Encounter Modal */}
{selectedRoute && ( {selectedRoute && (

View File

@@ -238,7 +238,7 @@ export interface BossBattle {
export interface BossResultTeamMember { export interface BossResultTeamMember {
id: number id: number
encounterId: number encounterId: number
level: number level: number | null
} }
export interface BossResult { export interface BossResult {
@@ -253,7 +253,7 @@ export interface BossResult {
export interface BossResultTeamMemberInput { export interface BossResultTeamMemberInput {
encounterId: number encounterId: number
level: number level?: number | null
} }
export interface CreateBossResultInput { export interface CreateBossResultInput {