Add stats screen with backend endpoint and frontend page

Implements a dedicated /stats page showing cross-run aggregate statistics:
run overview with win rate, runs by game bar chart, encounter breakdowns,
top caught/encountered pokemon rankings, mortality analysis with death
causes, and type distribution. Backend endpoint uses aggregate SQL queries
to avoid N+1 fetching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 20:46:36 +01:00
parent 78d31f2856
commit fb90410055
12 changed files with 700 additions and 13 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, NewRun, RunList, RunEncounters } from './pages'
import { Home, NewRun, RunList, RunEncounters, Stats } from './pages'
import {
AdminGames,
AdminGameDetail,
@@ -18,6 +18,7 @@ function App() {
<Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunEncounters />} />
<Route path="stats" element={<Stats />} />
<Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} />
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/games" replace />} />

View File

@@ -0,0 +1,6 @@
import { api } from './client'
import type { StatsResponse } from '../types/stats'
export function getStats(): Promise<StatsResponse> {
return api.get('/stats')
}

View File

@@ -28,6 +28,12 @@ export function Layout() {
>
My Runs
</Link>
<Link
to="/stats"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Stats
</Link>
<Link
to="/admin"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -87,6 +93,13 @@ export function Layout() {
>
My Runs
</Link>
<Link
to="/stats"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Stats
</Link>
<Link
to="/admin"
onClick={() => setMenuOpen(false)}

View File

@@ -0,0 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { getStats } from '../api/stats'
export function useStats() {
return useQuery({
queryKey: ['stats'],
queryFn: getStats,
})
}

View File

@@ -0,0 +1,338 @@
import { useState } from 'react'
import { useStats } from '../hooks/useStats'
import { StatCard } from '../components'
import type { PokemonRanking, StatsResponse } from '../types/stats'
const typeBarColors: Record<string, string> = {
normal: 'bg-gray-400',
fire: 'bg-red-500',
water: 'bg-blue-500',
electric: 'bg-yellow-400',
grass: 'bg-green-500',
ice: 'bg-cyan-300',
fighting: 'bg-red-700',
poison: 'bg-purple-500',
ground: 'bg-amber-600',
flying: 'bg-indigo-300',
psychic: 'bg-pink-500',
bug: 'bg-lime-500',
rock: 'bg-amber-700',
ghost: 'bg-purple-700',
dragon: 'bg-indigo-600',
dark: 'bg-gray-700',
steel: 'bg-gray-400',
fairy: 'bg-pink-300',
}
function fmt(value: number | null, suffix = ''): string {
if (value === null) return '—'
return `${value}${suffix}`
}
function pct(value: number | null): string {
if (value === null) return '—'
return `${(value * 100).toFixed(1)}%`
}
function PokemonList({
title,
pokemon,
}: {
title: string
pokemon: PokemonRanking[]
}) {
const [expanded, setExpanded] = useState(false)
const visible = expanded ? pokemon : pokemon.slice(0, 5)
return (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{title}
</h3>
{pokemon.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No data</p>
) : (
<>
<div className="space-y-1.5">
{visible.map((p, i) => (
<div
key={p.pokemonId}
className="flex items-center gap-2 text-sm"
>
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">
{i + 1}.
</span>
{p.spriteUrl ? (
<img
src={p.spriteUrl}
alt={p.name}
className="w-6 h-6"
loading="lazy"
/>
) : (
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded" />
)}
<span className="capitalize text-gray-800 dark:text-gray-200">
{p.name}
</span>
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
{p.count}
</span>
</div>
))}
</div>
{pokemon.length > 5 && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{expanded ? 'Show less' : `Show all ${pokemon.length}`}
</button>
)}
</>
)}
</div>
)
}
function HorizontalBar({
label,
value,
max,
color,
colorHex,
}: {
label: string
value: number
max: number
color?: string
colorHex?: string
}) {
const width = max > 0 ? (value / max) * 100 : 0
return (
<div className="flex items-center gap-2 text-sm">
<span className="w-24 text-right text-gray-600 dark:text-gray-400 capitalize shrink-0 truncate">
{label}
</span>
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-5 overflow-hidden">
<div
className={`h-full rounded-full ${color ?? ''}`}
style={{
width: `${Math.max(width, 1)}%`,
...(colorHex ? { backgroundColor: colorHex } : {}),
}}
/>
</div>
<span className="w-8 text-right text-gray-700 dark:text-gray-300 font-medium shrink-0">
{value}
</span>
</div>
)
}
function Section({
title,
children,
}: {
title: string
children: React.ReactNode
}) {
return (
<section className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h2>
{children}
</section>
)
}
function StatsContent({ stats }: { stats: StatsResponse }) {
const gameMax = Math.max(...stats.runsByGame.map((g) => g.count), 0)
const typeMax = Math.max(...stats.typeDistribution.map((t) => t.count), 0)
return (
<div className="space-y-6">
{/* Run Overview */}
<Section title="Run Overview">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<StatCard label="Total Runs" value={stats.totalRuns} color="blue" />
<StatCard label="Active" value={stats.activeRuns} color="green" />
<StatCard label="Completed" value={stats.completedRuns} color="blue" />
<StatCard label="Failed" value={stats.failedRuns} color="red" />
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<span>
Win Rate: <strong className="text-gray-800 dark:text-gray-200">{pct(stats.winRate)}</strong>
</span>
<span>
Avg Duration: <strong className="text-gray-800 dark:text-gray-200">{fmt(stats.avgDurationDays, ' days')}</strong>
</span>
</div>
</Section>
{/* Runs by Game */}
{stats.runsByGame.length > 0 && (
<Section title="Runs by Game">
<div className="space-y-2">
{stats.runsByGame.map((g) => (
<HorizontalBar
key={g.gameId}
label={g.gameName}
value={g.count}
max={gameMax}
colorHex={g.gameColor ?? undefined}
color={g.gameColor ? undefined : 'bg-blue-500'}
/>
))}
</div>
</Section>
)}
{/* Encounter Stats */}
<Section title="Encounter Stats">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
<StatCard
label="Caught"
value={stats.caughtCount}
total={stats.totalEncounters}
color="green"
/>
<StatCard
label="Fainted"
value={stats.faintedCount}
total={stats.totalEncounters}
color="red"
/>
<StatCard
label="Missed"
value={stats.missedCount}
total={stats.totalEncounters}
color="gray"
/>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<span>
Catch Rate: <strong className="text-gray-800 dark:text-gray-200">{pct(stats.catchRate)}</strong>
</span>
<span>
Avg per Run: <strong className="text-gray-800 dark:text-gray-200">{fmt(stats.avgEncountersPerRun)}</strong>
</span>
</div>
</Section>
{/* Pokemon Rankings */}
<Section title="Pokemon Rankings">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<PokemonList
title="Most Caught"
pokemon={stats.topCaughtPokemon}
/>
<PokemonList
title="Most Encountered"
pokemon={stats.topEncounteredPokemon}
/>
</div>
</Section>
{/* Team & Deaths */}
<Section title="Team & Deaths">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<StatCard label="Total Deaths" value={stats.totalDeaths} color="red" />
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{pct(stats.mortalityRate)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Mortality Rate</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{fmt(stats.avgCatchLevel)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Catch Lv.</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-purple-500">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{fmt(stats.avgFaintLevel)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Faint Lv.</div>
</div>
</div>
{stats.topDeathCauses.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Top Death Causes
</h3>
<div className="space-y-1.5">
{stats.topDeathCauses.map((d, i) => (
<div key={d.cause} className="flex items-center gap-2 text-sm">
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">
{i + 1}.
</span>
<span className="text-gray-800 dark:text-gray-200">
{d.cause}
</span>
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
{d.count}
</span>
</div>
))}
</div>
</div>
)}
</Section>
{/* Type Distribution */}
{stats.typeDistribution.length > 0 && (
<Section title="Type Distribution">
<div className="space-y-2">
{stats.typeDistribution.map((t) => (
<HorizontalBar
key={t.type}
label={t.type}
value={t.count}
max={typeMax}
color={typeBarColors[t.type] ?? 'bg-gray-500'}
/>
))}
</div>
</Section>
)}
</div>
)
}
export function Stats() {
const { data: stats, isLoading, error } = useStats()
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6">
Stats
</h1>
{isLoading && (
<div className="flex 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 stats: {error.message}
</div>
)}
{stats && stats.totalRuns === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<p className="text-lg mb-2">No data yet</p>
<p className="text-sm">Start a Nuzlocke run to see your stats here.</p>
</div>
)}
{stats && stats.totalRuns > 0 && <StatsContent stats={stats} />}
</div>
)
}

