Add section field to boss battles for run progression dividers

Adds a nullable `section` column to boss battles (e.g. "Main Story",
"Endgame") with dividers rendered in the run view between sections.
Admin UI gets a Section column in the table and a text input in the form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 14:55:26 +01:00
parent a01d01c565
commit a4f814e66e
9 changed files with 199 additions and 24 deletions

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-704x
title: Run progression dividers (main story / endgame)
status: completed
type: feature
priority: normal
created_at: 2026-02-08T13:46:12Z
updated_at: 2026-02-08T13:48:28Z
---
Add section field to boss battles to enable visual dividers between game progression phases (Main Story, Endgame, etc.) in both admin and run views.

View File

@@ -0,0 +1,26 @@
"""add section to boss battles
Revision ID: f5a6b7c8d9e0
Revises: e4f5a6b7c8d9
Create Date: 2026-02-08 20:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'f5a6b7c8d9e0'
down_revision: Union[str, Sequence[str], None] = 'e4f5a6b7c8d9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('boss_battles', sa.Column('section', sa.String(100), nullable=True))
def downgrade() -> None:
op.drop_column('boss_battles', 'section')

View File

@@ -24,6 +24,7 @@ class BossBattle(Base):
ForeignKey("routes.id"), index=True, default=None ForeignKey("routes.id"), index=True, default=None
) )
location: Mapped[str] = mapped_column(String(200)) location: Mapped[str] = mapped_column(String(200))
section: Mapped[str | None] = mapped_column(String(100), default=None)
sprite_url: Mapped[str | None] = mapped_column(String(500)) sprite_url: Mapped[str | None] = mapped_column(String(500))
version_group: Mapped["VersionGroup"] = relationship( version_group: Mapped["VersionGroup"] = relationship(

View File

@@ -23,6 +23,7 @@ class BossBattleResponse(CamelModel):
order: int order: int
after_route_id: int | None after_route_id: int | None
location: str location: str
section: str | None
sprite_url: str | None sprite_url: str | None
pokemon: list[BossPokemonResponse] = [] pokemon: list[BossPokemonResponse] = []
@@ -48,6 +49,7 @@ class BossBattleCreate(CamelModel):
order: int order: int
after_route_id: int | None = None after_route_id: int | None = None
location: str location: str
section: str | None = None
sprite_url: str | None = None sprite_url: str | None = None
@@ -60,6 +62,7 @@ class BossBattleUpdate(CamelModel):
order: int | None = None order: int | None = None
after_route_id: int | None = None after_route_id: int | None = None
location: str | None = None location: str | None = None
section: str | None = None
sprite_url: str | None = None sprite_url: str | None = None
@@ -75,6 +78,15 @@ class BossResultCreate(CamelModel):
attempts: int = 1 attempts: int = 1
class BossReorderItem(CamelModel):
id: int
order: int
class BossReorderRequest(CamelModel):
bosses: list[BossReorderItem]
class BossResultUpdate(CamelModel): class BossResultUpdate(CamelModel):
result: str | None = None result: str | None = None
attempts: int | None = None attempts: int | None = None

View File

@@ -43,6 +43,7 @@ export function BossBattleFormModal({
const [order, setOrder] = useState(String(boss?.order ?? nextOrder)) const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? '')) const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
const [location, setLocation] = useState(boss?.location ?? '') const [location, setLocation] = useState(boss?.location ?? '')
const [section, setSection] = useState(boss?.section ?? '')
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '') const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
@@ -56,6 +57,7 @@ export function BossBattleFormModal({
order: Number(order), order: Number(order),
afterRouteId: afterRouteId ? Number(afterRouteId) : null, afterRouteId: afterRouteId ? Number(afterRouteId) : null,
location, location,
section: section || null,
spriteUrl: spriteUrl || null, spriteUrl: spriteUrl || null,
}) })
} }
@@ -146,6 +148,17 @@ export function BossBattleFormModal({
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">Section</label>
<input
type="text"
value={section}
onChange={(e) => setSection(e.target.value)}
placeholder="e.g. Main Story, Endgame"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">Position After Route</label> <label className="block text-sm font-medium mb-1">Position After Route</label>
<select <select

View File

@@ -459,6 +459,20 @@ export function RunEncounters() {
return nextBoss.levelCap return nextBoss.levelCap
}, [nextBoss, sortedBosses]) }, [nextBoss, sortedBosses])
// Pre-compute which bosses get a section divider rendered AFTER them
// (when the next boss in order has a different section)
const sectionDividerAfterBoss = useMemo(() => {
const map = new Map<number, string>()
for (let i = 0; i < sortedBosses.length - 1; i++) {
const current = sortedBosses[i]
const next = sortedBosses[i + 1]
if (next.section != null && current.section !== next.section) {
map.set(current.id, next.section)
}
}
return map
}, [sortedBosses])
// Map afterRouteId → BossBattle[] for interleaving // Map afterRouteId → BossBattle[] for interleaving
const bossesAfterRoute = useMemo(() => { const bossesAfterRoute = useMemo(() => {
const map = new Map<number, BossBattle[]>() const map = new Map<number, BossBattle[]>()
@@ -1023,6 +1037,7 @@ export function RunEncounters() {
{/* Boss battle cards after this route */} {/* Boss battle cards after this route */}
{bossesHere.map((boss) => { {bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id) const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = { const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader', gym_leader: 'Gym Leader',
elite_four: 'Elite Four', elite_four: 'Elite Four',
@@ -1051,12 +1066,12 @@ export function RunEncounters() {
} }
return ( return (
<div <div key={`boss-${boss.id}`}>
key={`boss-${boss.id}`} <div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${ className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800' isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
} px-4 py-3`} } px-4 py-3`}
> >
<div <div
className="flex items-start justify-between cursor-pointer select-none" className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss} onClick={toggleBoss}
@@ -1122,6 +1137,14 @@ export function RunEncounters() {
))} ))}
</div> </div>
)} )}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
</div>
)}
</div> </div>
) )
})} })}

View File

@@ -26,6 +26,7 @@ import {
useUpdateRoute, useUpdateRoute,
useDeleteRoute, useDeleteRoute,
useReorderRoutes, useReorderRoutes,
useReorderBosses,
useCreateBossBattle, useCreateBossBattle,
useUpdateBossBattle, useUpdateBossBattle,
useDeleteBossBattle, useDeleteBossBattle,
@@ -82,6 +83,59 @@ function SortableRouteRow({
) )
} }
function SortableBossRow({
boss,
onClick,
}: {
boss: BossBattle
onClick: (b: BossBattle) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: boss.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<tr
ref={setNodeRef}
style={style}
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
onClick={() => onClick(boss)}
>
<td className="px-4 py-3 text-sm w-12">
<button
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
title="Drag to reorder"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
</button>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
{boss.bossType.replace('_', ' ')}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.section ?? '\u2014'}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
</tr>
)
}
export function AdminGameDetail() { export function AdminGameDetail() {
const { gameId } = useParams<{ gameId: string }>() const { gameId } = useParams<{ gameId: string }>()
const id = Number(gameId) const id = Number(gameId)
@@ -95,6 +149,7 @@ export function AdminGameDetail() {
const createBoss = useCreateBossBattle(id) const createBoss = useCreateBossBattle(id)
const updateBoss = useUpdateBossBattle(id) const updateBoss = useUpdateBossBattle(id)
const deleteBoss = useDeleteBossBattle(id) const deleteBoss = useDeleteBossBattle(id)
const reorderBosses = useReorderBosses(id)
const [tab, setTab] = useState<'routes' | 'bosses'>('routes') const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
@@ -133,6 +188,26 @@ export function AdminGameDetail() {
reorderRoutes.mutate(newOrders) reorderRoutes.mutate(newOrders)
} }
const handleBossDragEnd = (event: DragEndEvent) => {
if (!bosses) return
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = bosses.findIndex((b) => b.id === active.id)
const newIndex = bosses.findIndex((b) => b.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reordered = [...bosses]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
const newOrders = reordered.map((b, i) => ({
id: b.id,
order: i + 1,
}))
reorderBosses.mutate(newOrders)
}
return ( return (
<div> <div>
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400"> <nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
@@ -304,6 +379,7 @@ export function AdminGameDetail() {
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-gray-50 dark:bg-gray-800">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
Order Order
</th> </th>
@@ -313,6 +389,9 @@ export function AdminGameDetail() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type Type
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Section
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Location Location
</th> </th>
@@ -324,24 +403,26 @@ export function AdminGameDetail() {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <DndContext
{bosses.map((boss) => ( sensors={sensors}
<tr collisionDetection={closestCenter}
key={boss.id} onDragEnd={handleBossDragEnd}
className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" >
onClick={() => setEditingBoss(boss)} <SortableContext
> items={bosses.map((b) => b.id)}
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td> strategy={verticalListSortingStrategy}
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">{boss.name}</td> >
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize"> <tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{boss.bossType.replace('_', ' ')} {bosses.map((boss) => (
</td> <SortableBossRow
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td> key={boss.id}
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td> boss={boss}
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td> onClick={(b) => setEditingBoss(b)}
</tr> />
))} ))}
</tbody> </tbody>
</SortableContext>
</DndContext>
</table> </table>
</div> </div>
</div> </div>

View File

@@ -147,6 +147,7 @@ export interface CreateBossBattleInput {
order: number order: number
afterRouteId?: number | null afterRouteId?: number | null
location: string location: string
section?: string | null
spriteUrl?: string | null spriteUrl?: string | null
} }
@@ -159,9 +160,15 @@ export interface UpdateBossBattleInput {
order?: number order?: number
afterRouteId?: number | null afterRouteId?: number | null
location?: string location?: string
section?: string | null
spriteUrl?: string | null spriteUrl?: string | null
} }
export interface BossReorderItem {
id: number
order: number
}
export interface BossPokemonInput { export interface BossPokemonInput {
pokemonId: number pokemonId: number
level: number level: number

View File

@@ -151,6 +151,7 @@ export interface BossBattle {
order: number order: number
afterRouteId: number | null afterRouteId: number | null
location: string location: string
section: string | null
spriteUrl: string | null spriteUrl: string | null
pokemon: BossPokemon[] pokemon: BossPokemon[]
} }