From 34911fdee1861f0cf21194bd4d7c1b5eb179c835 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 00:41:21 +0200 Subject: [PATCH] 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) --- gerbil-manager-web/src/App.tsx | 2 + .../src/components/AppShell.tsx | 3 + .../src/components/BarChart.css | 28 +++ .../src/components/BarChart.tsx | 79 +++++++ .../src/pages/StatistikPage.tsx | 206 ++++++++++++++++++ gerbil-manager-web/src/pages/statistik.css | 80 +++++++ gerbil-manager-web/src/strings/de.ts | 21 ++ 7 files changed, 419 insertions(+) create mode 100644 gerbil-manager-web/src/components/BarChart.css create mode 100644 gerbil-manager-web/src/components/BarChart.tsx create mode 100644 gerbil-manager-web/src/pages/StatistikPage.tsx create mode 100644 gerbil-manager-web/src/pages/statistik.css diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 6af97cb..5243e54 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -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() { } /> } /> + } /> } /> diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 07d54e8..631bd60 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -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 }, ] /** diff --git a/gerbil-manager-web/src/components/BarChart.css b/gerbil-manager-web/src/components/BarChart.css new file mode 100644 index 0000000..7cd198e --- /dev/null +++ b/gerbil-manager-web/src/components/BarChart.css @@ -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); +} diff --git a/gerbil-manager-web/src/components/BarChart.tsx b/gerbil-manager-web/src/components/BarChart.tsx new file mode 100644 index 0000000..8545b5c --- /dev/null +++ b/gerbil-manager-web/src/components/BarChart.tsx @@ -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 ( + + + {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 ( + + + + {format(bar.value)} + + + {bar.label} + + + ) + })} + + ) +} diff --git a/gerbil-manager-web/src/pages/StatistikPage.tsx b/gerbil-manager-web/src/pages/StatistikPage.tsx new file mode 100644 index 0000000..0948021 --- /dev/null +++ b/gerbil-manager-web/src/pages/StatistikPage.tsx @@ -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(fetchPage: (q: GridifyQuery) => Promise>): Promise { + 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 ( +
+

{title}

+ {hint &&

{hint}

} + {children} +
+ ) +} + +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

{de.common.loading}

+ const loadError = gerbils.error ?? litters.error + if (loadError || !stats) { + return ( +
+

{t.title}

+
+ {loadError ?? de.api.errors.unknown} + +
+
+ ) + } + + const noData = gerbils.data!.length === 0 && litters.data!.length === 0 + const maxFarbschlagCount = Math.max(...stats.farbschlag.map((e) => e.count), 1) + + return ( +
+

{t.title}

+ + {noData ? ( +

{t.empty}

+ ) : ( +
+
+ {stats.littersPerYear.length > 0 ? ( + + ) : ( +

{t.sectionEmpty}

+ )} +
+ +
+ {stats.avgLitterSize.length > 0 ? ( + + ) : ( +

{t.sectionEmpty}

+ )} +
+ +
+ {stats.farbschlag.length > 0 ? ( +
    + {stats.farbschlag.map((entry) => { + const name = entry.name ?? t.withoutFarbschlag + const chip = entry.name ? chipColorFor(entry.name) : null + return ( +
  • + + {name} + + + + + {entry.count} +
  • + ) + })} +
+ ) : ( +

{t.sectionEmpty}

+ )} +
+ +
+ {stats.population.length >= 2 ? ( + + ) : ( +

{t.sectionEmpty}

+ )} +
+ +
+ {stats.losses.length > 0 ? ( + + ) : ( +

{t.sectionEmpty}

+ )} +
+
+ )} +
+ ) +} diff --git a/gerbil-manager-web/src/pages/statistik.css b/gerbil-manager-web/src/pages/statistik.css new file mode 100644 index 0000000..4740a21 --- /dev/null +++ b/gerbil-manager-web/src/pages/statistik.css @@ -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; +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 5f4936c..51365a0 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -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',