diff --git a/gerbil-manager-web/src/statistics/__tests__/aggregate.test.ts b/gerbil-manager-web/src/statistics/__tests__/aggregate.test.ts new file mode 100644 index 0000000..be32634 --- /dev/null +++ b/gerbil-manager-web/src/statistics/__tests__/aggregate.test.ts @@ -0,0 +1,162 @@ +/** FEAT-7: Tests der Statistik-Aggregationen (reine Daten, kein DOM). */ +import { describe, expect, it } from 'vitest' +import type { Gerbil, GerbilStatus, Litter } from '../../api/types' +import { + avgLitterSizePerYear, + farbschlagDistribution, + littersPerYear, + lossesPerYear, + populationOverTime, +} from '../aggregate' + +function makeLitter(date: string, totalBorn: number | null = null): Litter { + return { + id: `l-${date}-${Math.abs(totalBorn ?? 0)}-${counter++}`, + name: 'Wurf', + date, + totalBorn, + expectedGoHomeDate: null, + notes: null, + fatherId: null, + motherId: null, + } +} +let counter = 0 + +function makeGerbil(over: Partial & { id: string }): Gerbil { + return { + name: over.id, + gender: 'unknown', + status: 'Active' as GerbilStatus, + dateOfBirth: null, + dateOfDeath: null, + causeOfDeath: null, + goHomeDate: null, + litterId: null, + enclosureId: null, + colorVarietyId: null, + originContactId: null, + receiverContactId: null, + genotype: null, + notes: null, + ...over, + } +} + +describe('littersPerYear', () => { + it('zählt pro Jahr und füllt Lücken mit 0', () => { + const result = littersPerYear([ + makeLitter('2021-03-01'), + makeLitter('2021-09-10'), + makeLitter('2023-01-05'), + ]) + expect(result).toEqual([ + { year: 2021, value: 2 }, + { year: 2022, value: 0 }, + { year: 2023, value: 1 }, + ]) + }) + + it('leere Eingabe → leere Reihe', () => { + expect(littersPerYear([])).toEqual([]) + }) +}) + +describe('avgLitterSizePerYear', () => { + it('mittelt nur Würfe mit erfasster Wurfstärke und lässt Jahre ohne Daten aus', () => { + const result = avgLitterSizePerYear([ + makeLitter('2021-01-01', 4), + makeLitter('2021-06-01', 6), + makeLitter('2022-01-01', null), // zählt nicht + makeLitter('2023-01-01', 5), + ]) + expect(result).toEqual([ + { year: 2021, value: 5 }, + { year: 2023, value: 5 }, // 2022 fehlt bewusst (kein 0-Durchschnitt) + ]) + }) +}) + +describe('farbschlagDistribution', () => { + it('zählt nur aktive Tiere, sortiert absteigend, null-Eimer zuletzt', () => { + const gerbils = [ + makeGerbil({ id: 'a', colorVarietyId: 'Schwarz' }), + makeGerbil({ id: 'b', colorVarietyId: 'Schwarz' }), + makeGerbil({ id: 'c', colorVarietyId: 'Agouti' }), + makeGerbil({ id: 'd', colorVarietyId: null }), + makeGerbil({ id: 'e', colorVarietyId: 'Gold', status: 'Deceased' }), // inaktiv + ] + const result = farbschlagDistribution(gerbils, (g) => g.colorVarietyId) + expect(result).toEqual([ + { name: 'Schwarz', count: 2 }, + { name: 'Agouti', count: 1 }, + { name: null, count: 1 }, + ]) + }) + + it('sortiert bei Gleichstand alphabetisch (de)', () => { + const gerbils = [ + makeGerbil({ id: 'a', colorVarietyId: 'Zobel' }), + makeGerbil({ id: 'b', colorVarietyId: 'Ägypter' }), + ] + const names = farbschlagDistribution(gerbils, (g) => g.colorVarietyId).map((e) => e.name) + expect(names).toEqual(['Ägypter', 'Zobel']) + }) +}) + +describe('populationOverTime', () => { + it('bildet Geburt/Tod/Abgabe als Bestandskurve mit Monatsabtastung ab', () => { + const gerbils = [ + makeGerbil({ id: 'a', dateOfBirth: '2024-01-15' }), + makeGerbil({ id: 'b', dateOfBirth: '2024-02-20' }), + makeGerbil({ + id: 'c', + dateOfBirth: '2024-01-20', + status: 'Deceased', + dateOfDeath: '2024-03-10', + }), + makeGerbil({ + id: 'd', + dateOfBirth: '2024-02-01', + status: 'GivenAway', + goHomeDate: '2024-04-05', + }), + ] + const points = populationOverTime(gerbils, '2024-04-15') + expect(points).toEqual([ + { date: '2024-01-01', value: 0 }, // vor allen Ereignissen + { date: '2024-02-01', value: 3 }, // a, c geboren + d (Geburt genau am Stichtag zählt mit) + { date: '2024-03-01', value: 4 }, + { date: '2024-04-01', value: 3 }, // c verstorben am 10.03. + { date: '2024-04-15', value: 2 }, // d abgegeben am 05.04. + ]) + }) + + it('Tiere ohne Geburtsdatum werden übersprungen; leer → []', () => { + expect(populationOverTime([makeGerbil({ id: 'x' })], '2024-01-01')).toEqual([]) + expect(populationOverTime([], '2024-01-01')).toEqual([]) + }) + + it('Endpunkt heute wird nicht doppelt erzeugt, wenn heute ein Monatsanfang ist', () => { + const gerbils = [makeGerbil({ id: 'a', dateOfBirth: '2024-01-15' })] + const points = populationOverTime(gerbils, '2024-03-01') + expect(points[points.length - 1]).toEqual({ date: '2024-03-01', value: 1 }) + expect(points.filter((p) => p.date === '2024-03-01')).toHaveLength(1) + }) +}) + +describe('lossesPerYear', () => { + it('zählt Todesfälle pro Jahr (nur Status Verstorben, mit Todesdatum)', () => { + const gerbils = [ + makeGerbil({ id: 'a', status: 'Deceased', dateOfDeath: '2021-05-01' }), + makeGerbil({ id: 'b', status: 'Deceased', dateOfDeath: '2023-02-02' }), + makeGerbil({ id: 'c', status: 'Deceased', dateOfDeath: null }), // ohne Datum: nicht zuordenbar + makeGerbil({ id: 'd', status: 'GivenAway', goHomeDate: '2022-01-01' }), // kein Todesfall + ] + expect(lossesPerYear(gerbils)).toEqual([ + { year: 2021, value: 1 }, + { year: 2022, value: 0 }, + { year: 2023, value: 1 }, + ]) + }) +}) diff --git a/gerbil-manager-web/src/statistics/aggregate.ts b/gerbil-manager-web/src/statistics/aggregate.ts new file mode 100644 index 0000000..c1057ed --- /dev/null +++ b/gerbil-manager-web/src/statistics/aggregate.ts @@ -0,0 +1,154 @@ +/** + * FEAT-7: Statistik-Aggregationen — reine, getestete Funktionen ohne + * React/API. Die Seite (StatistikPage) lädt die Rohdaten über die + * Listen-Endpunkte und reicht sie hier durch; Mengen sind klein + * (~300 Tiere / ~750 Würfe nach dem Import), clientseitig ist also fein. + * + * Sprachneutral: keine deutschen Texte hier (de.ts ist UI-Schicht); + * Datums-Eingaben sind ISO-Strings ("YYYY-MM-DD") aus den DTOs. + */ +import type { Gerbil, Litter } from '../api/types' + +export interface YearValue { + year: number + value: number +} + +export interface NameCount { + /** Farbschlag-Name; null = Tier ohne auflösbaren Farbschlag. */ + name: string | null + count: number +} + +export interface DatePoint { + /** ISO-Datum "YYYY-MM-DD". */ + date: string + value: number +} + +function yearOf(iso: string | null | undefined): number | null { + if (!iso) return null + const y = Number(iso.slice(0, 4)) + return Number.isInteger(y) && y > 0 ? y : null +} + +/** Lückenlose Jahresreihe minJahr..maxJahr aus einer Jahr→Wert-Map. */ +function fillYears(byYear: Map): YearValue[] { + if (byYear.size === 0) return [] + const years = [...byYear.keys()] + const min = Math.min(...years) + const max = Math.max(...years) + const out: YearValue[] = [] + for (let y = min; y <= max; y++) out.push({ year: y, value: byYear.get(y) ?? 0 }) + return out +} + +/** Würfe pro Jahr (lückenlos von erstem bis letztem Jahr; 0 = kein Wurf). */ +export function littersPerYear(litters: Litter[]): YearValue[] { + const byYear = new Map() + for (const litter of litters) { + const y = yearOf(litter.date) + if (y !== null) byYear.set(y, (byYear.get(y) ?? 0) + 1) + } + return fillYears(byYear) +} + +/** + * Durchschnittliche Wurfstärke (TotalBorn) pro Jahr. Nur Würfe mit erfasster + * Wurfstärke zählen; Jahre ohne solche Würfe werden AUSGELASSEN (ein + * Durchschnitt von „nichts“ ist nicht 0). + */ +export function avgLitterSizePerYear(litters: Litter[]): YearValue[] { + const sum = new Map() + const n = new Map() + for (const litter of litters) { + const y = yearOf(litter.date) + if (y === null || litter.totalBorn == null) continue + sum.set(y, (sum.get(y) ?? 0) + litter.totalBorn) + n.set(y, (n.get(y) ?? 0) + 1) + } + return [...sum.keys()] + .sort((a, b) => a - b) + .map((y) => ({ year: y, value: sum.get(y)! / n.get(y)! })) +} + +/** + * Farbschlag-Verteilung der AKTIVEN Tiere, absteigend nach Anzahl + * (bei Gleichstand alphabetisch; der null-Eimer „ohne Farbschlag“ zuletzt). + * Die Namensauflösung (ColorVariety-Lookup, Genotyp-Fallback) injiziert die UI. + */ +export function farbschlagDistribution( + gerbils: Gerbil[], + resolveName: (g: Gerbil) => string | null, +): NameCount[] { + const byName = new Map() + for (const g of gerbils) { + if (g.status !== 'Active') continue + const name = resolveName(g) + byName.set(name, (byName.get(name) ?? 0) + 1) + } + return [...byName.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => { + if (a.name === null) return 1 + if (b.name === null) return -1 + return b.count - a.count || a.name.localeCompare(b.name, 'de') + }) +} + +/** + * Bestandsentwicklung: Zahl der lebenden, nicht abgegebenen Tiere im + * Zeitverlauf. Ereignisse: Geburt (+1), Tod (−1), Abgabe (−1); Tiere ohne + * Geburtsdatum können nicht eingeordnet werden und werden übersprungen. + * Abtastung an Monatsanfängen vom ersten Ereignis bis `todayIso`, plus + * Endpunkt `todayIso` — begrenzt die Punktzahl und glättet das Diagramm. + */ +export function populationOverTime(gerbils: Gerbil[], todayIso: string): DatePoint[] { + const events: { date: string; delta: number }[] = [] + for (const g of gerbils) { + if (!g.dateOfBirth) continue + events.push({ date: g.dateOfBirth, delta: +1 }) + if (g.status === 'Deceased' && g.dateOfDeath) events.push({ date: g.dateOfDeath, delta: -1 }) + if (g.status === 'GivenAway' && g.goHomeDate) events.push({ date: g.goHomeDate, delta: -1 }) + } + if (events.length === 0) return [] + events.sort((a, b) => a.date.localeCompare(b.date)) + + const points: DatePoint[] = [] + let running = 0 + let eventIndex = 0 + const consumeUpTo = (iso: string) => { + while (eventIndex < events.length && events[eventIndex].date <= iso) { + running += events[eventIndex].delta + eventIndex++ + } + return running + } + + // Monatsanfänge vom Monat des ersten Ereignisses bis heute. + let [y, m] = events[0].date.split('-').map(Number) + const monthStart = () => `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-01` + while (monthStart() <= todayIso) { + points.push({ date: monthStart(), value: consumeUpTo(monthStart()) }) + m++ + if (m > 12) { + m = 1 + y++ + } + } + const last = points[points.length - 1] + const todayValue = consumeUpTo(todayIso) + if (!last || last.date !== todayIso) points.push({ date: todayIso, value: todayValue }) + return points +} + +/** Verluste (Todesfälle) pro Jahr — lückenlos von erstem bis letztem Jahr. */ +export function lossesPerYear(gerbils: Gerbil[]): YearValue[] { + const byYear = new Map() + for (const g of gerbils) { + if (g.status !== 'Deceased') continue + const y = yearOf(g.dateOfDeath) + if (y !== null) byYear.set(y, (byYear.get(y) ?? 0) + 1) + } + return fillYears(byYear) +}