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>
307 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|