diff --git a/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md b/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md index 29b9687..527e174 100644 --- a/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md +++ b/.beans/nuzlocke-tracker-lsdy--genlocke-cumulative-graveyard.md @@ -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 \ No newline at end of file +- [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 \ No newline at end of file diff --git a/backend/src/app/api/genlockes.py b/backend/src/app/api/genlockes.py index d35c1a6..72ed25b 100644 --- a/backend/src/app/api/genlockes.py +++ b/backend/src/app/api/genlockes.py @@ -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) diff --git a/backend/src/app/schemas/genlocke.py b/backend/src/app/schemas/genlocke.py index 18ef69e..305e46c 100644 --- a/backend/src/app/schemas/genlocke.py +++ b/backend/src/app/schemas/genlocke.py @@ -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 diff --git a/frontend/src/api/genlockes.ts b/frontend/src/api/genlockes.ts index 5b481bc..e1d97e1 100644 --- a/frontend/src/api/genlockes.ts +++ b/frontend/src/api/genlockes.ts @@ -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 { return api.get('/genlockes') @@ -17,6 +17,10 @@ export function getGamesByRegion(): Promise { return api.get('/games/by-region') } +export function getGenlockeGraveyard(id: number): Promise { + return api.get(`/genlockes/${id}/graveyard`) +} + export function advanceLeg(genlockeId: number, legOrder: number): Promise { return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, {}) } diff --git a/frontend/src/components/GenlockeGraveyard.tsx b/frontend/src/components/GenlockeGraveyard.tsx new file mode 100644 index 0000000..f189485 --- /dev/null +++ b/frontend/src/components/GenlockeGraveyard.tsx @@ -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 ( +
+ {displayPokemon.spriteUrl ? ( + {displayPokemon.name} + ) : ( +
+ {displayPokemon.name[0].toUpperCase()} +
+ )} + +
+ + + {entry.nickname || displayPokemon.name} + +
+ {entry.nickname && ( +
+ {displayPokemon.name} +
+ )} + +
+ {displayPokemon.types.map((type) => ( + + ))} +
+ +
+ Lv. {entry.catchLevel} → {entry.faintLevel} +
+ +
+ {entry.routeName} +
+ +
+ Leg {entry.legOrder} — {entry.gameName} +
+ + {isEvolved && ( +
+ Originally: {entry.pokemon.name} +
+ )} + + {entry.deathCause && ( +
+ {entry.deathCause} +
+ )} +
+ ) +} + +export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) { + const { data, isLoading, error } = useGenlockeGraveyard(genlockeId) + const [filterLeg, setFilterLeg] = useState(null) + const [sortKey, setSortKey] = useState('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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+ Failed to load graveyard data. +
+ ) + } + + if (!data || data.totalDeaths === 0) { + return ( +
+ No deaths recorded across any leg. +
+ ) + } + + return ( +
+ {/* Summary bar */} +
+ + {data.totalDeaths} total death{data.totalDeaths !== 1 ? 's' : ''} + + {data.deadliestLeg && ( + + Deadliest: Leg {data.deadliestLeg.legOrder} — {data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount}) + + )} +
+ + {/* Controls */} +
+ + + +
+ + {/* Grid */} +
+ {filtered.map((entry) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 41c66bb..72c65f2 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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' diff --git a/frontend/src/hooks/useGenlockes.ts b/frontend/src/hooks/useGenlockes.ts index 42352f6..d44578b 100644 --- a/frontend/src/hooks/useGenlockes.ts +++ b/frontend/src/hooks/useGenlockes.ts @@ -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'], diff --git a/frontend/src/pages/GenlockeDetail.tsx b/frontend/src/pages/GenlockeDetail.tsx index 4931cc7..a4b9b36 100644 --- a/frontend/src/pages/GenlockeDetail.tsx +++ b/frontend/src/pages/GenlockeDetail.tsx @@ -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 = { 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() { )} @@ -300,6 +305,16 @@ export function GenlockeDetail() {
+ + {/* Graveyard */} + {showGraveyard && ( +
+

+ Cumulative Graveyard +

+ +
+ )} ) } diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index d8df373..29f72e7 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -284,3 +284,32 @@ export interface GenlockeDetail { stats: GenlockeStats retiredPokemon: Record } + +// 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 +}