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:
2026-06-06 00:41:21 +02:00
parent 1b776cd994
commit 34911fdee1
7 changed files with 419 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import WurfDetailPage from './pages/WurfDetailPage'
import WurfFormPage from './pages/WurfFormPage'
import NotFoundPage from './pages/NotFoundPage'
import StammbaumPage from './pages/StammbaumPage'
import StatistikPage from './pages/StatistikPage'
export default function App() {
return (
@@ -50,6 +51,7 @@ export default function App() {
<Route path=":id/bearbeiten" element={<KontaktFormPage />} />
</Route>
<Route path="genetik" element={<GenetikPage />} />
<Route path="statistik" element={<StatistikPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>

View File

@@ -8,6 +8,9 @@ const navItems = [
{ to: '/becken', label: de.nav.enclosures, icon: '🛁', end: false },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇', end: false },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬', end: false },
// FEAT-7: vorerst 7. Tab; „Mehr“-Overflow für das Smartphone ist mit Kevin
// abgestimmt (FEAT-7-Konversation) und ersetzt diesen Eintrag dann.
{ to: '/statistik', label: de.nav.statistics, icon: '📊', end: false },
]
/**

View File

@@ -0,0 +1,28 @@
/* FEAT-7: Stile des SVG-Balkendiagramms (BarChart.tsx). */
.bar-chart {
width: 100%;
height: auto;
display: block;
}
.bar-chart__axis {
stroke: var(--color-border);
stroke-width: 1;
}
.bar-chart__bar {
fill: var(--color-accent);
opacity: 0.85;
}
.bar-chart__value {
font-size: 12px;
fill: var(--color-text);
font-weight: 600;
}
.bar-chart__label {
font-size: 12px;
fill: var(--color-text-muted);
}

View File

@@ -0,0 +1,79 @@
/**
* FEAT-7: Schlichtes, responsives SVG-Balkendiagramm (eine Serie) —
* Geschwister von LineChart (FEAT-6), gleiche Konventionen: kein
* Chart-Paket, viewBox-skaliert, __axis/__grid/__label-Klassen.
* Eigene CSS-Datei (Standing-Rule-2-Muster: seitennahe Stile kapseln).
*/
import './BarChart.css'
export interface Bar {
label: string
value: number
}
interface Props {
bars: Bar[]
/** Zugänglicher Titel des Diagramms. */
title: string
/** Nachkommastellen der Wertbeschriftung (Standard 0). */
decimals?: number
}
const W = 600
const H = 240
const PAD = { top: 24, right: 12, bottom: 28, left: 12 }
export default function BarChart({ bars, title, decimals = 0 }: Props) {
const innerW = W - PAD.left - PAD.right
const innerH = H - PAD.top - PAD.bottom
const max = Math.max(...bars.map((b) => b.value), 1)
const slot = innerW / bars.length
const barW = Math.min(56, slot * 0.6)
const format = (v: number) =>
v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: decimals })
return (
<svg
className="bar-chart"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={title}
preserveAspectRatio="xMidYMid meet"
>
<line
x1={PAD.left}
y1={H - PAD.bottom}
x2={W - PAD.right}
y2={H - PAD.bottom}
className="bar-chart__axis"
/>
{bars.map((bar, i) => {
const h = (bar.value / max) * innerH
const x = PAD.left + i * slot + (slot - barW) / 2
const y = H - PAD.bottom - h
return (
<g key={bar.label}>
<rect x={x} y={y} width={barW} height={h} rx={3} className="bar-chart__bar" />
<text
x={x + barW / 2}
y={y - 6}
textAnchor="middle"
className="bar-chart__value"
>
{format(bar.value)}
</text>
<text
x={x + barW / 2}
y={H - PAD.bottom + 18}
textAnchor="middle"
className="bar-chart__label"
>
{bar.label}
</text>
</g>
)
})}
</svg>
)
}

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

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

View File

@@ -17,6 +17,8 @@ export const de = {
// FEAT-2 (Oscar): Becken + Kontakte
enclosures: 'Becken',
contacts: 'Kontakte',
// FEAT-7 (Kelly): Statistik
statistics: 'Statistik',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation',
@@ -284,6 +286,25 @@ export const de = {
born: 'geb.',
},
},
// ── FEAT-7 (Kelly): Statistik & Berichte ──
statistik: {
title: 'Statistik & Berichte',
empty:
'Noch keine Daten — lege zuerst Tiere und Würfe an oder importiere deinen Bestand.',
sectionEmpty: 'Noch keine Daten für diese Auswertung.',
littersPerYear: 'Würfe pro Jahr',
avgLitterSize: 'Durchschnittliche Wurfstärke pro Jahr',
avgLitterSizeHint: 'Nur Würfe mit erfasster Wurfstärke.',
farbschlag: 'Farbschlag-Verteilung',
farbschlagHint: 'Nur aktive Tiere.',
withoutFarbschlag: 'ohne Farbschlag',
population: 'Bestandsentwicklung',
populationHint:
'Lebende, nicht abgegebene Tiere im Zeitverlauf. Tiere ohne Geburtsdatum sind nicht enthalten.',
populationUnit: 'Tiere',
losses: 'Verluste pro Jahr',
lossesHint: 'Verstorbene Tiere nach Todesjahr.',
},
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
kontakte: {
title: 'Kontakte',