Release: MFA, JWKS auth, run ownership, and dependency updates #79
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1301,7 +1301,9 @@ export function RunEncounters() {
|
|||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={
|
onClick={
|
||||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
isActive && canEdit
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1317,7 +1319,9 @@ export function RunEncounters() {
|
|||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
showFaintLevel
|
||||||
onClick={
|
onClick={
|
||||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
isActive && canEdit
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1350,7 +1354,9 @@ export function RunEncounters() {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
|
onClick={
|
||||||
|
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1475,7 +1481,9 @@ export function RunEncounters() {
|
|||||||
>
|
>
|
||||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-text-primary">{route.name}</div>
|
<div className="text-sm font-medium text-text-primary">
|
||||||
|
{route.name}
|
||||||
|
</div>
|
||||||
{encounter ? (
|
{encounter ? (
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
{encounter.pokemon.spriteUrl && (
|
{encounter.pokemon.spriteUrl && (
|
||||||
@@ -1489,7 +1497,9 @@ export function RunEncounters() {
|
|||||||
{encounter.nickname ?? encounter.pokemon.name}
|
{encounter.nickname ?? encounter.pokemon.name}
|
||||||
{encounter.status === 'caught' &&
|
{encounter.status === 'caught' &&
|
||||||
encounter.faintLevel !== null &&
|
encounter.faintLevel !== null &&
|
||||||
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
(encounter.deathCause
|
||||||
|
? ` — ${encounter.deathCause}`
|
||||||
|
: ' (dead)')}
|
||||||
</span>
|
</span>
|
||||||
{giftEncounter && (
|
{giftEncounter && (
|
||||||
<>
|
<>
|
||||||
@@ -1601,7 +1611,11 @@ export function RunEncounters() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{boss.spriteUrl && (
|
{boss.spriteUrl && (
|
||||||
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
<img
|
||||||
|
src={boss.spriteUrl}
|
||||||
|
alt={boss.name}
|
||||||
|
className="h-10 w-auto"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1611,7 +1625,9 @@ export function RunEncounters() {
|
|||||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
|
||||||
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
||||||
</span>
|
</span>
|
||||||
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
|
{boss.specialtyType && (
|
||||||
|
<TypeBadge type={boss.specialtyType} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-tertiary">
|
<p className="text-xs text-text-tertiary">
|
||||||
{boss.location} · Level Cap: {boss.levelCap}
|
{boss.location} · Level Cap: {boss.levelCap}
|
||||||
@@ -1666,9 +1682,11 @@ export function RunEncounters() {
|
|||||||
<span className="text-[10px] text-text-tertiary capitalize">
|
<span className="text-[10px] text-text-tertiary capitalize">
|
||||||
{enc.nickname ?? dp.name}
|
{enc.nickname ?? dp.name}
|
||||||
</span>
|
</span>
|
||||||
|
{tm.level != null && (
|
||||||
<span className="text-[10px] text-text-muted">
|
<span className="text-[10px] text-text-muted">
|
||||||
Lv.{tm.level}
|
Lv.{tm.level}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1743,7 +1761,9 @@ export function RunEncounters() {
|
|||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
showFaintLevel
|
||||||
onClick={
|
onClick={
|
||||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
isActive && canEdit
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user