From a911259ef50955820fa49174aec89feb49e61c16 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Thu, 5 Feb 2026 18:36:08 +0100 Subject: [PATCH] Add pokemon status management with death tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement status change workflow (alive → dead) with confirmation modal, death cause recording, and visual status indicators on pokemon cards. Includes backend migration for death_cause field and graveyard view on the run dashboard. Co-Authored-By: Claude Opus 4.5 --- ...tracker-hm6t--pokemon-status-management.md | 43 +-- README.md | 45 +++- ...2c3d4e5f6_add_death_cause_to_encounters.py | 26 ++ backend/src/app/models/encounter.py | 1 + backend/src/app/schemas/encounter.py | 2 + frontend/src/components/EncounterModal.tsx | 75 ++++-- frontend/src/components/PokemonCard.tsx | 23 +- frontend/src/components/StatusChangeModal.tsx | 247 ++++++++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/pages/RunDashboard.tsx | 39 ++- frontend/src/pages/RunEncounters.tsx | 11 +- frontend/src/types/game.ts | 2 + 12 files changed, 462 insertions(+), 53 deletions(-) create mode 100644 backend/src/app/alembic/versions/a1b2c3d4e5f6_add_death_cause_to_encounters.py create mode 100644 frontend/src/components/StatusChangeModal.tsx diff --git a/.beans/nuzlocke-tracker-hm6t--pokemon-status-management.md b/.beans/nuzlocke-tracker-hm6t--pokemon-status-management.md index 5896a87..b72662a 100644 --- a/.beans/nuzlocke-tracker-hm6t--pokemon-status-management.md +++ b/.beans/nuzlocke-tracker-hm6t--pokemon-status-management.md @@ -1,30 +1,39 @@ --- # nuzlocke-tracker-hm6t title: Pokemon Status Management -status: todo +status: completed type: task +priority: normal created_at: 2026-02-04T15:44:37Z -updated_at: 2026-02-04T15:44:37Z +updated_at: 2026-02-05T16:47:18Z parent: nuzlocke-tracker-f5ob --- Implement the system for tracking Pokémon status (alive, dead, boxed). ## Checklist -- [ ] Create Pokémon card/tile component showing: - - [ ] Sprite, name, nickname - - [ ] Current status with visual indicator - - [ ] Location caught -- [ ] Implement status transitions: - - [ ] Alive → Dead (fainted in battle) - - [ ] Alive → Boxed (stored in PC) - - [ ] Boxed → Alive (added to party) -- [ ] Add death recording: - - [ ] Optional: record cause of death (trainer, wild, gym leader) - - [ ] Optional: record level at death -- [ ] Create "Graveyard" view for fallen Pokémon -- [ ] Create "Box" view for stored Pokémon +- [x] Create Pokémon card/tile component showing: + - [x] Sprite, name, nickname + - [x] Current status with visual indicator (green/red dot) + - [x] Location caught +- [x] Implement status transitions: + - [x] Alive → Dead (fainted in battle) via StatusChangeModal with confirmation + - [ ] Alive → Boxed (stored in PC) — deferred, no boxed tracking yet + - [ ] Boxed → Alive (added to party) — deferred, no boxed tracking yet +- [x] Add death recording: + - [x] Optional: record cause of death (free text, max 100 chars) + - [x] Optional: record level at death +- [x] Create "Graveyard" view for fallen Pokémon (on RunDashboard) +- [ ] Create "Box" view for stored Pokémon — deferred, no boxed tracking yet + +## Implementation (death_cause feature) +- Backend: Alembic migration adds `death_cause` VARCHAR(100) to encounters +- Backend: Model + schemas updated with `death_cause` field +- Frontend: `StatusChangeModal` for recording death with confirmation from RunDashboard +- Frontend: `PokemonCard` now clickable with status indicator dot and death cause display +- Frontend: `EncounterModal` includes death cause input alongside faint level +- Frontend: `RunEncounters` shows death cause in route list ## Notes -- Status changes should be confirmable (prevent accidental deaths) -- Consider undo functionality for misclicks \ No newline at end of file +- Status changes should be confirmable (prevent accidental deaths) ✓ +- Consider undo functionality for misclicks — not implemented (Nuzlocke rules: death is permanent) \ No newline at end of file diff --git a/README.md b/README.md index e5dfeb1..fff11d3 100644 --- a/README.md +++ b/README.md @@ -1 +1,44 @@ -# nuzlocke-tracker \ No newline at end of file +# nuzlocke-tracker + +A full-stack Nuzlocke run tracker for Pokemon games. + +## Getting Started + +### Prerequisites + +- Docker & Docker Compose + +### Start the Stack + +```bash +docker compose up +``` + +This starts three services: + +| Service | URL | +|------------|--------------------------| +| Frontend | http://localhost:5173 | +| API | http://localhost:8000 | +| API Docs | http://localhost:8000/docs| +| PostgreSQL | localhost:5432 | + +### Run Migrations + +```bash +docker compose exec api alembic -c /app/alembic.ini upgrade head +``` + +### Seed the Database + +```bash +docker compose exec api python -m app.seeds +``` + +To seed and verify the data was loaded correctly: + +```bash +docker compose exec api python -m app.seeds --verify +``` + +This loads game data, Pokemon, routes, and encounter tables for FireRed, LeafGreen, Emerald, HeartGold, and SoulSilver. \ No newline at end of file diff --git a/backend/src/app/alembic/versions/a1b2c3d4e5f6_add_death_cause_to_encounters.py b/backend/src/app/alembic/versions/a1b2c3d4e5f6_add_death_cause_to_encounters.py new file mode 100644 index 0000000..adc9c77 --- /dev/null +++ b/backend/src/app/alembic/versions/a1b2c3d4e5f6_add_death_cause_to_encounters.py @@ -0,0 +1,26 @@ +"""add death_cause to encounters + +Revision ID: a1b2c3d4e5f6 +Revises: 9afcbafe9888 +Create Date: 2026-02-05 17:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, Sequence[str], None] = '9afcbafe9888' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('encounters', sa.Column('death_cause', sa.String(100), nullable=True)) + + +def downgrade() -> None: + op.drop_column('encounters', 'death_cause') diff --git a/backend/src/app/models/encounter.py b/backend/src/app/models/encounter.py index d5cf91f..cc8f1af 100644 --- a/backend/src/app/models/encounter.py +++ b/backend/src/app/models/encounter.py @@ -17,6 +17,7 @@ class Encounter(Base): status: Mapped[str] = mapped_column(String(20)) # caught, fainted, missed catch_level: Mapped[int | None] = mapped_column(SmallInteger) faint_level: Mapped[int | None] = mapped_column(SmallInteger) + death_cause: Mapped[str | None] = mapped_column(String(100)) caught_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) diff --git a/backend/src/app/schemas/encounter.py b/backend/src/app/schemas/encounter.py index 255dbae..83d50b1 100644 --- a/backend/src/app/schemas/encounter.py +++ b/backend/src/app/schemas/encounter.py @@ -17,6 +17,7 @@ class EncounterUpdate(CamelModel): nickname: str | None = None status: str | None = None faint_level: int | None = None + death_cause: str | None = None class EncounterResponse(CamelModel): @@ -28,6 +29,7 @@ class EncounterResponse(CamelModel): status: str catch_level: int | None faint_level: int | None + death_cause: str | None caught_at: datetime diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index 182a32e..c8830e8 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -19,7 +19,12 @@ interface EncounterModalProps { }) => void onUpdate?: (data: { id: number - data: { nickname?: string; status?: EncounterStatus; faintLevel?: number } + data: { + nickname?: string + status?: EncounterStatus + faintLevel?: number + deathCause?: string + } }) => void onClose: () => void isPending: boolean @@ -69,6 +74,7 @@ export function EncounterModal({ existing?.catchLevel?.toString() ?? '', ) const [faintLevel, setFaintLevel] = useState('') + const [deathCause, setDeathCause] = useState('') const [search, setSearch] = useState('') const isEditing = !!existing @@ -95,6 +101,7 @@ export function EncounterModal({ nickname: nickname || undefined, status, faintLevel: faintLevel ? Number(faintLevel) : undefined, + deathCause: deathCause || undefined, }, }) } else if (selectedPokemon) { @@ -301,31 +308,53 @@ export function EncounterModal({ )} - {/* Faint Level (only when editing a caught pokemon to mark dead) */} + {/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */} {isEditing && existing?.status === 'caught' && existing?.faintLevel === null && ( -
- - setFaintLevel(e.target.value)} - placeholder="Leave empty if still alive" - className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
+ <> +
+ + setFaintLevel(e.target.value)} + placeholder="Leave empty if still alive" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setDeathCause(e.target.value)} + placeholder="e.g. Crit from rival's Charizard" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ )} diff --git a/frontend/src/components/PokemonCard.tsx b/frontend/src/components/PokemonCard.tsx index b1ba87b..14cc889 100644 --- a/frontend/src/components/PokemonCard.tsx +++ b/frontend/src/components/PokemonCard.tsx @@ -3,6 +3,7 @@ import type { EncounterDetail } from '../types' interface PokemonCardProps { encounter: EncounterDetail showFaintLevel?: boolean + onClick?: () => void } const typeColors: Record = { @@ -26,15 +27,16 @@ const typeColors: Record = { fairy: 'bg-pink-300', } -export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) { - const { pokemon, route, nickname, catchLevel, faintLevel } = encounter +export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) { + const { pokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter const isDead = faintLevel !== null return (
{pokemon.spriteUrl ? ( )} -
- {nickname || pokemon.name} +
+ + + {nickname || pokemon.name} +
{nickname && (
@@ -77,6 +84,12 @@ export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) {
{route.name}
+ + {isDead && deathCause && ( +
+ {deathCause} +
+ )}
) } diff --git a/frontend/src/components/StatusChangeModal.tsx b/frontend/src/components/StatusChangeModal.tsx new file mode 100644 index 0000000..bfef675 --- /dev/null +++ b/frontend/src/components/StatusChangeModal.tsx @@ -0,0 +1,247 @@ +import { useState } from 'react' +import type { EncounterDetail } from '../types' + +interface StatusChangeModalProps { + encounter: EncounterDetail + onUpdate: (data: { + id: number + data: { faintLevel?: number; deathCause?: string } + }) => void + onClose: () => void + isPending: boolean +} + +const typeColors: Record = { + normal: 'bg-gray-400', + fire: 'bg-red-500', + water: 'bg-blue-500', + electric: 'bg-yellow-400', + grass: 'bg-green-500', + ice: 'bg-cyan-300', + fighting: 'bg-red-700', + poison: 'bg-purple-500', + ground: 'bg-amber-600', + flying: 'bg-indigo-300', + psychic: 'bg-pink-500', + bug: 'bg-lime-500', + rock: 'bg-amber-700', + ghost: 'bg-purple-700', + dragon: 'bg-indigo-600', + dark: 'bg-gray-700', + steel: 'bg-gray-400', + fairy: 'bg-pink-300', +} + +export function StatusChangeModal({ + encounter, + onUpdate, + onClose, + isPending, +}: StatusChangeModalProps) { + const { pokemon, route, nickname, catchLevel, faintLevel, deathCause } = + encounter + const isDead = faintLevel !== null + const [showConfirm, setShowConfirm] = useState(false) + const [deathLevel, setDeathLevel] = useState('') + const [cause, setCause] = useState('') + + const handleConfirmDeath = () => { + onUpdate({ + id: encounter.id, + data: { + faintLevel: deathLevel ? Number(deathLevel) : undefined, + deathCause: cause || undefined, + }, + }) + } + + return ( +
+
+
+ {/* Header */} +
+

+ {isDead ? 'Death Details' : 'Pokemon Status'} +

+ +
+ +
+ {/* Pokemon info */} +
+ {pokemon.spriteUrl ? ( + {pokemon.name} + ) : ( +
+ {pokemon.name[0].toUpperCase()} +
+ )} +
+
+ {nickname || pokemon.name} +
+ {nickname && ( +
+ {pokemon.name} +
+ )} +
+ {pokemon.types.map((type) => ( + + {type} + + ))} +
+
+ Lv. {catchLevel ?? '?'} · {route.name} +
+
+
+ + {/* Dead pokemon: view-only details */} + {isDead && ( +
+
+ + Deceased +
+ {faintLevel !== null && ( +
+ + Level at death: + {' '} + {faintLevel} +
+ )} + {deathCause && ( +
+ + Cause: + {' '} + {deathCause} +
+ )} +
+ )} + + {/* Alive pokemon: mark as dead */} + {!isDead && !showConfirm && ( + + )} + + {/* Confirmation form */} + {!isDead && showConfirm && ( +
+
+

+ This cannot be undone (Nuzlocke rules). +

+
+ +
+ + setDeathLevel(e.target.value)} + placeholder="Level" + className="w-24 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500" + /> +
+ +
+ + setCause(e.target.value)} + placeholder="e.g. Crit from rival's Charizard" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-500" + /> +
+ +
+ + +
+
+ )} +
+ + {/* Footer for dead/no-confirm views */} + {(isDead || (!isDead && !showConfirm)) && ( +
+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index bc79d49..4675471 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -4,6 +4,7 @@ export { GameGrid } from './GameGrid' export { Layout } from './Layout' export { PokemonCard } from './PokemonCard' export { RuleBadges } from './RuleBadges' +export { StatusChangeModal } from './StatusChangeModal' export { RuleToggle } from './RuleToggle' export { RulesConfiguration } from './RulesConfiguration' export { StatCard } from './StatCard' diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index 8c2b950..d453c81 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react' import { useParams, Link } from 'react-router-dom' import { useRun } from '../hooks/useRuns' import { useGameRoutes } from '../hooks/useGames' -import { StatCard, PokemonCard, RuleBadges } from '../components' -import type { RunStatus } from '../types' +import { useUpdateEncounter } from '../hooks/useEncounters' +import { StatCard, PokemonCard, RuleBadges, StatusChangeModal } from '../components' +import type { RunStatus, EncounterDetail } from '../types' const statusStyles: Record = { active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', @@ -13,8 +15,12 @@ const statusStyles: Record = { export function RunDashboard() { const { runId } = useParams<{ runId: string }>() - const { data: run, isLoading, error } = useRun(Number(runId)) + const runIdNum = Number(runId) + const { data: run, isLoading, error } = useRun(runIdNum) const { data: routes } = useGameRoutes(run?.gameId ?? null) + const updateEncounter = useUpdateEncounter(runIdNum) + const [selectedEncounter, setSelectedEncounter] = + useState(null) if (isLoading) { return ( @@ -118,7 +124,11 @@ export function RunDashboard() { ) : (
{alive.map((enc) => ( - + setSelectedEncounter(enc)} + /> ))}
)} @@ -132,7 +142,12 @@ export function RunDashboard() {
{dead.map((enc) => ( - + setSelectedEncounter(enc)} + /> ))}
@@ -147,6 +162,20 @@ export function RunDashboard() { Log Encounter
+ + {/* Status Change Modal */} + {selectedEncounter && ( + { + updateEncounter.mutate(data, { + onSuccess: () => setSelectedEncounter(null), + }) + }} + onClose={() => setSelectedEncounter(null)} + isPending={updateEncounter.isPending} + /> + )}
) } diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index d464245..a3a976e 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -121,7 +121,12 @@ export function RunEncounters() { const handleUpdate = (data: { id: number - data: { nickname?: string; status?: EncounterStatus; faintLevel?: number } + data: { + nickname?: string + status?: EncounterStatus + faintLevel?: number + deathCause?: string + } }) => { updateEncounter.mutate(data, { onSuccess: () => { @@ -225,7 +230,9 @@ export function RunEncounters() { {encounter.nickname ?? encounter.pokemon.name} {encounter.status === 'caught' && encounter.faintLevel !== null && - ' (dead)'} + (encounter.deathCause + ? ` — ${encounter.deathCause}` + : ' (dead)')} )} diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 523d1ba..c4e7c1a 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -48,6 +48,7 @@ export interface Encounter { status: EncounterStatus catchLevel: number | null faintLevel: number | null + deathCause: string | null caughtAt: string } @@ -97,6 +98,7 @@ export interface UpdateEncounterInput { nickname?: string status?: EncounterStatus faintLevel?: number + deathCause?: string } // Re-export for convenience