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:
2026-02-07 20:46:36 +01:00
parent 78d31f2856
commit fb90410055
12 changed files with 700 additions and 13 deletions

View File

@@ -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

View File

@@ -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"])

View 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,
)

View 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]

View File

@@ -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 />} />

View File

@@ -0,0 +1,6 @@
import { api } from './client'
import type { StatsResponse } from '../types/stats'
export function getStats(): Promise<StatsResponse> {
return api.get('/stats')
}

View File

@@ -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)}

View 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,
})
}

View 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>
)
}

View File

@@ -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'

View File

@@ -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'

View 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[]
}