155 lines
5.3 KiB
TypeScript
155 lines
5.3 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|