Add pokemon status management with death tracking
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,39 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-hm6t
|
# nuzlocke-tracker-hm6t
|
||||||
title: Pokemon Status Management
|
title: Pokemon Status Management
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-04T15:44:37Z
|
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
|
parent: nuzlocke-tracker-f5ob
|
||||||
---
|
---
|
||||||
|
|
||||||
Implement the system for tracking Pokémon status (alive, dead, boxed).
|
Implement the system for tracking Pokémon status (alive, dead, boxed).
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Create Pokémon card/tile component showing:
|
- [x] Create Pokémon card/tile component showing:
|
||||||
- [ ] Sprite, name, nickname
|
- [x] Sprite, name, nickname
|
||||||
- [ ] Current status with visual indicator
|
- [x] Current status with visual indicator (green/red dot)
|
||||||
- [ ] Location caught
|
- [x] Location caught
|
||||||
- [ ] Implement status transitions:
|
- [x] Implement status transitions:
|
||||||
- [ ] Alive → Dead (fainted in battle)
|
- [x] Alive → Dead (fainted in battle) via StatusChangeModal with confirmation
|
||||||
- [ ] Alive → Boxed (stored in PC)
|
- [ ] Alive → Boxed (stored in PC) — deferred, no boxed tracking yet
|
||||||
- [ ] Boxed → Alive (added to party)
|
- [ ] Boxed → Alive (added to party) — deferred, no boxed tracking yet
|
||||||
- [ ] Add death recording:
|
- [x] Add death recording:
|
||||||
- [ ] Optional: record cause of death (trainer, wild, gym leader)
|
- [x] Optional: record cause of death (free text, max 100 chars)
|
||||||
- [ ] Optional: record level at death
|
- [x] Optional: record level at death
|
||||||
- [ ] Create "Graveyard" view for fallen Pokémon
|
- [x] Create "Graveyard" view for fallen Pokémon (on RunDashboard)
|
||||||
- [ ] Create "Box" view for stored Pokémon
|
- [ ] 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
|
## Notes
|
||||||
- Status changes should be confirmable (prevent accidental deaths)
|
- Status changes should be confirmable (prevent accidental deaths) ✓
|
||||||
- Consider undo functionality for misclicks
|
- Consider undo functionality for misclicks — not implemented (Nuzlocke rules: death is permanent)
|
||||||
43
README.md
43
README.md
@@ -1 +1,44 @@
|
|||||||
# nuzlocke-tracker
|
# 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.
|
||||||
@@ -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')
|
||||||
@@ -17,6 +17,7 @@ class Encounter(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(20)) # caught, fainted, missed
|
status: Mapped[str] = mapped_column(String(20)) # caught, fainted, missed
|
||||||
catch_level: Mapped[int | None] = mapped_column(SmallInteger)
|
catch_level: Mapped[int | None] = mapped_column(SmallInteger)
|
||||||
faint_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(
|
caught_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class EncounterUpdate(CamelModel):
|
|||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
faint_level: int | None = None
|
faint_level: int | None = None
|
||||||
|
death_cause: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EncounterResponse(CamelModel):
|
class EncounterResponse(CamelModel):
|
||||||
@@ -28,6 +29,7 @@ class EncounterResponse(CamelModel):
|
|||||||
status: str
|
status: str
|
||||||
catch_level: int | None
|
catch_level: int | None
|
||||||
faint_level: int | None
|
faint_level: int | None
|
||||||
|
death_cause: str | None
|
||||||
caught_at: datetime
|
caught_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ interface EncounterModalProps {
|
|||||||
}) => void
|
}) => void
|
||||||
onUpdate?: (data: {
|
onUpdate?: (data: {
|
||||||
id: number
|
id: number
|
||||||
data: { nickname?: string; status?: EncounterStatus; faintLevel?: number }
|
data: {
|
||||||
|
nickname?: string
|
||||||
|
status?: EncounterStatus
|
||||||
|
faintLevel?: number
|
||||||
|
deathCause?: string
|
||||||
|
}
|
||||||
}) => void
|
}) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
@@ -69,6 +74,7 @@ export function EncounterModal({
|
|||||||
existing?.catchLevel?.toString() ?? '',
|
existing?.catchLevel?.toString() ?? '',
|
||||||
)
|
)
|
||||||
const [faintLevel, setFaintLevel] = useState<string>('')
|
const [faintLevel, setFaintLevel] = useState<string>('')
|
||||||
|
const [deathCause, setDeathCause] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const isEditing = !!existing
|
const isEditing = !!existing
|
||||||
@@ -95,6 +101,7 @@ export function EncounterModal({
|
|||||||
nickname: nickname || undefined,
|
nickname: nickname || undefined,
|
||||||
status,
|
status,
|
||||||
faintLevel: faintLevel ? Number(faintLevel) : undefined,
|
faintLevel: faintLevel ? Number(faintLevel) : undefined,
|
||||||
|
deathCause: deathCause || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else if (selectedPokemon) {
|
} else if (selectedPokemon) {
|
||||||
@@ -301,31 +308,53 @@ export function EncounterModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 &&
|
{isEditing &&
|
||||||
existing?.status === 'caught' &&
|
existing?.status === 'caught' &&
|
||||||
existing?.faintLevel === null && (
|
existing?.faintLevel === null && (
|
||||||
<div>
|
<>
|
||||||
<label
|
<div>
|
||||||
htmlFor="faint-level"
|
<label
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
htmlFor="faint-level"
|
||||||
>
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
Faint Level{' '}
|
>
|
||||||
<span className="font-normal text-gray-400">
|
Faint Level{' '}
|
||||||
(mark as dead)
|
<span className="font-normal text-gray-400">
|
||||||
</span>
|
(mark as dead)
|
||||||
</label>
|
</span>
|
||||||
<input
|
</label>
|
||||||
id="faint-level"
|
<input
|
||||||
type="number"
|
id="faint-level"
|
||||||
min={1}
|
type="number"
|
||||||
max={100}
|
min={1}
|
||||||
value={faintLevel}
|
max={100}
|
||||||
onChange={(e) => setFaintLevel(e.target.value)}
|
value={faintLevel}
|
||||||
placeholder="Leave empty if still alive"
|
onChange={(e) => setFaintLevel(e.target.value)}
|
||||||
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"
|
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"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="death-cause"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Cause of Death{' '}
|
||||||
|
<span className="font-normal text-gray-400">
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="death-cause"
|
||||||
|
type="text"
|
||||||
|
maxLength={100}
|
||||||
|
value={deathCause}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { EncounterDetail } from '../types'
|
|||||||
interface PokemonCardProps {
|
interface PokemonCardProps {
|
||||||
encounter: EncounterDetail
|
encounter: EncounterDetail
|
||||||
showFaintLevel?: boolean
|
showFaintLevel?: boolean
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const typeColors: Record<string, string> = {
|
||||||
@@ -26,15 +27,16 @@ const typeColors: Record<string, string> = {
|
|||||||
fairy: 'bg-pink-300',
|
fairy: 'bg-pink-300',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) {
|
export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) {
|
||||||
const { pokemon, route, nickname, catchLevel, faintLevel } = encounter
|
const { pokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
|
||||||
const isDead = faintLevel !== null
|
const isDead = faintLevel !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={onClick}
|
||||||
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center ${
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center ${
|
||||||
isDead ? 'opacity-60 grayscale' : ''
|
isDead ? 'opacity-60 grayscale' : ''
|
||||||
}`}
|
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
|
||||||
>
|
>
|
||||||
{pokemon.spriteUrl ? (
|
{pokemon.spriteUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -48,8 +50,13 @@ export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-2 font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
{nickname || pokemon.name}
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${isDead ? 'bg-red-500' : 'bg-green-500'}`}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||||
|
{nickname || pokemon.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{nickname && (
|
{nickname && (
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
@@ -77,6 +84,12 @@ export function PokemonCard({ encounter, showFaintLevel }: PokemonCardProps) {
|
|||||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
{route.name}
|
{route.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDead && deathCause && (
|
||||||
|
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2">
|
||||||
|
{deathCause}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
247
frontend/src/components/StatusChangeModal.tsx
Normal file
247
frontend/src/components/StatusChangeModal.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-sm w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{isDead ? 'Death Details' : 'Pokemon Status'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{/* Pokemon info */}
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
{pokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={pokemon.spriteUrl}
|
||||||
|
alt={pokemon.name}
|
||||||
|
className={`w-16 h-16 ${isDead ? 'grayscale opacity-60' : ''}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
|
{pokemon.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{nickname || pokemon.name}
|
||||||
|
</div>
|
||||||
|
{nickname && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{pokemon.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{pokemon.types.map((type) => (
|
||||||
|
<span
|
||||||
|
key={type}
|
||||||
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${typeColors[type] ?? 'bg-gray-500'}`}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Lv. {catchLevel ?? '?'} · {route.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dead pokemon: view-only details */}
|
||||||
|
{isDead && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-400 font-medium text-sm">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
Deceased
|
||||||
|
</div>
|
||||||
|
{faintLevel !== null && (
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Level at death:
|
||||||
|
</span>{' '}
|
||||||
|
{faintLevel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deathCause && (
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Cause:
|
||||||
|
</span>{' '}
|
||||||
|
{deathCause}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alive pokemon: mark as dead */}
|
||||||
|
{!isDead && !showConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Mark as Dead
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation form */}
|
||||||
|
{!isDead && showConfirm && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400 font-medium">
|
||||||
|
This cannot be undone (Nuzlocke rules).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="death-level"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Level at Death{' '}
|
||||||
|
<span className="font-normal text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="death-level"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={deathLevel}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="death-cause"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Cause of Death{' '}
|
||||||
|
<span className="font-normal text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="death-cause"
|
||||||
|
type="text"
|
||||||
|
maxLength={100}
|
||||||
|
value={cause}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleConfirmDeath}
|
||||||
|
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? 'Saving...' : 'Confirm Death'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer for dead/no-confirm views */}
|
||||||
|
{(isDead || (!isDead && !showConfirm)) && (
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { GameGrid } from './GameGrid'
|
|||||||
export { Layout } from './Layout'
|
export { Layout } from './Layout'
|
||||||
export { PokemonCard } from './PokemonCard'
|
export { PokemonCard } from './PokemonCard'
|
||||||
export { RuleBadges } from './RuleBadges'
|
export { RuleBadges } from './RuleBadges'
|
||||||
|
export { StatusChangeModal } from './StatusChangeModal'
|
||||||
export { RuleToggle } from './RuleToggle'
|
export { RuleToggle } from './RuleToggle'
|
||||||
export { RulesConfiguration } from './RulesConfiguration'
|
export { RulesConfiguration } from './RulesConfiguration'
|
||||||
export { StatCard } from './StatCard'
|
export { StatCard } from './StatCard'
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useRun } from '../hooks/useRuns'
|
import { useRun } from '../hooks/useRuns'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
import { StatCard, PokemonCard, RuleBadges } from '../components'
|
import { useUpdateEncounter } from '../hooks/useEncounters'
|
||||||
import type { RunStatus } from '../types'
|
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal } from '../components'
|
||||||
|
import type { RunStatus, EncounterDetail } from '../types'
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
@@ -13,8 +15,12 @@ const statusStyles: Record<RunStatus, string> = {
|
|||||||
|
|
||||||
export function RunDashboard() {
|
export function RunDashboard() {
|
||||||
const { runId } = useParams<{ runId: string }>()
|
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 { data: routes } = useGameRoutes(run?.gameId ?? null)
|
||||||
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
|
const [selectedEncounter, setSelectedEncounter] =
|
||||||
|
useState<EncounterDetail | null>(null)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -118,7 +124,11 @@ export function RunDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
{alive.map((enc) => (
|
{alive.map((enc) => (
|
||||||
<PokemonCard key={enc.id} encounter={enc} />
|
<PokemonCard
|
||||||
|
key={enc.id}
|
||||||
|
encounter={enc}
|
||||||
|
onClick={() => setSelectedEncounter(enc)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -132,7 +142,12 @@ export function RunDashboard() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
{dead.map((enc) => (
|
{dead.map((enc) => (
|
||||||
<PokemonCard key={enc.id} encounter={enc} showFaintLevel />
|
<PokemonCard
|
||||||
|
key={enc.id}
|
||||||
|
encounter={enc}
|
||||||
|
showFaintLevel
|
||||||
|
onClick={() => setSelectedEncounter(enc)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,6 +162,20 @@ export function RunDashboard() {
|
|||||||
Log Encounter
|
Log Encounter
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Change Modal */}
|
||||||
|
{selectedEncounter && (
|
||||||
|
<StatusChangeModal
|
||||||
|
encounter={selectedEncounter}
|
||||||
|
onUpdate={(data) => {
|
||||||
|
updateEncounter.mutate(data, {
|
||||||
|
onSuccess: () => setSelectedEncounter(null),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => setSelectedEncounter(null)}
|
||||||
|
isPending={updateEncounter.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,12 @@ export function RunEncounters() {
|
|||||||
|
|
||||||
const handleUpdate = (data: {
|
const handleUpdate = (data: {
|
||||||
id: number
|
id: number
|
||||||
data: { nickname?: string; status?: EncounterStatus; faintLevel?: number }
|
data: {
|
||||||
|
nickname?: string
|
||||||
|
status?: EncounterStatus
|
||||||
|
faintLevel?: number
|
||||||
|
deathCause?: string
|
||||||
|
}
|
||||||
}) => {
|
}) => {
|
||||||
updateEncounter.mutate(data, {
|
updateEncounter.mutate(data, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -225,7 +230,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 &&
|
||||||
' (dead)'}
|
(encounter.deathCause
|
||||||
|
? ` — ${encounter.deathCause}`
|
||||||
|
: ' (dead)')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface Encounter {
|
|||||||
status: EncounterStatus
|
status: EncounterStatus
|
||||||
catchLevel: number | null
|
catchLevel: number | null
|
||||||
faintLevel: number | null
|
faintLevel: number | null
|
||||||
|
deathCause: string | null
|
||||||
caughtAt: string
|
caughtAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export interface UpdateEncounterInput {
|
|||||||
nickname?: string
|
nickname?: string
|
||||||
status?: EncounterStatus
|
status?: EncounterStatus
|
||||||
faintLevel?: number
|
faintLevel?: number
|
||||||
|
deathCause?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
|
|||||||
Reference in New Issue
Block a user