/** * 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) }