Add genlocke cumulative graveyard with backend endpoint and UI
Aggregates all fainted encounters across every leg of a genlocke into a
unified graveyard view. Backend serves GET /genlockes/{id}/graveyard with
per-entry leg/game context and summary stats (total deaths, deaths per
leg, deadliest leg). Frontend adds a toggle button on the genlocke detail
page that reveals a filterable/sortable grid of grayscale Pokemon cards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
---
|
||||
# nuzlocke-tracker-lsdy
|
||||
title: Genlocke cumulative graveyard
|
||||
status: todo
|
||||
status: in-progress
|
||||
type: feature
|
||||
priority: normal
|
||||
created_at: 2026-02-09T07:42:46Z
|
||||
updated_at: 2026-02-09T07:46:22Z
|
||||
updated_at: 2026-02-09T09:58:56Z
|
||||
parent: nuzlocke-tracker-25mh
|
||||
---
|
||||
|
||||
@@ -32,10 +32,10 @@ Display all deaths across all legs of a genlocke in a single unified graveyard v
|
||||
- Reuse existing graveyard/encounter display components where possible
|
||||
|
||||
## Checklist
|
||||
- [ ] Implement `GET /api/v1/genlockes/{id}/graveyard` — query all encounters with status "fainted" across all runs linked to the genlocke's legs, include leg/game context per entry
|
||||
- [ ] Add summary stats to the response: total deaths, deaths per leg, deadliest leg
|
||||
- [ ] Indicate whether each dead Pokemon was a transferred Pokemon or caught fresh (join with GenlockeTransfer)
|
||||
- [ ] Build the cumulative graveyard component: list of dead Pokemon with sprite, nickname, species, leg/game, death cause, level
|
||||
- [ ] Add sorting (by leg, level, species) and filtering (by leg/game)
|
||||
- [ ] Integrate as a tab on the genlocke overview page or as a sub-route
|
||||
- [ ] Reuse existing graveyard display components where applicable
|
||||
- [x] Implement `GET /api/v1/genlockes/{id}/graveyard` — query all encounters with status "fainted" across all runs linked to the genlocke's legs, include leg/game context per entry
|
||||
- [x] Add summary stats to the response: total deaths, deaths per leg, deadliest leg
|
||||
- [ ] Indicate whether each dead Pokemon was a transferred Pokemon or caught fresh (join with GenlockeTransfer) — deferred until GenlockeTransfer model exists (nuzlocke-tracker-lsc2)
|
||||
- [x] Build the cumulative graveyard component: list of dead Pokemon with sprite, nickname, species, leg/game, death cause, level
|
||||
- [x] Add sorting (by leg, level, species) and filtering (by leg/game)
|
||||
- [x] Integrate as a tab on the genlocke overview page or as a sub-route
|
||||
- [x] Reuse existing graveyard display components where applicable
|
||||
@@ -15,13 +15,17 @@ from app.schemas.genlocke import (
|
||||
AddLegRequest,
|
||||
GenlockeCreate,
|
||||
GenlockeDetailResponse,
|
||||
GenlockeGraveyardResponse,
|
||||
GenlockeLegDetailResponse,
|
||||
GenlockeListItem,
|
||||
GenlockeResponse,
|
||||
GenlockeStatsResponse,
|
||||
GenlockeUpdate,
|
||||
GraveyardEntryResponse,
|
||||
GraveyardLegSummary,
|
||||
RetiredPokemonResponse,
|
||||
)
|
||||
from app.schemas.pokemon import PokemonResponse
|
||||
from app.services.families import build_families
|
||||
|
||||
router = APIRouter()
|
||||
@@ -154,6 +158,105 @@ async def get_genlocke(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{genlocke_id}/graveyard",
|
||||
response_model=GenlockeGraveyardResponse,
|
||||
)
|
||||
async def get_genlocke_graveyard(
|
||||
genlocke_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
# Load genlocke with legs + game
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
.where(Genlocke.id == genlocke_id)
|
||||
.options(
|
||||
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
|
||||
)
|
||||
)
|
||||
genlocke = result.scalar_one_or_none()
|
||||
if genlocke is None:
|
||||
raise HTTPException(status_code=404, detail="Genlocke not found")
|
||||
|
||||
# Build run_id → (leg_order, game_name) lookup
|
||||
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
|
||||
run_lookup: dict[int, tuple[int, str]] = {}
|
||||
for leg in genlocke.legs:
|
||||
if leg.run_id is not None:
|
||||
run_lookup[leg.run_id] = (leg.leg_order, leg.game.name)
|
||||
|
||||
if not run_ids:
|
||||
return GenlockeGraveyardResponse(
|
||||
entries=[], total_deaths=0, deaths_per_leg=[], deadliest_leg=None
|
||||
)
|
||||
|
||||
# Query all fainted encounters across all legs
|
||||
enc_result = await session.execute(
|
||||
select(Encounter)
|
||||
.where(
|
||||
Encounter.run_id.in_(run_ids),
|
||||
Encounter.faint_level.isnot(None),
|
||||
Encounter.status == "caught",
|
||||
)
|
||||
.options(
|
||||
selectinload(Encounter.pokemon),
|
||||
selectinload(Encounter.current_pokemon),
|
||||
selectinload(Encounter.route),
|
||||
)
|
||||
)
|
||||
encounters = enc_result.scalars().all()
|
||||
|
||||
# Map to response entries and compute stats
|
||||
entries: list[GraveyardEntryResponse] = []
|
||||
deaths_count: dict[int, int] = {} # run_id → count
|
||||
|
||||
for enc in encounters:
|
||||
leg_order, game_name = run_lookup[enc.run_id]
|
||||
deaths_count[enc.run_id] = deaths_count.get(enc.run_id, 0) + 1
|
||||
|
||||
entries.append(
|
||||
GraveyardEntryResponse(
|
||||
id=enc.id,
|
||||
pokemon=PokemonResponse.model_validate(enc.pokemon),
|
||||
current_pokemon=(
|
||||
PokemonResponse.model_validate(enc.current_pokemon)
|
||||
if enc.current_pokemon
|
||||
else None
|
||||
),
|
||||
nickname=enc.nickname,
|
||||
catch_level=enc.catch_level,
|
||||
faint_level=enc.faint_level,
|
||||
death_cause=enc.death_cause,
|
||||
is_shiny=enc.is_shiny,
|
||||
route_name=enc.route.name,
|
||||
leg_order=leg_order,
|
||||
game_name=game_name,
|
||||
)
|
||||
)
|
||||
|
||||
# Build per-leg summaries
|
||||
deaths_per_leg: list[GraveyardLegSummary] = []
|
||||
for leg in genlocke.legs:
|
||||
if leg.run_id is not None:
|
||||
count = deaths_count.get(leg.run_id, 0)
|
||||
if count > 0:
|
||||
deaths_per_leg.append(
|
||||
GraveyardLegSummary(
|
||||
leg_order=leg.leg_order,
|
||||
game_name=leg.game.name,
|
||||
death_count=count,
|
||||
)
|
||||
)
|
||||
|
||||
deadliest = max(deaths_per_leg, key=lambda s: s.death_count) if deaths_per_leg else None
|
||||
|
||||
return GenlockeGraveyardResponse(
|
||||
entries=entries,
|
||||
total_deaths=len(entries),
|
||||
deaths_per_leg=deaths_per_leg,
|
||||
deadliest_leg=deadliest,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=GenlockeResponse, status_code=201)
|
||||
async def create_genlocke(
|
||||
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
from app.schemas.game import GameResponse
|
||||
from app.schemas.pokemon import PokemonResponse
|
||||
|
||||
|
||||
class GenlockeCreate(CamelModel):
|
||||
@@ -87,3 +88,33 @@ class GenlockeDetailResponse(CamelModel):
|
||||
legs: list[GenlockeLegDetailResponse] = []
|
||||
stats: GenlockeStatsResponse
|
||||
retired_pokemon: dict[int, RetiredPokemonResponse] = {}
|
||||
|
||||
|
||||
# --- Graveyard schemas ---
|
||||
|
||||
|
||||
class GraveyardEntryResponse(CamelModel):
|
||||
id: int
|
||||
pokemon: PokemonResponse
|
||||
current_pokemon: PokemonResponse | None = None
|
||||
nickname: str | None = None
|
||||
catch_level: int | None = None
|
||||
faint_level: int | None = None
|
||||
death_cause: str | None = None
|
||||
is_shiny: bool = False
|
||||
route_name: str
|
||||
leg_order: int
|
||||
game_name: str
|
||||
|
||||
|
||||
class GraveyardLegSummary(CamelModel):
|
||||
leg_order: int
|
||||
game_name: str
|
||||
death_count: int
|
||||
|
||||
|
||||
class GenlockeGraveyardResponse(CamelModel):
|
||||
entries: list[GraveyardEntryResponse]
|
||||
total_deaths: int
|
||||
deaths_per_leg: list[GraveyardLegSummary]
|
||||
deadliest_leg: GraveyardLegSummary | None = None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from './client'
|
||||
import type { Genlocke, GenlockeListItem, GenlockeDetail, CreateGenlockeInput, Region } from '../types/game'
|
||||
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, CreateGenlockeInput, Region } from '../types/game'
|
||||
|
||||
export function getGenlockes(): Promise<GenlockeListItem[]> {
|
||||
return api.get('/genlockes')
|
||||
@@ -17,6 +17,10 @@ export function getGamesByRegion(): Promise<Region[]> {
|
||||
return api.get('/games/by-region')
|
||||
}
|
||||
|
||||
export function getGenlockeGraveyard(id: number): Promise<GenlockeGraveyard> {
|
||||
return api.get(`/genlockes/${id}/graveyard`)
|
||||
}
|
||||
|
||||
export function advanceLeg(genlockeId: number, legOrder: number): Promise<Genlocke> {
|
||||
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {})
|
||||
}
|
||||
|
||||
176
frontend/src/components/GenlockeGraveyard.tsx
Normal file
176
frontend/src/components/GenlockeGraveyard.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useGenlockeGraveyard } from '../hooks/useGenlockes'
|
||||
import { TypeBadge } from './TypeBadge'
|
||||
import type { GraveyardEntry } from '../types'
|
||||
|
||||
type SortKey = 'leg' | 'level' | 'species'
|
||||
|
||||
interface GenlockeGraveyardProps {
|
||||
genlockeId: number
|
||||
}
|
||||
|
||||
function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
||||
const displayPokemon = entry.currentPokemon ?? entry.pokemon
|
||||
const isEvolved = entry.currentPokemon !== null
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex flex-col items-center text-center opacity-60 grayscale">
|
||||
{displayPokemon.spriteUrl ? (
|
||||
<img
|
||||
src={displayPokemon.spriteUrl}
|
||||
alt={displayPokemon.name}
|
||||
className="w-25 h-25"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-25 h-25 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">
|
||||
{displayPokemon.name[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full shrink-0 bg-red-500" />
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
||||
{entry.nickname || displayPokemon.name}
|
||||
</span>
|
||||
</div>
|
||||
{entry.nickname && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{displayPokemon.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-0.5 mt-1">
|
||||
{displayPokemon.types.map((type) => (
|
||||
<TypeBadge key={type} type={type} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Lv. {entry.catchLevel} → {entry.faintLevel}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{entry.routeName}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium">
|
||||
Leg {entry.legOrder} — {entry.gameName}
|
||||
</div>
|
||||
|
||||
{isEvolved && (
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Originally: {entry.pokemon.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.deathCause && (
|
||||
<div className="text-[10px] italic text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2">
|
||||
{entry.deathCause}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
||||
const { data, isLoading, error } = useGenlockeGraveyard(genlockeId)
|
||||
const [filterLeg, setFilterLeg] = useState<number | null>(null)
|
||||
const [sortKey, setSortKey] = useState<SortKey>('leg')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
let entries = data.entries
|
||||
if (filterLeg !== null) {
|
||||
entries = entries.filter((e) => e.legOrder === filterLeg)
|
||||
}
|
||||
return [...entries].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'leg':
|
||||
return a.legOrder - b.legOrder || a.id - b.id
|
||||
case 'level':
|
||||
return (b.faintLevel ?? 0) - (a.faintLevel ?? 0)
|
||||
case 'species': {
|
||||
const nameA = (a.currentPokemon ?? a.pokemon).name
|
||||
const nameB = (b.currentPokemon ?? b.pokemon).name
|
||||
return nameA.localeCompare(nameB)
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [data, filterLeg, sortKey])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-4 border-red-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||
Failed to load graveyard data.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.totalDeaths === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
No deaths recorded across any leg.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.totalDeaths} total death{data.totalDeaths !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{data.deadliestLeg && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Deadliest: Leg {data.deadliestLeg.legOrder} — {data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterLeg ?? ''}
|
||||
onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
|
||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All Legs</option>
|
||||
{data.deathsPerLeg.map((leg) => (
|
||||
<option key={leg.legOrder} value={leg.legOrder}>
|
||||
Leg {leg.legOrder} — {leg.gameName} ({leg.deathCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="leg">Sort by Leg</option>
|
||||
<option value="level">Sort by Level</option>
|
||||
<option value="species">Sort by Species</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{filtered.map((entry) => (
|
||||
<GraveyardCard key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||
export { EncounterModal } from './EncounterModal'
|
||||
export { EndRunModal } from './EndRunModal'
|
||||
export { GameCard } from './GameCard'
|
||||
export { GenlockeGraveyard } from './GenlockeGraveyard'
|
||||
export { HofTeamModal } from './HofTeamModal'
|
||||
export { GameGrid } from './GameGrid'
|
||||
export { Layout } from './Layout'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke } from '../api/genlockes'
|
||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard } from '../api/genlockes'
|
||||
import type { CreateGenlockeInput } from '../types/game'
|
||||
|
||||
export function useGenlockes() {
|
||||
@@ -16,6 +16,13 @@ export function useGenlocke(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenlockeGraveyard(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['genlockes', id, 'graveyard'],
|
||||
queryFn: () => getGenlockeGraveyard(id),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRegions() {
|
||||
return useQuery({
|
||||
queryKey: ['games', 'by-region'],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useGenlocke } from '../hooks/useGenlockes'
|
||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||
import { StatCard, RuleBadges } from '../components'
|
||||
import { GenlockeGraveyard, StatCard, RuleBadges } from '../components'
|
||||
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
const statusColors: Record<RunStatus, string> = {
|
||||
completed: 'bg-blue-500',
|
||||
@@ -86,6 +86,8 @@ export function GenlockeDetail() {
|
||||
const { data: genlocke, isLoading, error } = useGenlocke(id)
|
||||
const { data: familiesData } = usePokemonFamilies()
|
||||
|
||||
const [showGraveyard, setShowGraveyard] = useState(false)
|
||||
|
||||
const activeLeg = useMemo(() => {
|
||||
if (!genlocke) return null
|
||||
return genlocke.legs.find((l) => l.runStatus === 'active') ?? null
|
||||
@@ -285,9 +287,12 @@ export function GenlockeDetail() {
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
disabled
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-lg font-medium cursor-not-allowed"
|
||||
title="Coming soon"
|
||||
onClick={() => setShowGraveyard((v) => !v)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
showGraveyard
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Graveyard
|
||||
</button>
|
||||
@@ -300,6 +305,16 @@ export function GenlockeDetail() {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Graveyard */}
|
||||
{showGraveyard && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Cumulative Graveyard
|
||||
</h2>
|
||||
<GenlockeGraveyard genlockeId={id} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,3 +284,32 @@ export interface GenlockeDetail {
|
||||
stats: GenlockeStats
|
||||
retiredPokemon: Record<number, RetiredPokemon>
|
||||
}
|
||||
|
||||
// Graveyard types
|
||||
|
||||
export interface GraveyardEntry {
|
||||
id: number
|
||||
pokemon: Pokemon
|
||||
currentPokemon: Pokemon | null
|
||||
nickname: string | null
|
||||
catchLevel: number | null
|
||||
faintLevel: number | null
|
||||
deathCause: string | null
|
||||
isShiny: boolean
|
||||
routeName: string
|
||||
legOrder: number
|
||||
gameName: string
|
||||
}
|
||||
|
||||
export interface GraveyardLegSummary {
|
||||
legOrder: number
|
||||
gameName: string
|
||||
deathCount: number
|
||||
}
|
||||
|
||||
export interface GenlockeGraveyard {
|
||||
entries: GraveyardEntry[]
|
||||
totalDeaths: number
|
||||
deathsPerLeg: GraveyardLegSummary[]
|
||||
deadliestLeg: GraveyardLegSummary | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user