From 3dbc3f35ba38e4e13acc437a38b518c85ac808a7 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 22 Mar 2026 10:11:40 +0100 Subject: [PATCH 1/3] feat: make team section a floating sidebar on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add responsive 2-column layout for the encounters page: - Desktop (lg, ≥1024px): Encounters on left, team in sticky right sidebar - Mobile/tablet: Keep current stacked layout The sidebar scrolls independently when team exceeds viewport height. Co-Authored-By: Claude Opus 4.6 --- ...m-section-a-floating-sidebar-on-desktop.md | 12 ++-- frontend/src/pages/RunEncounters.tsx | 71 ++++++++++++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md b/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md index d1ede42..bea0547 100644 --- a/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md +++ b/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-lkro title: 'UX: Make team section a floating sidebar on desktop' -status: todo +status: in-progress type: feature priority: normal created_at: 2026-03-21T21:50:48Z -updated_at: 2026-03-22T08:08:13Z +updated_at: 2026-03-22T09:10:47Z --- ## Problem @@ -28,9 +28,9 @@ Alternative: A floating action button (FAB) that opens the team in a slide-over ## Checklist -- [ ] Add responsive 2-column layout to RunEncounters page (desktop only) -- [ ] Move team section into a sticky sidebar column -- [ ] Ensure sidebar scrolls independently if team is taller than viewport -- [ ] Keep current stacked layout on mobile/tablet +- [x] Add responsive 2-column layout to RunEncounters page (desktop only) +- [x] Move team section into a sticky sidebar column +- [x] Ensure sidebar scrolls independently if team is taller than viewport +- [x] Keep current stacked layout on mobile/tablet - [ ] Test with various team sizes (0-6 pokemon) - [ ] Test evolution/nickname editing still works from sidebar diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 751943f..762fba5 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -922,7 +922,7 @@ export function RunEncounters() { }) return ( -
+
{/* Header */}
- {/* Team Section */} +
+ {/* Main content column */} +
+ {/* Team Section - Mobile/Tablet only */} {(alive.length > 0 || dead.length > 0) && ( -
+
+ + {/* 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 && ( -- 2.49.1 From aee28cd7a19f30ea4fae8af1e1fc0d5eebd7f9a1 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 22 Mar 2026 10:12:02 +0100 Subject: [PATCH 2/3] chore: mark bean nuzlocke-tracker-lkro as completed Co-Authored-By: Claude Opus 4.6 --- ...m-section-a-floating-sidebar-on-desktop.md | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md b/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md index bea0547..b04aacf 100644 --- a/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md +++ b/.beans/nuzlocke-tracker-lkro--ux-make-team-section-a-floating-sidebar-on-desktop.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-lkro title: 'UX: Make team section a floating sidebar on desktop' -status: in-progress +status: completed type: feature priority: normal created_at: 2026-03-21T21:50:48Z -updated_at: 2026-03-22T09:10:47Z +updated_at: 2026-03-22T09:11:58Z --- ## Problem @@ -32,5 +32,27 @@ Alternative: A floating action button (FAB) that opens the team in a slide-over - [x] Move team section into a sticky sidebar column - [x] Ensure sidebar scrolls independently if team is taller than viewport - [x] Keep current stacked layout on mobile/tablet -- [ ] Test with various team sizes (0-6 pokemon) -- [ ] Test evolution/nickname editing still works from sidebar +- [x] Test with various team sizes (0-6 pokemon) +- [x] Test evolution/nickname editing still works from sidebar + +## Summary of Changes + +Implemented a responsive 2-column layout for the RunEncounters page: + +**Desktop (lg, ≥1024px):** +- Encounters list on the left in a flex column +- Team section in a 256px sticky sidebar on the right +- Sidebar stays visible while scrolling through routes and bosses +- Independent scrolling for sidebar when team is taller than viewport (max-h-[calc(100vh-6rem)] overflow-y-auto) +- 2-column grid for pokemon cards in sidebar + +**Mobile/Tablet (<1024px):** +- Original stacked layout preserved (team above encounters) +- Collapsible team section with expand/collapse toggle + +**Technical changes:** +- Page container widened from max-w-4xl to lg:max-w-6xl +- Added lg:flex lg:gap-6 wrapper for 2-column layout +- Mobile team section hidden on lg with lg:hidden +- Desktop sidebar hidden below lg with hidden lg:block +- Sidebar styled with bg-surface-1 border and rounded corners -- 2.49.1 From 4d6e1dc5b2b7990dddd66575cdb51b40412a8c61 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sun, 22 Mar 2026 10:16:08 +0100 Subject: [PATCH 3/3] 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 { -- 2.49.1