Files
nuzlocke-tracker/frontend/src/pages/Stats.tsx
Julian Tabel 4fbfcf9b29
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s
Fix WCAG AA color contrast violations across all pages
Replace incorrect perceived-brightness formula in Stats progress bars
with proper WCAG relative luminance calculation, and convert type bar
colors to hex values for reliable contrast detection. Add light: variant
classes to status badges, yellow/purple text, and admin nav links across
17 files. Darken light-mode status-active token and text-tertiary/muted
tokens. Add aria-labels to admin filter selects and flex-wrap for mobile
overflow on AdminEvolutions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 20:48:16 +01:00

307 lines
10 KiB
TypeScript

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: '#9ca3af',
fire: '#ef4444',
water: '#3b82f6',
electric: '#facc15',
grass: '#22c55e',
ice: '#67e8f9',
fighting: '#b91c1c',
poison: '#a855f7',
ground: '#d97706',
flying: '#a5b4fc',
psychic: '#ec4899',
bug: '#84cc16',
rock: '#b45309',
ghost: '#7e22ce',
dragon: '#4f46e5',
dark: '#374151',
steel: '#9ca3af',
fairy: '#f9a8d4',
}
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-text-secondary mb-2">{title}</h3>
{pokemon.length === 0 ? (
<p className="text-sm text-text-tertiary">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-text-muted 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-surface-3 rounded" />
)}
<span className="capitalize text-text-primary">{p.name}</span>
<span className="ml-auto text-text-tertiary font-medium">{p.count}</span>
</div>
))}
</div>
{pokemon.length > 5 && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-2 text-xs text-text-link hover:underline"
>
{expanded ? 'Show less' : `Show all ${pokemon.length}`}
</button>
)}
</>
)}
</div>
)
}
function srgbLuminance(hex: string): number {
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const r = toLinear(parseInt(hex.slice(1, 3), 16) / 255)
const g = toLinear(parseInt(hex.slice(3, 5), 16) / 255)
const b = toLinear(parseInt(hex.slice(5, 7), 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function shouldUseDarkText(bgHex: string): boolean {
const bgL = srgbLuminance(bgHex)
const whiteContrast = 1.05 / (bgL + 0.05)
const blackContrast = (bgL + 0.05) / 0.05
return blackContrast > whiteContrast
}
function HorizontalBar({
label,
value,
max,
colorHex,
}: {
label: string
value: number
max: number
colorHex: string
}) {
const width = max > 0 ? (value / max) * 100 : 0
const useDark = shouldUseDarkText(colorHex)
return (
<div className="flex items-center gap-2 text-sm">
<div className="flex-1 bg-surface-2 rounded-full h-6 overflow-hidden relative">
<div
className="h-full rounded-full"
style={{
width: `${Math.max(width, 1)}%`,
backgroundColor: colorHex,
}}
/>
<span
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
useDark ? 'text-gray-900' : 'text-white'
}`}
style={{
textShadow: useDark ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.5)',
}}
>
{label}
</span>
</div>
<span className="w-8 text-right text-text-secondary font-medium shrink-0">{value}</span>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-surface-1 rounded-lg shadow p-6">
<h2 className="text-lg font-bold text-text-primary 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-text-tertiary">
<span>
Win Rate: <strong className="text-text-primary">{pct(stats.winRate)}</strong>
</span>
<span>
Avg Duration:{' '}
<strong className="text-text-primary">{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 ?? '#3b82f6'}
/>
))}
</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-text-tertiary">
<span>
Catch Rate: <strong className="text-text-primary">{pct(stats.catchRate)}</strong>
</span>
<span>
Avg per Run:{' '}
<strong className="text-text-primary">{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-surface-1 rounded-lg shadow p-4 border-l-4 border-amber-500">
<div className="text-2xl font-bold text-text-primary">{pct(stats.mortalityRate)}</div>
<div className="text-sm text-text-tertiary">Mortality Rate</div>
</div>
<div className="bg-surface-1 rounded-lg shadow p-4 border-l-4 border-blue-500">
<div className="text-2xl font-bold text-text-primary">{fmt(stats.avgCatchLevel)}</div>
<div className="text-sm text-text-tertiary">Avg Catch Lv.</div>
</div>
<div className="bg-surface-1 rounded-lg shadow p-4 border-l-4 border-purple-500">
<div className="text-2xl font-bold text-text-primary">{fmt(stats.avgFaintLevel)}</div>
<div className="text-sm text-text-tertiary">Avg Faint Lv.</div>
</div>
</div>
{stats.topDeathCauses.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-semibold text-text-secondary 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-text-muted w-5 text-right">{i + 1}.</span>
<span className="text-text-primary">{d.cause}</span>
<span className="ml-auto text-text-tertiary 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}
colorHex={typeBarColors[t.type] ?? '#6b7280'}
/>
))}
</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-text-primary 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-status-failed-bg p-4 text-status-failed">
Failed to load stats: {error.message}
</div>
)}
{stats && stats.totalRuns === 0 && (
<div className="text-center py-12 text-text-tertiary">
<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>
)
}