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:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
28
gerbil-manager-web/src/components/BarChart.css
Normal file
28
gerbil-manager-web/src/components/BarChart.css
Normal 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);
|
||||
}
|
||||
79
gerbil-manager-web/src/components/BarChart.tsx
Normal file
79
gerbil-manager-web/src/components/BarChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user