FEAT-7: statistics aggregation module - litters/year, avg litter size, Farbschlag distribution, population curve, losses (9 tests)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
gerbil-manager-web/src/statistics/__tests__/aggregate.test.ts
Normal file
162
gerbil-manager-web/src/statistics/__tests__/aggregate.test.ts
Normal file
@@ -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<Gerbil> & { 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 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
154
gerbil-manager-web/src/statistics/aggregate.ts
Normal file
154
gerbil-manager-web/src/statistics/aggregate.ts
Normal file
@@ -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<number, number>): 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<number, number>()
|
||||||
|
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<number, number>()
|
||||||
|
const n = new Map<number, number>()
|
||||||
|
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<string | null, number>()
|
||||||
|
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<number, number>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user