2026-02-07 20:46:36 +01:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 22:00:11 +01:00
|
|
|
function hexLuminance(hex: string): number {
|
|
|
|
|
const r = parseInt(hex.slice(1, 3), 16)
|
|
|
|
|
const g = parseInt(hex.slice(3, 5), 16)
|
|
|
|
|
const b = parseInt(hex.slice(5, 7), 16)
|
|
|
|
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 20:46:36 +01:00
|
|
|
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
|
2026-02-07 22:00:11 +01:00
|
|
|
const isLight = colorHex ? hexLuminance(colorHex) > 0.55 : false
|
2026-02-07 20:46:36 +01:00
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm">
|
2026-02-07 22:00:11 +01:00
|
|
|
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-6 overflow-hidden relative">
|
2026-02-07 20:46:36 +01:00
|
|
|
<div
|
|
|
|
|
className={`h-full rounded-full ${color ?? ''}`}
|
|
|
|
|
style={{
|
|
|
|
|
width: `${Math.max(width, 1)}%`,
|
|
|
|
|
...(colorHex ? { backgroundColor: colorHex } : {}),
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-07 22:00:11 +01:00
|
|
|
<span
|
|
|
|
|
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
|
|
|
|
|
isLight
|
|
|
|
|
? 'text-gray-900 dark:text-gray-900'
|
|
|
|
|
: 'text-gray-700 dark:text-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
style={{
|
|
|
|
|
textShadow: isLight
|
|
|
|
|
? '0 0 4px rgba(255,255,255,0.8)'
|
|
|
|
|
: '0 0 4px rgba(0,0,0,0.3)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
2026-02-07 20:46:36 +01:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|