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:
Julian Tabel
2026-02-09 11:00:37 +01:00
parent d39898a7a1
commit 3bd4250305
9 changed files with 382 additions and 16 deletions

View File

@@ -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`, {})
}

View 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} &rarr; {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} &mdash; {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} &mdash; {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} &mdash; {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>
)
}

View File

@@ -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'

View File

@@ -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'],

View File

@@ -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>
)
}

View File

@@ -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
}