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 WurfFormPage from './pages/WurfFormPage'
|
||||||
import NotFoundPage from './pages/NotFoundPage'
|
import NotFoundPage from './pages/NotFoundPage'
|
||||||
import StammbaumPage from './pages/StammbaumPage'
|
import StammbaumPage from './pages/StammbaumPage'
|
||||||
|
import StatistikPage from './pages/StatistikPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +51,7 @@ export default function App() {
|
|||||||
<Route path=":id/bearbeiten" element={<KontaktFormPage />} />
|
<Route path=":id/bearbeiten" element={<KontaktFormPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="genetik" element={<GenetikPage />} />
|
<Route path="genetik" element={<GenetikPage />} />
|
||||||
|
<Route path="statistik" element={<StatistikPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const navItems = [
|
|||||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁', end: false },
|
{ to: '/becken', label: de.nav.enclosures, icon: '🛁', end: false },
|
||||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇', end: false },
|
{ to: '/kontakte', label: de.nav.contacts, icon: '📇', end: false },
|
||||||
{ to: '/genetik', label: de.nav.genetics, 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
|
// FEAT-2 (Oscar): Becken + Kontakte
|
||||||
enclosures: 'Becken',
|
enclosures: 'Becken',
|
||||||
contacts: 'Kontakte',
|
contacts: 'Kontakte',
|
||||||
|
// FEAT-7 (Kelly): Statistik
|
||||||
|
statistics: 'Statistik',
|
||||||
openMenu: 'Menü öffnen',
|
openMenu: 'Menü öffnen',
|
||||||
closeMenu: 'Menü schließen',
|
closeMenu: 'Menü schließen',
|
||||||
mainNavigation: 'Hauptnavigation',
|
mainNavigation: 'Hauptnavigation',
|
||||||
@@ -284,6 +286,25 @@ export const de = {
|
|||||||
born: 'geb.',
|
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) ──
|
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
|
||||||
kontakte: {
|
kontakte: {
|
||||||
title: 'Kontakte',
|
title: 'Kontakte',
|
||||||
|
|||||||
Reference in New Issue
Block a user