View File

@@ -2,3 +2,4 @@ export { Home } from './Home'
export { NewRun } from './NewRun'
export { RunList } from './RunList'
export { RunEncounters } from './RunEncounters'
export { Stats } from './Stats'

View File

@@ -1,3 +1,4 @@
export * from './admin'
export * from './game'
export * from './rules'
export * from './stats'

View File

@@ -0,0 +1,51 @@
export interface GameRunCount {
gameId: number
gameName: string
gameColor: string | null
count: number
}
export interface PokemonRanking {
pokemonId: number
name: string
spriteUrl: string | null
count: number
}
export interface DeathCause {
cause: string
count: number
}
export interface TypeCount {
type: string
count: number
}
export interface StatsResponse {
totalRuns: number
activeRuns: number
completedRuns: number
failedRuns: number
winRate: number | null
avgDurationDays: number | null
runsByGame: GameRunCount[]
totalEncounters: number
caughtCount: number
faintedCount: number
missedCount: number
catchRate: number | null
avgEncountersPerRun: number | null
topCaughtPokemon: PokemonRanking[]
topEncounteredPokemon: PokemonRanking[]
totalDeaths: number
mortalityRate: number | null
topDeathCauses: DeathCause[]
avgCatchLevel: number | null
avgFaintLevel: number | null
typeDistribution: TypeCount[]
}