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,11 +1,11 @@
--- ---
# nuzlocke-tracker-x4p6 # nuzlocke-tracker-x4p6
title: Genlocke overview page title: Genlocke overview page
status: todo status: in-progress
type: feature type: feature
priority: normal priority: normal
created_at: 2026-02-09T07:42:19Z created_at: 2026-02-09T07:42:19Z
updated_at: 2026-02-09T09:07:40Z updated_at: 2026-02-09T09:33:02Z
parent: nuzlocke-tracker-25mh parent: nuzlocke-tracker-25mh
blocking: blocking:
- nuzlocke-tracker-lsc2 - nuzlocke-tracker-lsc2

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -10,12 +10,148 @@ from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.schemas.genlocke import GenlockeCreate, GenlockeResponse from app.models.pokemon import Pokemon
from app.schemas.genlocke import (
GenlockeCreate,
GenlockeDetailResponse,
GenlockeLegDetailResponse,
GenlockeListItem,
GenlockeResponse,
GenlockeStatsResponse,
RetiredPokemonResponse,
)
from app.services.families import build_families from app.services.families import build_families
router = APIRouter() router = APIRouter()
@router.get("", response_model=list[GenlockeListItem])
async def list_genlockes(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(Genlocke)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
)
.order_by(Genlocke.created_at.desc())
)
genlockes = result.scalars().all()
items = []
for g in genlockes:
completed_legs = 0
current_leg_order = None
for leg in g.legs:
if leg.run and leg.run.status == "completed":
completed_legs += 1
elif leg.run and leg.run.status == "active":
current_leg_order = leg.leg_order
items.append(
GenlockeListItem(
id=g.id,
name=g.name,
status=g.status,
created_at=g.created_at,
total_legs=len(g.legs),
completed_legs=completed_legs,
current_leg_order=current_leg_order,
)
)
return items
@router.get("/{genlocke_id}", response_model=GenlockeDetailResponse)
async def get_genlocke(
genlocke_id: int, session: AsyncSession = Depends(get_session)
):
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
selectinload(Genlocke.legs).selectinload(GenlockeLeg.run),
)
)
genlocke = result.scalar_one_or_none()
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
# Collect run IDs for aggregate query
run_ids = [leg.run_id for leg in genlocke.legs if leg.run_id is not None]
stats_by_run: dict[int, tuple[int, int]] = {}
if run_ids:
stats_result = await session.execute(
select(
Encounter.run_id,
func.count().label("encounter_count"),
func.count(Encounter.faint_level).label("death_count"),
)
.where(Encounter.run_id.in_(run_ids))
.group_by(Encounter.run_id)
)
for row in stats_result:
stats_by_run[row.run_id] = (row.encounter_count, row.death_count)
legs = []
total_encounters = 0
total_deaths = 0
legs_completed = 0
for leg in genlocke.legs:
run_status = leg.run.status if leg.run else None
enc_count, death_count = stats_by_run.get(leg.run_id, (0, 0)) if leg.run_id else (0, 0)
total_encounters += enc_count
total_deaths += death_count
if run_status == "completed":
legs_completed += 1
legs.append(
GenlockeLegDetailResponse(
id=leg.id,
leg_order=leg.leg_order,
game=leg.game,
run_id=leg.run_id,
run_status=run_status,
encounter_count=enc_count,
death_count=death_count,
retired_pokemon_ids=leg.retired_pokemon_ids,
)
)
# Fetch retired Pokemon data
retired_pokemon: dict[int, RetiredPokemonResponse] = {}
all_retired_ids: set[int] = set()
for leg in genlocke.legs:
if leg.retired_pokemon_ids:
all_retired_ids.update(leg.retired_pokemon_ids)
if all_retired_ids:
pokemon_result = await session.execute(
select(Pokemon).where(Pokemon.id.in_(all_retired_ids))
)
for p in pokemon_result.scalars().all():
retired_pokemon[p.id] = RetiredPokemonResponse(
id=p.id, name=p.name, sprite_url=p.sprite_url
)
return GenlockeDetailResponse(
id=genlocke.id,
name=genlocke.name,
status=genlocke.status,
genlocke_rules=genlocke.genlocke_rules,
nuzlocke_rules=genlocke.nuzlocke_rules,
created_at=genlocke.created_at,
legs=legs,
stats=GenlockeStatsResponse(
total_encounters=total_encounters,
total_deaths=total_deaths,
legs_completed=legs_completed,
total_legs=len(genlocke.legs),
),
retired_pokemon=retired_pokemon,
)
@router.post("", response_model=GenlockeResponse, status_code=201) @router.post("", response_model=GenlockeResponse, status_code=201)
async def create_genlocke( async def create_genlocke(
data: GenlockeCreate, session: AsyncSession = Depends(get_session) data: GenlockeCreate, session: AsyncSession = Depends(get_session)

View File

@@ -17,6 +17,7 @@ class GenlockeLegResponse(CamelModel):
game_id: int game_id: int
run_id: int | None = None run_id: int | None = None
leg_order: int leg_order: int
retired_pokemon_ids: list[int] | None = None
game: GameResponse game: GameResponse
@@ -28,3 +29,52 @@ class GenlockeResponse(CamelModel):
nuzlocke_rules: dict nuzlocke_rules: dict
created_at: datetime created_at: datetime
legs: list[GenlockeLegResponse] = [] legs: list[GenlockeLegResponse] = []
# --- List / Detail schemas ---
class RetiredPokemonResponse(CamelModel):
id: int
name: str
sprite_url: str | None = None
class GenlockeLegDetailResponse(CamelModel):
id: int
leg_order: int
game: GameResponse
run_id: int | None = None
run_status: str | None = None
encounter_count: int = 0
death_count: int = 0
retired_pokemon_ids: list[int] | None = None
class GenlockeStatsResponse(CamelModel):
total_encounters: int
total_deaths: int
legs_completed: int
total_legs: int
class GenlockeListItem(CamelModel):
id: int
name: str
status: str
created_at: datetime
total_legs: int
completed_legs: int
current_leg_order: int | None = None
class GenlockeDetailResponse(CamelModel):
id: int
name: str
status: str
genlocke_rules: dict
nuzlocke_rules: dict
created_at: datetime
legs: list[GenlockeLegDetailResponse] = []
stats: GenlockeStatsResponse
retired_pokemon: dict[int, RetiredPokemonResponse] = {}

View File

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

View File

@@ -1,5 +1,13 @@
import { api } from './client' 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> { export function createGenlocke(data: CreateGenlockeInput): Promise<Genlocke> {
return api.post('/genlockes', data) return api.post('/genlockes', data)

View File

@@ -29,7 +29,7 @@ export function Layout() {
My Runs My Runs
</Link> </Link>
<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" className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
> >
Genlockes Genlockes
@@ -100,7 +100,7 @@ export function Layout() {
My Runs My Runs
</Link> </Link>
<Link <Link
to="/genlockes/new" to="/genlockes"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" 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 { 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' 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() { export function useRegions() {
return useQuery({ return useQuery({
queryKey: ['games', 'by-region'], queryKey: ['games', 'by-region'],
@@ -15,6 +29,7 @@ export function useCreateGenlocke() {
mutationFn: (data: CreateGenlockeInput) => createGenlocke(data), mutationFn: (data: CreateGenlockeInput) => createGenlocke(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] }) queryClient.invalidateQueries({ queryKey: ['runs'] })
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
}, },
}) })
} }
@@ -26,6 +41,7 @@ export function useAdvanceLeg() {
advanceLeg(genlockeId, legOrder), advanceLeg(genlockeId, legOrder),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] }) 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 { Home } from './Home'
export { NewGenlocke } from './NewGenlocke' export { NewGenlocke } from './NewGenlocke'
export { NewRun } from './NewRun' export { NewRun } from './NewRun'

View File

@@ -236,3 +236,51 @@ export interface CreateGenlockeInput {
genlockeRules: GenlockeRules genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules 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>
}