From 4d6e1dc5b2b7990dddd66575cdb51b40412a8c61 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 22 Mar 2026 10:16:08 +0100 Subject: [PATCH] feat: make level field optional in boss defeat modal 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 --- ...move-level-field-from-boss-defeat-modal.md | 23 +- ...5a_make_boss_result_team_level_nullable.py | 37 + backend/src/app/models/boss_result_team.py | 2 +- backend/src/app/schemas/boss.py | 4 +- frontend/src/components/BossDefeatModal.tsx | 70 +- frontend/src/pages/RunEncounters.tsx | 954 +++++++++--------- frontend/src/types/game.ts | 4 +- 7 files changed, 568 insertions(+), 526 deletions(-) create mode 100644 backend/src/app/alembic/versions/903e0cdbfe5a_make_boss_result_team_level_nullable.py 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 {