FEAT-7: Statistik page - Wuerfe/Jahr + avg Wurfstaerke bars, Farbschlag h-bars (FEAT-4 chip colors), Bestandsentwicklung line, Verluste/Jahr + /statistik route & nav entry (Mehr overflow follows via Kevin)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
206
gerbil-manager-web/src/pages/StatistikPage.tsx
Normal file
206
gerbil-manager-web/src/pages/StatistikPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* FEAT-7: Statistik & Berichte (/statistik).
|
||||
*
|
||||
* Lädt Tiere + Würfe + Farbschläge komplett (kleine Bestände, Gridify-paged,
|
||||
* notfalls mehrseitig) und wertet clientseitig über die reinen Funktionen in
|
||||
* src/statistics aus. Diagramme: BarChart (FEAT-7), LineChart (FEAT-6,
|
||||
* unverändert wiederverwendet), div-basierte horizontale Balken für die
|
||||
* Farbschlag-Verteilung (eingefärbt über die FEAT-4-Chipfarben).
|
||||
*/
|
||||
import { useCallback, useMemo, type ReactNode } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import type { GridifyQuery } from '../api/gridify'
|
||||
import type { Gerbil, Paged } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||
import {
|
||||
avgLitterSizePerYear,
|
||||
farbschlagDistribution,
|
||||
littersPerYear,
|
||||
lossesPerYear,
|
||||
populationOverTime,
|
||||
type YearValue,
|
||||
} from '../statistics/aggregate'
|
||||
import BarChart from '../components/BarChart'
|
||||
import LineChart from '../components/LineChart'
|
||||
import { chipColorFor } from '../pedigree/chipColors'
|
||||
import './statistik.css'
|
||||
|
||||
/** Alle Seiten eines Gridify-Listen-Endpunkts einsammeln (kleine Bestände). */
|
||||
async function allPages<T>(fetchPage: (q: GridifyQuery) => Promise<Paged<T>>): Promise<T[]> {
|
||||
const first = await fetchPage({ page: 1, pageSize: 1000 })
|
||||
const items = [...first.items]
|
||||
let page = 2
|
||||
while (items.length < first.totalCount && page <= 10) {
|
||||
const next = await fetchPage({ page, pageSize: 1000 })
|
||||
if (next.items.length === 0) break
|
||||
items.push(...next.items)
|
||||
page++
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const toBars = (rows: YearValue[]) =>
|
||||
rows.map((r) => ({ label: String(r.year), value: r.value }))
|
||||
|
||||
function Section({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="statistik-card">
|
||||
<h3>{title}</h3>
|
||||
{hint && <p className="statistik-card__hint">{hint}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatistikPage() {
|
||||
const t = de.pages.statistik
|
||||
|
||||
const gerbils = useApi(() => allPages((q) => listGerbils(q)), [])
|
||||
const litters = useApi(() => allPages((q) => listLitters(q)), [])
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
|
||||
const colorNameById = useMemo(
|
||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||
[colorVarieties.data],
|
||||
)
|
||||
const farbschlagOf = useCallback(
|
||||
(g: Gerbil): string | null => {
|
||||
const byId = g.colorVarietyId ? colorNameById.get(g.colorVarietyId) : undefined
|
||||
if (byId) return byId
|
||||
if (g.genotype && g.genotype.trim()) {
|
||||
try {
|
||||
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
|
||||
return name === UNKNOWN_FARBSCHLAG ? null : name
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[colorNameById],
|
||||
)
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10)
|
||||
const stats = useMemo(() => {
|
||||
if (!gerbils.data || !litters.data) return null
|
||||
return {
|
||||
littersPerYear: littersPerYear(litters.data),
|
||||
avgLitterSize: avgLitterSizePerYear(litters.data),
|
||||
farbschlag: farbschlagDistribution(gerbils.data, farbschlagOf),
|
||||
population: populationOverTime(gerbils.data, todayIso),
|
||||
losses: lossesPerYear(gerbils.data),
|
||||
}
|
||||
}, [gerbils.data, litters.data, farbschlagOf, todayIso])
|
||||
|
||||
if (gerbils.loading || litters.loading) return <p className="muted">{de.common.loading}</p>
|
||||
const loadError = gerbils.error ?? litters.error
|
||||
if (loadError || !stats) {
|
||||
return (
|
||||
<section className="page">
|
||||
<h2>{t.title}</h2>
|
||||
<div className="alert alert--error">
|
||||
<span>{loadError ?? de.api.errors.unknown}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
gerbils.reload()
|
||||
litters.reload()
|
||||
}}
|
||||
>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const noData = gerbils.data!.length === 0 && litters.data!.length === 0
|
||||
const maxFarbschlagCount = Math.max(...stats.farbschlag.map((e) => e.count), 1)
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<h2>{t.title}</h2>
|
||||
|
||||
{noData ? (
|
||||
<p className="muted">{t.empty}</p>
|
||||
) : (
|
||||
<div className="statistik-grid">
|
||||
<Section title={t.littersPerYear}>
|
||||
{stats.littersPerYear.length > 0 ? (
|
||||
<BarChart bars={toBars(stats.littersPerYear)} title={t.littersPerYear} />
|
||||
) : (
|
||||
<p className="muted">{t.sectionEmpty}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t.avgLitterSize} hint={t.avgLitterSizeHint}>
|
||||
{stats.avgLitterSize.length > 0 ? (
|
||||
<BarChart bars={toBars(stats.avgLitterSize)} title={t.avgLitterSize} decimals={1} />
|
||||
) : (
|
||||
<p className="muted">{t.sectionEmpty}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t.farbschlag} hint={t.farbschlagHint}>
|
||||
{stats.farbschlag.length > 0 ? (
|
||||
<ul className="hbar-list">
|
||||
{stats.farbschlag.map((entry) => {
|
||||
const name = entry.name ?? t.withoutFarbschlag
|
||||
const chip = entry.name ? chipColorFor(entry.name) : null
|
||||
return (
|
||||
<li key={name} className="hbar">
|
||||
<span className="hbar__label" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<span className="hbar__track">
|
||||
<span
|
||||
className="hbar__fill"
|
||||
style={{
|
||||
width: `${(entry.count / maxFarbschlagCount) * 100}%`,
|
||||
background: chip?.bg,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="hbar__count">{entry.count}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">{t.sectionEmpty}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t.population} hint={t.populationHint}>
|
||||
{stats.population.length >= 2 ? (
|
||||
<LineChart points={stats.population} unit={t.populationUnit} title={t.population} />
|
||||
) : (
|
||||
<p className="muted">{t.sectionEmpty}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t.losses} hint={t.lossesHint}>
|
||||
{stats.losses.length > 0 ? (
|
||||
<BarChart bars={toBars(stats.losses)} title={t.losses} />
|
||||
) : (
|
||||
<p className="muted">{t.sectionEmpty}</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
80
gerbil-manager-web/src/pages/statistik.css
Normal file
80
gerbil-manager-web/src/pages/statistik.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* FEAT-7: Statistik & Berichte — seitenspezifische Stile
|
||||
(Standing-Rule-2-Muster: eigene Datei statt index.css). */
|
||||
|
||||
.statistik-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.statistik-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.statistik-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.statistik-card h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.statistik-card__hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Horizontale Balken (Farbschlag-Verteilung) ── */
|
||||
|
||||
.hbar-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.hbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 10rem) 1fr 2.25rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.hbar__label {
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hbar__track {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
height: 0.85rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hbar__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-accent);
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.hbar__count {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
Reference in New Issue
Block a user