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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
|
|||||||
@@ -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 />}>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
305
frontend/src/pages/GenlockeDetail.tsx
Normal file
305
frontend/src/pages/GenlockeDetail.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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} — {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
frontend/src/pages/GenlockeList.tsx
Normal file
93
frontend/src/pages/GenlockeList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user