diff --git a/.beans/nuzlocke-tracker-532i--ux-remove-level-field-from-boss-defeat-modal.md b/.beans/nuzlocke-tracker-532i--ux-remove-level-field-from-boss-defeat-modal.md index cd52db8..97bdbd2 100644 --- a/.beans/nuzlocke-tracker-532i--ux-remove-level-field-from-boss-defeat-modal.md +++ b/.beans/nuzlocke-tracker-532i--ux-remove-level-field-from-boss-defeat-modal.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-532i title: 'UX: Make level field optional in boss defeat modal' -status: todo +status: completed type: feature priority: normal created_at: 2026-03-21T21:50:48Z -updated_at: 2026-03-21T22:04:08Z +updated_at: 2026-03-22T09:16:12Z --- ## 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 level input from `BossDefeatModal.tsx` -- [ ] Make `level` column nullable in the database (alembic migration) -- [ ] Update the API schema to make level optional (default to null) -- [ ] Update any backend validation that requires level -- [ ] Verify boss result display still works without level data +- [x] Remove level input from `BossDefeatModal.tsx` +- [x] Make `level` column nullable in the database (alembic migration) +- [x] Update the API schema to make level optional (default to null) +- [x] Update any backend validation that requires level +- [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 diff --git a/backend/src/app/alembic/versions/903e0cdbfe5a_make_boss_result_team_level_nullable.py b/backend/src/app/alembic/versions/903e0cdbfe5a_make_boss_result_team_level_nullable.py new file mode 100644 index 0000000..c31f525 --- /dev/null +++ b/backend/src/app/alembic/versions/903e0cdbfe5a_make_boss_result_team_level_nullable.py @@ -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, + ) diff --git a/backend/src/app/models/boss_result_team.py b/backend/src/app/models/boss_result_team.py index 29409e1..a94cb8b 100644 --- a/backend/src/app/models/boss_result_team.py +++ b/backend/src/app/models/boss_result_team.py @@ -14,7 +14,7 @@ class BossResultTeam(Base): encounter_id: Mapped[int] = mapped_column( 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") encounter: Mapped[Encounter] = relationship() diff --git a/backend/src/app/schemas/boss.py b/backend/src/app/schemas/boss.py index 6dc982d..5f4bb6a 100644 --- a/backend/src/app/schemas/boss.py +++ b/backend/src/app/schemas/boss.py @@ -57,7 +57,7 @@ class BossBattleResponse(CamelModel): class BossResultTeamMemberResponse(CamelModel): id: int encounter_id: int - level: int + level: int | None class BossResultResponse(CamelModel): @@ -120,7 +120,7 @@ class BossPokemonInput(CamelModel): class BossResultTeamMemberInput(CamelModel): encounter_id: int - level: int + level: int | None = None class BossResultCreate(CamelModel): diff --git a/frontend/src/components/BossDefeatModal.tsx b/frontend/src/components/BossDefeatModal.tsx index b294dbe..89dac7f 100644 --- a/frontend/src/components/BossDefeatModal.tsx +++ b/frontend/src/components/BossDefeatModal.tsx @@ -23,10 +23,7 @@ function matchVariant(labels: string[], starterName?: string | null): string | n return matches.length === 1 ? (matches[0] ?? null) : null } -interface TeamSelection { - encounterId: number - level: number -} +type TeamSelection = number export function BossDefeatModal({ boss, @@ -36,26 +33,15 @@ export function BossDefeatModal({ isPending, starterName, }: BossDefeatModalProps) { - const [selectedTeam, setSelectedTeam] = useState>(new Map()) + const [selectedTeam, setSelectedTeam] = useState>(new Set()) - const toggleTeamMember = (enc: EncounterDetail) => { + const toggleTeamMember = (encounterId: number) => { setSelectedTeam((prev) => { - const next = new Map(prev) - if (next.has(enc.id)) { - next.delete(enc.id) + const next = new Set(prev) + if (next.has(encounterId)) { + next.delete(encounterId) } else { - next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 }) - } - 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 }) + next.add(encounterId) } return next }) @@ -87,7 +73,9 @@ export function BossDefeatModal({ const handleSubmit = (e: FormEvent) => { e.preventDefault() - const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values()) + const team: BossResultTeamMemberInput[] = Array.from(selectedTeam).map((encounterId) => ({ + encounterId, + })) onSubmit({ bossBattleId: boss.id, result: 'won', @@ -134,11 +122,17 @@ export function BossDefeatModal({ return (
{bp.pokemon.spriteUrl ? ( - {bp.pokemon.name} + {bp.pokemon.name} ) : (
)} - {bp.pokemon.name} + + {bp.pokemon.name} + Lv.{bp.level} {bp.ability && ( @@ -166,7 +160,6 @@ export function BossDefeatModal({
{aliveEncounters.map((enc) => { const isSelected = selectedTeam.has(enc.id) - const selection = selectedTeam.get(enc.id) const displayPokemon = enc.currentPokemon ?? enc.pokemon return (
toggleTeamMember(enc)} + onClick={() => toggleTeamMember(enc.id)} > toggleTeamMember(enc)} + onChange={() => toggleTeamMember(enc.id)} className="sr-only" /> {displayPokemon.spriteUrl ? ( @@ -193,26 +186,9 @@ export function BossDefeatModal({ ) : (
)} -
-

- {enc.nickname ?? displayPokemon.name} -

- {isSelected && ( - { - 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" - /> - )} -
+

+ {enc.nickname ?? displayPokemon.name} +

) })} diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 762fba5..5adc5fe 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -1246,253 +1246,279 @@ export function RunEncounters() { {/* Encounters Tab */} {activeTab === 'encounters' && ( <> -
- {/* Main content column */} -
- {/* Team Section - Mobile/Tablet only */} - {(alive.length > 0 || dead.length > 0) && ( -
-
- - {showTeam && alive.length > 1 && ( - - )} -
- {showTeam && ( - <> - {alive.length > 0 && ( -
- {alive.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined - } +
+ {/* Main content column */} +
+ {/* Team Section - Mobile/Tablet only */} + {(alive.length > 0 || dead.length > 0) && ( +
+
+
- )} - {dead.length > 0 && ( + + + {showTeam && alive.length > 1 && ( + + )} +
+ {showTeam && ( <> -

Graveyard

-
- {dead.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined - } - /> - ))} -
+ {alive.length > 0 && ( +
+ {alive.map((enc) => ( + setSelectedTeamEncounter(enc) + : undefined + } + /> + ))} +
+ )} + {dead.length > 0 && ( + <> +

Graveyard

+
+ {dead.map((enc) => ( + setSelectedTeamEncounter(enc) + : undefined + } + /> + ))} +
+ + )} )} - +
)} -
- )} - {/* Shiny Box */} - {run.rules?.shinyClause && shinyEncounters.length > 0 && ( -
- setSelectedTeamEncounter(enc) : undefined - } - /> -
- )} - - {/* Transfer Encounters */} - {transferEncounters.length > 0 && ( -
-

Transferred Pokemon

-
- {transferEncounters.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined} + {/* Shiny Box */} + {run.rules?.shinyClause && shinyEncounters.length > 0 && ( +
+ setSelectedTeamEncounter(enc) : undefined + } /> +
+ )} + + {/* Transfer Encounters */} + {transferEncounters.length > 0 && ( +
+

Transferred Pokemon

+
+ {transferEncounters.map((enc) => ( + setSelectedTeamEncounter(enc) : undefined + } + /> + ))} +
+
+ )} + + {/* Progress bar */} +
+
+
+

Encounters

+ {isActive && canEdit && completedCount < totalLocations && ( + + )} +
+ + {completedCount} / {totalLocations} locations + +
+
+
0 ? (completedCount / totalLocations) * 100 : 0}%`, + }} + /> +
+
+ + {/* Filter tabs */} +
+ {( + [ + { 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 }) => ( + ))}
-
- )} - {/* Progress bar */} -
-
-
-

Encounters

- {isActive && canEdit && completedCount < totalLocations && ( - + {/* Route list */} +
+ {filteredRoutes.length === 0 && ( +

+ {filter === 'all' + ? 'Click a route to log your first encounter' + : 'No routes match this filter — try a different one'} +

)} -
- - {completedCount} / {totalLocations} locations - -
-
-
0 ? (completedCount / totalLocations) * 100 : 0}%`, - }} - /> -
-
+ {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] - {/* Filter tabs */} -
- {( - [ - { 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 }) => ( - - ))} -
+ // 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) + } - {/* Route list */} -
- {filteredRoutes.length === 0 && ( -

- {filter === 'all' - ? 'Click a route to log your first encounter' - : 'No routes match this filter — try a different one'} -

- )} - {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 ? ( - 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 ( - - ) - })() - ) - - return ( -
- {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 = { - 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 = { - 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 ( -
-
-
-
- - - - {boss.spriteUrl && ( - {boss.name} - )} -
-
- - {boss.name} - - - {bossTypeLabel[boss.bossType] ?? boss.bossType} - - {boss.specialtyType && }
-

- {boss.location} · Level Cap: {boss.levelCap} -

-
+ ) : ( + route.encounterMethods.length > 0 && ( +
+ {route.encounterMethods.map((m) => ( + + ))} +
+ ) + )}
-
e.stopPropagation()}> - {isDefeated ? ( - - Defeated ✓ - - ) : isActive && canEdit ? ( - - ) : null} -
-
- {/* Boss pokemon team */} - {isBossExpanded && boss.pokemon.length > 0 && ( - - )} - {/* Player team snapshot */} - {isDefeated && - (() => { - const result = bossResultByBattleId.get(boss.id) - if (!result || result.team.length === 0) return null - return ( -
-

- Your Team -

-
- {result.team.map((tm: BossResultTeamMember) => { - const enc = encounterById.get(tm.encounterId) - if (!enc) return null - const dp = enc.currentPokemon ?? enc.pokemon - return ( -
- {dp.spriteUrl ? ( - {dp.name} - ) : ( -
- )} - - {enc.nickname ?? dp.name} - - - Lv.{tm.level} - -
- ) - })} + {si.label} + + ) + })() + ) + + return ( +
+ {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 = { + 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 = { + 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 ( +
+
+
+
+ + + + {boss.spriteUrl && ( + {boss.name} + )} +
+
+ + {boss.name} + + + {bossTypeLabel[boss.bossType] ?? boss.bossType} + + {boss.specialtyType && ( + + )} +
+

+ {boss.location} · Level Cap: {boss.levelCap} +

- ) - })()} -
- {sectionAfter && ( -
-
- - {sectionAfter} - -
+
e.stopPropagation()}> + {isDefeated ? ( + + Defeated ✓ + + ) : isActive && canEdit ? ( + + ) : null} +
+
+ {/* Boss pokemon team */} + {isBossExpanded && boss.pokemon.length > 0 && ( + + )} + {/* Player team snapshot */} + {isDefeated && + (() => { + const result = bossResultByBattleId.get(boss.id) + if (!result || result.team.length === 0) return null + return ( +
+

+ Your Team +

+
+ {result.team.map((tm: BossResultTeamMember) => { + const enc = encounterById.get(tm.encounterId) + if (!enc) return null + const dp = enc.currentPokemon ?? enc.pokemon + return ( +
+ {dp.spriteUrl ? ( + {dp.name} + ) : ( +
+ )} + + {enc.nickname ?? dp.name} + + {tm.level != null && ( + + Lv.{tm.level} + + )} +
+ ) + })} +
+
+ ) + })()} +
+ {sectionAfter && ( +
+
+ + {sectionAfter} + +
+
+ )}
- )} -
- ) - })} -
- ) - })} -
-
- - {/* Team Sidebar - Desktop only */} - {(alive.length > 0 || dead.length > 0) && ( -
-
-
-
-

- {isActive ? 'Team' : 'Final Team'} -

- - {alive.length}/{alive.length + dead.length} - -
- {alive.length > 1 && ( - - )} - {alive.length > 0 && ( -
- {alive.map((enc) => ( - setSelectedTeamEncounter(enc) : undefined - } - /> - ))} + ) + })}
- )} - {dead.length > 0 && ( - <> -

Graveyard

-
- {dead.map((enc) => ( + ) + })} +
+
+ + {/* Team Sidebar - Desktop only */} + {(alive.length > 0 || dead.length > 0) && ( +
+
+
+
+

+ {isActive ? 'Team' : 'Final Team'} +

+ + {alive.length}/{alive.length + dead.length} + +
+ {alive.length > 1 && ( + + )} + {alive.length > 0 && ( +
+ {alive.map((enc) => ( setSelectedTeamEncounter(enc) : undefined } /> ))}
- - )} + )} + {dead.length > 0 && ( + <> +

Graveyard

+
+ {dead.map((enc) => ( + setSelectedTeamEncounter(enc) + : undefined + } + /> + ))} +
+ + )} +
-
- )} -
+ )} +
{/* Encounter Modal */} {selectedRoute && ( diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 32a0a60..98100c0 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -238,7 +238,7 @@ export interface BossBattle { export interface BossResultTeamMember { id: number encounterId: number - level: number + level: number | null } export interface BossResult { @@ -253,7 +253,7 @@ export interface BossResult { export interface BossResultTeamMemberInput { encounterId: number - level: number + level?: number | null } export interface CreateBossResultInput {