Files
GerbilManager/gerbil-manager-web/src/statistics/aggregate.ts

155 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
}