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:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-9ngw
|
# nuzlocke-tracker-9ngw
|
||||||
title: Stats Screen
|
title: Stats Screen
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-07T19:19:40Z
|
created_at: 2026-02-07T19:19:40Z
|
||||||
updated_at: 2026-02-07T19:28:49Z
|
updated_at: 2026-02-07T19:45:55Z
|
||||||
---
|
---
|
||||||
|
|
||||||
A dedicated stats page aggregating data across all runs. Accessible from the main navigation.
|
A dedicated stats page aggregating data across all runs. Accessible from the main navigation.
|
||||||
@@ -56,12 +56,12 @@ Add `GET /api/stats` that runs aggregate queries server-side and returns pre-com
|
|||||||
- Responsive grid layout matching the existing design system (dark mode support)
|
- Responsive grid layout matching the existing design system (dark mode support)
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Add `/stats` route and page component
|
- [x] Add `/stats` route and page component
|
||||||
- [ ] Add "Stats" navigation link
|
- [x] Add "Stats" navigation link
|
||||||
- [ ] Fetch all runs with encounters (or add backend stats endpoint)
|
- [x] Fetch all runs with encounters (or add backend stats endpoint)
|
||||||
- [ ] Run Overview section (counts, win rate, duration)
|
- [x] Run Overview section (counts, win rate, duration)
|
||||||
- [ ] Encounter Stats section (caught/fainted/missed breakdown)
|
- [x] Encounter Stats section (caught/fainted/missed breakdown)
|
||||||
- [ ] Pokemon Rankings section (top caught, top encountered, expandable)
|
- [x] Pokemon Rankings section (top caught, top encountered, expandable)
|
||||||
- [ ] Team & Deaths section (mortality, death causes, type distribution)
|
- [x] Team & Deaths section (mortality, death causes, type distribution)
|
||||||
- [ ] Charts for region/generation/type breakdowns
|
- [x] Charts for region/generation/type breakdowns
|
||||||
- [ ] Responsive layout + dark mode styling
|
- [x] Responsive layout + dark mode styling
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api import encounters, evolutions, games, health, pokemon, runs
|
from app.api import encounters, evolutions, games, health, pokemon, runs, stats
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
@@ -9,3 +9,4 @@ api_router.include_router(pokemon.router, tags=["pokemon"])
|
|||||||
api_router.include_router(evolutions.router, tags=["evolutions"])
|
api_router.include_router(evolutions.router, tags=["evolutions"])
|
||||||
api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
|
api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
|
||||||
api_router.include_router(encounters.router, tags=["encounters"])
|
api_router.include_router(encounters.router, tags=["encounters"])
|
||||||
|
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||||
|
|||||||
208
backend/src/app/api/stats.py
Normal file
208
backend/src/app/api/stats.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.models.encounter import Encounter
|
||||||
|
from app.models.game import Game
|
||||||
|
from app.models.nuzlocke_run import NuzlockeRun
|
||||||
|
from app.models.pokemon import Pokemon
|
||||||
|
from app.schemas.stats import (
|
||||||
|
DeathCause,
|
||||||
|
GameRunCount,
|
||||||
|
PokemonRanking,
|
||||||
|
StatsResponse,
|
||||||
|
TypeCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=StatsResponse)
|
||||||
|
async def get_stats(session: AsyncSession = Depends(get_session)):
|
||||||
|
# --- Run overview ---
|
||||||
|
run_counts = await session.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.count().filter(NuzlockeRun.status == "active").label("active"),
|
||||||
|
func.count().filter(NuzlockeRun.status == "completed").label("completed"),
|
||||||
|
func.count().filter(NuzlockeRun.status == "failed").label("failed"),
|
||||||
|
).select_from(NuzlockeRun)
|
||||||
|
)
|
||||||
|
row = run_counts.one()
|
||||||
|
total_runs = row.total
|
||||||
|
active_runs = row.active
|
||||||
|
completed_runs = row.completed
|
||||||
|
failed_runs = row.failed
|
||||||
|
|
||||||
|
finished = completed_runs + failed_runs
|
||||||
|
win_rate = round(completed_runs / finished, 4) if finished > 0 else None
|
||||||
|
|
||||||
|
# Average duration for finished runs
|
||||||
|
avg_dur = await session.execute(
|
||||||
|
select(
|
||||||
|
func.avg(
|
||||||
|
func.extract("epoch", NuzlockeRun.completed_at)
|
||||||
|
- func.extract("epoch", NuzlockeRun.started_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.select_from(NuzlockeRun)
|
||||||
|
.where(NuzlockeRun.completed_at.is_not(None))
|
||||||
|
)
|
||||||
|
avg_seconds = avg_dur.scalar()
|
||||||
|
avg_duration_days = round(avg_seconds / 86400, 1) if avg_seconds else None
|
||||||
|
|
||||||
|
# --- Runs by game ---
|
||||||
|
runs_by_game_q = await session.execute(
|
||||||
|
select(
|
||||||
|
Game.id,
|
||||||
|
Game.name,
|
||||||
|
Game.color,
|
||||||
|
func.count().label("count"),
|
||||||
|
)
|
||||||
|
.join(NuzlockeRun, NuzlockeRun.game_id == Game.id)
|
||||||
|
.group_by(Game.id, Game.name, Game.color)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
)
|
||||||
|
runs_by_game = [
|
||||||
|
GameRunCount(game_id=r.id, game_name=r.name, game_color=r.color, count=r.count)
|
||||||
|
for r in runs_by_game_q.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Encounter stats ---
|
||||||
|
enc_counts = await session.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.count().filter(Encounter.status == "caught").label("caught"),
|
||||||
|
func.count().filter(Encounter.status == "fainted").label("fainted"),
|
||||||
|
func.count().filter(Encounter.status == "missed").label("missed"),
|
||||||
|
).select_from(Encounter)
|
||||||
|
)
|
||||||
|
enc = enc_counts.one()
|
||||||
|
total_encounters = enc.total
|
||||||
|
caught_count = enc.caught
|
||||||
|
fainted_count = enc.fainted
|
||||||
|
missed_count = enc.missed
|
||||||
|
|
||||||
|
catch_rate = round(caught_count / total_encounters, 4) if total_encounters > 0 else None
|
||||||
|
avg_encounters_per_run = round(total_encounters / total_runs, 1) if total_runs > 0 else None
|
||||||
|
|
||||||
|
# --- Top caught pokemon (top 10) ---
|
||||||
|
top_caught_q = await session.execute(
|
||||||
|
select(
|
||||||
|
Pokemon.id,
|
||||||
|
Pokemon.name,
|
||||||
|
Pokemon.sprite_url,
|
||||||
|
func.count().label("count"),
|
||||||
|
)
|
||||||
|
.join(Encounter, Encounter.pokemon_id == Pokemon.id)
|
||||||
|
.where(Encounter.status == "caught")
|
||||||
|
.group_by(Pokemon.id, Pokemon.name, Pokemon.sprite_url)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
top_caught_pokemon = [
|
||||||
|
PokemonRanking(pokemon_id=r.id, name=r.name, sprite_url=r.sprite_url, count=r.count)
|
||||||
|
for r in top_caught_q.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Top encountered pokemon (top 10) ---
|
||||||
|
top_enc_q = await session.execute(
|
||||||
|
select(
|
||||||
|
Pokemon.id,
|
||||||
|
Pokemon.name,
|
||||||
|
Pokemon.sprite_url,
|
||||||
|
func.count().label("count"),
|
||||||
|
)
|
||||||
|
.join(Encounter, Encounter.pokemon_id == Pokemon.id)
|
||||||
|
.group_by(Pokemon.id, Pokemon.name, Pokemon.sprite_url)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
top_encountered_pokemon = [
|
||||||
|
PokemonRanking(pokemon_id=r.id, name=r.name, sprite_url=r.sprite_url, count=r.count)
|
||||||
|
for r in top_enc_q.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Deaths ---
|
||||||
|
total_deaths_q = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Encounter)
|
||||||
|
.where(Encounter.status == "caught", Encounter.faint_level.is_not(None))
|
||||||
|
)
|
||||||
|
total_deaths = total_deaths_q.scalar() or 0
|
||||||
|
|
||||||
|
mortality_rate = round(total_deaths / caught_count, 4) if caught_count > 0 else None
|
||||||
|
|
||||||
|
# Top death causes
|
||||||
|
death_causes_q = await session.execute(
|
||||||
|
select(
|
||||||
|
Encounter.death_cause,
|
||||||
|
func.count().label("count"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Encounter.death_cause.is_not(None),
|
||||||
|
Encounter.death_cause != "",
|
||||||
|
)
|
||||||
|
.group_by(Encounter.death_cause)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
top_death_causes = [
|
||||||
|
DeathCause(cause=r.death_cause, count=r.count)
|
||||||
|
for r in death_causes_q.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Average levels
|
||||||
|
avg_levels = await session.execute(
|
||||||
|
select(
|
||||||
|
func.avg(Encounter.catch_level).label("avg_catch"),
|
||||||
|
func.avg(Encounter.faint_level).label("avg_faint"),
|
||||||
|
)
|
||||||
|
.select_from(Encounter)
|
||||||
|
.where(Encounter.status == "caught")
|
||||||
|
)
|
||||||
|
levels = avg_levels.one()
|
||||||
|
avg_catch_level = round(float(levels.avg_catch), 1) if levels.avg_catch else None
|
||||||
|
avg_faint_level = round(float(levels.avg_faint), 1) if levels.avg_faint else None
|
||||||
|
|
||||||
|
# --- Type distribution ---
|
||||||
|
# Pokemon types is a PostgreSQL ARRAY, unnest it
|
||||||
|
type_q = await session.execute(
|
||||||
|
select(
|
||||||
|
func.unnest(Pokemon.types).label("type_name"),
|
||||||
|
func.count().label("count"),
|
||||||
|
)
|
||||||
|
.join(Encounter, Encounter.pokemon_id == Pokemon.id)
|
||||||
|
.where(Encounter.status == "caught")
|
||||||
|
.group_by("type_name")
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
)
|
||||||
|
type_distribution = [
|
||||||
|
TypeCount(type=r.type_name, count=r.count)
|
||||||
|
for r in type_q.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return StatsResponse(
|
||||||
|
total_runs=total_runs,
|
||||||
|
active_runs=active_runs,
|
||||||
|
completed_runs=completed_runs,
|
||||||
|
failed_runs=failed_runs,
|
||||||
|
win_rate=win_rate,
|
||||||
|
avg_duration_days=avg_duration_days,
|
||||||
|
runs_by_game=runs_by_game,
|
||||||
|
total_encounters=total_encounters,
|
||||||
|
caught_count=caught_count,
|
||||||
|
fainted_count=fainted_count,
|
||||||
|
missed_count=missed_count,
|
||||||
|
catch_rate=catch_rate,
|
||||||
|
avg_encounters_per_run=avg_encounters_per_run,
|
||||||
|
top_caught_pokemon=top_caught_pokemon,
|
||||||
|
top_encountered_pokemon=top_encountered_pokemon,
|
||||||
|
total_deaths=total_deaths,
|
||||||
|
mortality_rate=mortality_rate,
|
||||||
|
top_death_causes=top_death_causes,
|
||||||
|
avg_catch_level=avg_catch_level,
|
||||||
|
avg_faint_level=avg_faint_level,
|
||||||
|
type_distribution=type_distribution,
|
||||||
|
)
|
||||||
58
backend/src/app/schemas/stats.py
Normal file
58
backend/src/app/schemas/stats.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from app.schemas.base import CamelModel
|
||||||
|
|
||||||
|
|
||||||
|
class GameRunCount(CamelModel):
|
||||||
|
game_id: int
|
||||||
|
game_name: str
|
||||||
|
game_color: str | None
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PokemonRanking(CamelModel):
|
||||||
|
pokemon_id: int
|
||||||
|
name: str
|
||||||
|
sprite_url: str | None
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeathCause(CamelModel):
|
||||||
|
cause: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class TypeCount(CamelModel):
|
||||||
|
type: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class StatsResponse(CamelModel):
|
||||||
|
# Run overview
|
||||||
|
total_runs: int
|
||||||
|
active_runs: int
|
||||||
|
completed_runs: int
|
||||||
|
failed_runs: int
|
||||||
|
win_rate: float | None
|
||||||
|
avg_duration_days: float | None
|
||||||
|
|
||||||
|
# Runs by game
|
||||||
|
runs_by_game: list[GameRunCount]
|
||||||
|
|
||||||
|
# Encounter stats
|
||||||
|
total_encounters: int
|
||||||
|
caught_count: int
|
||||||
|
fainted_count: int
|
||||||
|
missed_count: int
|
||||||
|
catch_rate: float | None
|
||||||
|
avg_encounters_per_run: float | None
|
||||||
|
|
||||||
|
# Pokemon rankings
|
||||||
|
top_caught_pokemon: list[PokemonRanking]
|
||||||
|
top_encountered_pokemon: list[PokemonRanking]
|
||||||
|
|
||||||
|
# Team & deaths
|
||||||
|
total_deaths: int
|
||||||
|
mortality_rate: float | None
|
||||||
|
top_death_causes: list[DeathCause]
|
||||||
|
avg_catch_level: float | None
|
||||||
|
avg_faint_level: float | None
|
||||||
|
type_distribution: list[TypeCount]
|
||||||
@@ -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, NewRun, RunList, RunEncounters } from './pages'
|
import { Home, NewRun, RunList, RunEncounters, Stats } from './pages'
|
||||||
import {
|
import {
|
||||||
AdminGames,
|
AdminGames,
|
||||||
AdminGameDetail,
|
AdminGameDetail,
|
||||||
@@ -18,6 +18,7 @@ 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="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 />}>
|
||||||
<Route index element={<Navigate to="/admin/games" replace />} />
|
<Route index element={<Navigate to="/admin/games" replace />} />
|
||||||
|
|||||||
6
frontend/src/api/stats.ts
Normal file
6
frontend/src/api/stats.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import type { StatsResponse } from '../types/stats'
|
||||||
|
|
||||||
|
export function getStats(): Promise<StatsResponse> {
|
||||||
|
return api.get('/stats')
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
My Runs
|
My Runs
|
||||||
</Link>
|
</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
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
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"
|
||||||
@@ -87,6 +93,13 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
My Runs
|
My Runs
|
||||||
</Link>
|
</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
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
|
|||||||
9
frontend/src/hooks/useStats.ts
Normal file
9
frontend/src/hooks/useStats.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
338
frontend/src/pages/Stats.tsx
Normal file
338
frontend/src/pages/Stats.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export { Home } from './Home'
|
|||||||
export { NewRun } from './NewRun'
|
export { NewRun } from './NewRun'
|
||||||
export { RunList } from './RunList'
|
export { RunList } from './RunList'
|
||||||
export { RunEncounters } from './RunEncounters'
|
export { RunEncounters } from './RunEncounters'
|
||||||
|
export { Stats } from './Stats'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './admin'
|
export * from './admin'
|
||||||
export * from './game'
|
export * from './game'
|
||||||
export * from './rules'
|
export * from './rules'
|
||||||
|
export * from './stats'
|
||||||
|
|||||||
51
frontend/src/types/stats.ts
Normal file
51
frontend/src/types/stats.ts
Normal 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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user