From fb90410055e23b4a481630c47fc6a1c02d0c4c0c Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 7 Feb 2026 20:46:36 +0100 Subject: [PATCH] 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 --- .beans/nuzlocke-tracker-9ngw--stats-screen.md | 22 +- backend/src/app/api/routes.py | 3 +- backend/src/app/api/stats.py | 208 +++++++++++ backend/src/app/schemas/stats.py | 58 +++ frontend/src/App.tsx | 3 +- frontend/src/api/stats.ts | 6 + frontend/src/components/Layout.tsx | 13 + frontend/src/hooks/useStats.ts | 9 + frontend/src/pages/Stats.tsx | 338 ++++++++++++++++++ frontend/src/pages/index.ts | 1 + frontend/src/types/index.ts | 1 + frontend/src/types/stats.ts | 51 +++ 12 files changed, 700 insertions(+), 13 deletions(-) create mode 100644 backend/src/app/api/stats.py create mode 100644 backend/src/app/schemas/stats.py create mode 100644 frontend/src/api/stats.ts create mode 100644 frontend/src/hooks/useStats.ts create mode 100644 frontend/src/pages/Stats.tsx create mode 100644 frontend/src/types/stats.ts diff --git a/.beans/nuzlocke-tracker-9ngw--stats-screen.md b/.beans/nuzlocke-tracker-9ngw--stats-screen.md index fc7badf..1db2d23 100644 --- a/.beans/nuzlocke-tracker-9ngw--stats-screen.md +++ b/.beans/nuzlocke-tracker-9ngw--stats-screen.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-9ngw title: Stats Screen -status: todo +status: completed type: feature priority: normal 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. @@ -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) ## Checklist -- [ ] Add `/stats` route and page component -- [ ] Add "Stats" navigation link -- [ ] Fetch all runs with encounters (or add backend stats endpoint) -- [ ] Run Overview section (counts, win rate, duration) -- [ ] Encounter Stats section (caught/fainted/missed breakdown) -- [ ] Pokemon Rankings section (top caught, top encountered, expandable) -- [ ] Team & Deaths section (mortality, death causes, type distribution) -- [ ] Charts for region/generation/type breakdowns -- [ ] Responsive layout + dark mode styling \ No newline at end of file +- [x] Add `/stats` route and page component +- [x] Add "Stats" navigation link +- [x] Fetch all runs with encounters (or add backend stats endpoint) +- [x] Run Overview section (counts, win rate, duration) +- [x] Encounter Stats section (caught/fainted/missed breakdown) +- [x] Pokemon Rankings section (top caught, top encountered, expandable) +- [x] Team & Deaths section (mortality, death causes, type distribution) +- [x] Charts for region/generation/type breakdowns +- [x] Responsive layout + dark mode styling \ No newline at end of file diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index 5af37fb..1ee1034 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -1,6 +1,6 @@ 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.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(runs.router, prefix="/runs", tags=["runs"]) api_router.include_router(encounters.router, tags=["encounters"]) +api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) diff --git a/backend/src/app/api/stats.py b/backend/src/app/api/stats.py new file mode 100644 index 0000000..3688625 --- /dev/null +++ b/backend/src/app/api/stats.py @@ -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, + ) diff --git a/backend/src/app/schemas/stats.py b/backend/src/app/schemas/stats.py new file mode 100644 index 0000000..dc18f3c --- /dev/null +++ b/backend/src/app/schemas/stats.py @@ -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] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index affa059..b1fe61e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> }> } /> diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts new file mode 100644 index 0000000..e6aabe3 --- /dev/null +++ b/frontend/src/api/stats.ts @@ -0,0 +1,6 @@ +import { api } from './client' +import type { StatsResponse } from '../types/stats' + +export function getStats(): Promise { + return api.get('/stats') +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3898999..200a7ce 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -28,6 +28,12 @@ export function Layout() { > My Runs + + Stats + My Runs + setMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700" + > + Stats + setMenuOpen(false)} diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts new file mode 100644 index 0000000..b3b3d66 --- /dev/null +++ b/frontend/src/hooks/useStats.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query' +import { getStats } from '../api/stats' + +export function useStats() { + return useQuery({ + queryKey: ['stats'], + queryFn: getStats, + }) +} diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx new file mode 100644 index 0000000..1178d93 --- /dev/null +++ b/frontend/src/pages/Stats.tsx @@ -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 = { + 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 ( +
+

+ {title} +

+ {pokemon.length === 0 ? ( +

No data

+ ) : ( + <> +
+ {visible.map((p, i) => ( +
+ + {i + 1}. + + {p.spriteUrl ? ( + {p.name} + ) : ( +
+ )} + + {p.name} + + + {p.count} + +
+ ))} +
+ {pokemon.length > 5 && ( + + )} + + )} +
+ ) +} + +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 ( +
+ + {label} + +
+
+
+ + {value} + +
+ ) +} + +function Section({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +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 ( +
+ {/* Run Overview */} +
+
+ + + + +
+
+ + Win Rate: {pct(stats.winRate)} + + + Avg Duration: {fmt(stats.avgDurationDays, ' days')} + +
+
+ + {/* Runs by Game */} + {stats.runsByGame.length > 0 && ( +
+
+ {stats.runsByGame.map((g) => ( + + ))} +
+
+ )} + + {/* Encounter Stats */} +
+
+ + + +
+
+ + Catch Rate: {pct(stats.catchRate)} + + + Avg per Run: {fmt(stats.avgEncountersPerRun)} + +
+
+ + {/* Pokemon Rankings */} +
+
+ + +
+
+ + {/* Team & Deaths */} +
+
+ +
+
+ {pct(stats.mortalityRate)} +
+
Mortality Rate
+
+
+
+ {fmt(stats.avgCatchLevel)} +
+
Avg Catch Lv.
+
+
+
+ {fmt(stats.avgFaintLevel)} +
+
Avg Faint Lv.
+
+
+ + {stats.topDeathCauses.length > 0 && ( +
+

+ Top Death Causes +

+
+ {stats.topDeathCauses.map((d, i) => ( +
+ + {i + 1}. + + + {d.cause} + + + {d.count} + +
+ ))} +
+
+ )} +
+ + {/* Type Distribution */} + {stats.typeDistribution.length > 0 && ( +
+
+ {stats.typeDistribution.map((t) => ( + + ))} +
+
+ )} +
+ ) +} + +export function Stats() { + const { data: stats, isLoading, error } = useStats() + + return ( +
+

+ Stats +

+ + {isLoading && ( +
+
+
+ )} + + {error && ( +
+ Failed to load stats: {error.message} +
+ )} + + {stats && stats.totalRuns === 0 && ( +
+

No data yet

+

Start a Nuzlocke run to see your stats here.

+
+ )} + + {stats && stats.totalRuns > 0 && } +
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 67b20d8..cbf12be 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -2,3 +2,4 @@ export { Home } from './Home' export { NewRun } from './NewRun' export { RunList } from './RunList' export { RunEncounters } from './RunEncounters' +export { Stats } from './Stats' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 14fc022..1a52dc1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,3 +1,4 @@ export * from './admin' export * from './game' export * from './rules' +export * from './stats' diff --git a/frontend/src/types/stats.ts b/frontend/src/types/stats.ts new file mode 100644 index 0000000..c4b7fd1 --- /dev/null +++ b/frontend/src/types/stats.ts @@ -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[] +}