Add genlocke list and detail pages

Add GET /genlockes and GET /genlockes/{id} endpoints with aggregate
encounter/death stats per leg, and a frontend list page at /genlockes
plus a detail page at /genlockes/:genlockeId showing progress timeline,
cumulative stats, configuration, retired families, and quick actions.
Update nav link to point to the list page instead of /genlockes/new.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 10:39:59 +01:00
parent c7c66c76d3
commit 08f6857451
11 changed files with 669 additions and 9 deletions

View File

@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
import { AdminLayout } from './components/admin'
import { Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages'
import { GenlockeDetail, GenlockeList, Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages'
import {
AdminGames,
AdminGameDetail,
@@ -19,7 +19,9 @@ function App() {
<Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunEncounters />} />
<Route path="genlockes" element={<GenlockeList />} />
<Route path="genlockes/new" element={<NewGenlocke />} />
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
<Route path="stats" element={<Stats />} />
<Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} />
<Route path="admin" element={<AdminLayout />}>

View File

@@ -1,5 +1,13 @@
import { api } from './client'
import type { Genlocke, CreateGenlockeInput, Region } from '../types/game'
import type { Genlocke, GenlockeListItem, GenlockeDetail, CreateGenlockeInput, Region } from '../types/game'
export function getGenlockes(): Promise<GenlockeListItem[]> {
return api.get('/genlockes')
}
export function getGenlocke(id: number): Promise<GenlockeDetail> {
return api.get(`/genlockes/${id}`)
}
export function createGenlocke(data: CreateGenlockeInput): Promise<Genlocke> {
return api.post('/genlockes', data)

View File

@@ -29,7 +29,7 @@ export function Layout() {
My Runs
</Link>
<Link
to="/genlockes/new"
to="/genlockes"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Genlockes
@@ -100,7 +100,7 @@ export function Layout() {
My Runs
</Link>
<Link
to="/genlockes/new"
to="/genlockes"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>

View File

@@ -1,7 +1,21 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { advanceLeg, createGenlocke, getGamesByRegion } from '../api/genlockes'
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke } from '../api/genlockes'
import type { CreateGenlockeInput } from '../types/game'
export function useGenlockes() {
return useQuery({
queryKey: ['genlockes'],
queryFn: getGenlockes,
})
}
export function useGenlocke(id: number) {
return useQuery({
queryKey: ['genlockes', id],
queryFn: () => getGenlocke(id),
})
}
export function useRegions() {
return useQuery({
queryKey: ['games', 'by-region'],
@@ -15,6 +29,7 @@ export function useCreateGenlocke() {
mutationFn: (data: CreateGenlockeInput) => createGenlocke(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
},
})
}
@@ -26,6 +41,7 @@ export function useAdvanceLeg() {
advanceLeg(genlockeId, legOrder),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
},
})
}

View File

@@ -0,0 +1,305 @@
import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes'
import { usePokemonFamilies } from '../hooks/usePokemon'
import { StatCard, RuleBadges } from '../components'
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo } from 'react'
const statusColors: Record<RunStatus, string> = {
completed: 'bg-blue-500',
active: 'bg-green-500',
failed: 'bg-red-500',
}
const statusRing: Record<RunStatus, string> = {
completed: 'ring-blue-500',
active: 'ring-green-500 animate-pulse',
failed: 'ring-red-500',
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}
function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
const hasRun = leg.runId !== null
const status = leg.runStatus as RunStatus | null
const dot = status ? (
<div className={`w-4 h-4 rounded-full ${statusColors[status]} ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900 ${statusRing[status]}`} />
) : (
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
)
const content = (
<div className="flex flex-col items-center gap-1 min-w-[80px]">
{dot}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 text-center leading-tight">
{leg.game.name}
</span>
{status && (
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">
{status}
</span>
)}
</div>
)
if (hasRun) {
return (
<Link to={`/runs/${leg.runId}`} className="hover:opacity-80 transition-opacity">
{content}
</Link>
)
}
return content
}
function PokemonSprite({ pokemon }: { pokemon: RetiredPokemon }) {
if (pokemon.spriteUrl) {
return (
<img
src={pokemon.spriteUrl}
alt={pokemon.name}
title={pokemon.name}
className="w-10 h-10"
loading="lazy"
/>
)
}
return (
<div
className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold"
title={pokemon.name}
>
{pokemon.name[0].toUpperCase()}
</div>
)
}
export function GenlockeDetail() {
const { genlockeId } = useParams<{ genlockeId: string }>()
const id = Number(genlockeId)
const { data: genlocke, isLoading, error } = useGenlocke(id)
const { data: familiesData } = usePokemonFamilies()
const activeLeg = useMemo(() => {
if (!genlocke) return null
return genlocke.legs.find((l) => l.runStatus === 'active') ?? null
}, [genlocke])
// Group retired Pokemon by leg, showing only the "base" Pokemon per family
const retiredByLeg = useMemo(() => {
if (!genlocke || !familiesData) return []
const familyMap = new Map<number, number[]>()
for (const family of familiesData.families) {
for (const id of family) {
familyMap.set(id, family)
}
}
return genlocke.legs
.filter((leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0)
.map((leg) => {
// Find base Pokemon (lowest ID) for each family in this leg's retired list
const seen = new Set<string>()
const bases: number[] = []
for (const pid of leg.retiredPokemonIds!) {
const family = familyMap.get(pid)
const key = family ? family.join(',') : String(pid)
if (!seen.has(key)) {
seen.add(key)
bases.push(family ? Math.min(...family) : pid)
}
}
return { legOrder: leg.legOrder, gameName: leg.game.name, pokemonIds: bases.sort((a, b) => a - b) }
})
}, [genlocke, familiesData])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !genlocke) {
return (
<div className="max-w-4xl mx-auto p-8">
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load genlocke. Please try again.
</div>
</div>
)
}
const survivalRate =
genlocke.stats.totalEncounters > 0
? Math.round(
((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) /
genlocke.stats.totalEncounters) *
100
)
: 0
return (
<div className="max-w-4xl mx-auto p-8 space-y-8">
{/* Header */}
<div>
<Link
to="/genlockes"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
&larr; Back to Genlockes
</Link>
<div className="flex items-center gap-3 mt-2">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{genlocke.name}
</h1>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[genlocke.status]}`}
>
{genlocke.status}
</span>
</div>
</div>
{/* Progress Timeline */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Progress
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-start gap-2 overflow-x-auto pb-2">
{genlocke.legs.map((leg, i) => (
<div key={leg.id} className="flex items-center">
<LegIndicator leg={leg} />
{i < genlocke.legs.length - 1 && (
<div
className={`h-0.5 w-6 mx-1 mt-[-16px] ${
leg.runStatus === 'completed'
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}
</div>
))}
</div>
</div>
</section>
{/* Cumulative Stats */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Cumulative Stats
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Encounters" value={genlocke.stats.totalEncounters} color="blue" />
<StatCard label="Deaths" value={genlocke.stats.totalDeaths} color="red" />
<StatCard
label="Legs Completed"
value={genlocke.stats.legsCompleted}
total={genlocke.stats.totalLegs}
color="green"
/>
<StatCard label="Survival Rate" value={survivalRate} color="purple" />
</div>
</section>
{/* Configuration */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Configuration
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Genlocke Rules
</h3>
<div className="flex flex-wrap gap-1.5">
{genlocke.genlockeRules.retireHoF ? (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
Retire HoF Teams
</span>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">
No genlocke-specific rules enabled
</span>
)}
</div>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Nuzlocke Rules
</h3>
<RuleBadges rules={genlocke.nuzlockeRules} />
</div>
</div>
</section>
{/* Retired Families */}
{genlocke.genlockeRules.retireHoF && retiredByLeg.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Retired Families
</h2>
<div className="space-y-3">
{retiredByLeg.map((leg) => (
<div
key={leg.legOrder}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Leg {leg.legOrder} &mdash; {leg.gameName}
</h3>
<div className="flex flex-wrap gap-1">
{leg.pokemonIds.map((pid) => {
const pokemon = genlocke.retiredPokemon[pid]
if (!pokemon) return null
return <PokemonSprite key={pid} pokemon={pokemon} />
})}
</div>
</div>
))}
</div>
</section>
)}
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Quick Actions
</h2>
<div className="flex flex-wrap gap-3">
{activeLeg && (
<Link
to={`/runs/${activeLeg.runId}`}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Go to Active Leg (Leg {activeLeg.legOrder})
</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"
>
Graveyard
</button>
<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"
>
Lineage
</button>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { Link } from 'react-router-dom'
import { useGenlockes } from '../hooks/useGenlockes'
import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}
export function GenlockeList() {
const { data: genlockes, isLoading, error } = useGenlockes()
return (
<div className="max-w-4xl mx-auto p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Your Genlockes
</h1>
<Link
to="/genlockes/new"
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Start New Genlocke
</Link>
</div>
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load genlockes. Please try again.
</div>
)}
{genlockes && genlockes.length === 0 && (
<div className="text-center py-16">
<p className="text-lg text-gray-500 dark:text-gray-400 mb-4">
No genlockes yet. Start your first Generation Locke!
</p>
<Link
to="/genlockes/new"
className="inline-block px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Start New Genlocke
</Link>
</div>
)}
{genlockes && genlockes.length > 0 && (
<div className="space-y-3">
{genlockes.map((g) => (
<Link
key={g.id}
to={`/genlockes/${g.id}`}
className="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow p-4"
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{g.name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{g.currentLegOrder !== null
? `Leg ${g.currentLegOrder} / ${g.totalLegs}`
: `${g.completedLegs} / ${g.totalLegs} legs completed`}
{' \u00b7 '}
Started{' '}
{new Date(g.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[g.status]}`}
>
{g.status}
</span>
</div>
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -1,3 +1,5 @@
export { GenlockeDetail } from './GenlockeDetail'
export { GenlockeList } from './GenlockeList'
export { Home } from './Home'
export { NewGenlocke } from './NewGenlocke'
export { NewRun } from './NewRun'

View File

@@ -236,3 +236,51 @@ export interface CreateGenlockeInput {
genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules
}
// Genlocke list / detail types
export interface GenlockeLegDetail {
id: number
legOrder: number
game: Game
runId: number | null
runStatus: RunStatus | null
encounterCount: number
deathCount: number
retiredPokemonIds: number[] | null
}
export interface GenlockeStats {
totalEncounters: number
totalDeaths: number
legsCompleted: number
totalLegs: number
}
export interface GenlockeListItem {
id: number
name: string
status: 'active' | 'completed' | 'failed'
createdAt: string
totalLegs: number
completedLegs: number
currentLegOrder: number | null
}
export interface RetiredPokemon {
id: number
name: string
spriteUrl: string | null
}
export interface GenlockeDetail {
id: number
name: string
status: 'active' | 'completed' | 'failed'
genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules
createdAt: string
legs: GenlockeLegDetail[]
stats: GenlockeStats
retiredPokemon: Record<number, RetiredPokemon>
}