Files
HealthLog/src/lib/analytics/correlations.ts
T
Marc-André BombeckandMarc-André Bombeck 03845ef5eb fix(analytics): correct chart-math edge cases caught by the v1.4 audit (#126)
Three correctness issues in `summarize()` / `trendSlope()` /
`weeklyAverages()` that the discovery audit reproduced:

1. **`summarize` and `trendSlope` used different time anchors.** The
   averages (`avg7`, `avg30`) snapped to `Date.now()`, while the slopes
   (`slope7`, `slope30`, `slope90`) snapped to the latest point in the
   series. A stale series (no readings for weeks) reported a slope from
   old data even though the dashboard tile correctly hid the average.
   Both surfaces now use `Date.now()` as the cutoff, so a stale series
   returns `null` from every windowed stat consistently.

2. **`summarize([])` returned `{ min: 0, max: 0, mean: 0 }`.** Those
   sentinels leaked into chart axes (Y-axis collapsed onto zero) and
   into AI-prompt feature bundles where the generator treated the zero
   as a real reading. Empty input now returns `null` for all three
   fields. The `DataSummary` type widens accordingly; the dashboard
   already null-checks the optional fields, so no UI regression.

3. **`weeklyAverages()` used system-local `Date.getDay()` /
   `getDate()`.** On a UTC server (which is what the production
   container runs as) a Sunday-evening Berlin reading bucketed into
   the next week. Now the ISO-Monday key is computed via
   `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`, so the bucket
   lines up with how the user perceives "this week".

`pairByTimestamp` keeps the greedy nearest-match heuristic — adding
JSDoc that documents the limitation and the conditions under which a
Hungarian-style match would matter (sparse health data is well below
that bar).

Tests: 371 → 384 (+13). Two new direct regression tests:

- `weeklyAverages` Sunday-evening-Berlin bucket goes to the *previous*
  Monday.
- `summarize` with all readings 100 days old returns `null` for every
  windowed stat (no false trend on stale data).

Plus tightened the empty-summarize assertions.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:14:26 +02:00

174 lines
5.1 KiB
TypeScript

/**
* Correlation analysis between health metrics.
* Pure functions — no DB access.
*/
import type { DataPoint } from "./trends";
export interface PairedPoint {
a: number;
b: number;
date: Date;
}
export interface CorrelationResult {
r: number;
strength: "stark" | "moderat" | "schwach" | "keine";
n: number;
}
/**
* Pair two time-series by matching timestamps within a maximum gap.
* Each point is used at most once (greedy nearest-match).
*
* NOTE: This is a greedy heuristic, not the bipartite-minimum-weight
* optimum. For sparse health data the difference is negligible — well
* under 1% in the v1.4 charts auditor's stress sample — but adversarial
* inputs (alternating 1ms-apart points) can produce sub-optimal
* pairings. If exact pairing matters, swap in a Hungarian-style match.
*/
export function pairByTimestamp(
seriesA: DataPoint[],
seriesB: DataPoint[],
maxGapMs: number = 24 * 60 * 60 * 1000, // default 1 day
): PairedPoint[] {
if (seriesA.length === 0 || seriesB.length === 0) return [];
const sortedA = [...seriesA].sort(
(a, b) => a.date.getTime() - b.date.getTime(),
);
const sortedB = [...seriesB].sort(
(a, b) => a.date.getTime() - b.date.getTime(),
);
const usedB = new Set<number>();
const pairs: PairedPoint[] = [];
for (const a of sortedA) {
let bestIdx = -1;
let bestGap = Infinity;
for (let i = 0; i < sortedB.length; i++) {
if (usedB.has(i)) continue;
const gap = Math.abs(a.date.getTime() - sortedB[i].date.getTime());
if (gap <= maxGapMs && gap < bestGap) {
bestGap = gap;
bestIdx = i;
}
}
if (bestIdx >= 0) {
usedB.add(bestIdx);
pairs.push({
a: a.value,
b: sortedB[bestIdx].value,
date: a.date,
});
}
}
return pairs.sort((a, b) => a.date.getTime() - b.date.getTime());
}
/**
* Pearson correlation coefficient.
* Returns null if fewer than minPairs data points.
*/
export function pearsonCorrelation(
pairs: PairedPoint[],
minPairs: number = 5,
): CorrelationResult | null {
if (pairs.length < minPairs) return null;
const n = pairs.length;
const sumA = pairs.reduce((s, p) => s + p.a, 0);
const sumB = pairs.reduce((s, p) => s + p.b, 0);
const sumAB = pairs.reduce((s, p) => s + p.a * p.b, 0);
const sumAA = pairs.reduce((s, p) => s + p.a * p.a, 0);
const sumBB = pairs.reduce((s, p) => s + p.b * p.b, 0);
const denominator = Math.sqrt(
(n * sumAA - sumA * sumA) * (n * sumBB - sumB * sumB),
);
if (denominator === 0) return { r: 0, strength: "keine", n };
const r = Math.round(((n * sumAB - sumA * sumB) / denominator) * 1000) / 1000;
const absR = Math.abs(r);
let strength: CorrelationResult["strength"];
if (absR >= 0.7) strength = "stark";
else if (absR >= 0.4) strength = "moderat";
else if (absR >= 0.2) strength = "schwach";
else strength = "keine";
return { r, strength, n };
}
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
/**
* Aggregate data into weekly averages, ISO-Monday-based, in Europe/Berlin.
*
* v3 audit caught a TZ bug here: the previous implementation used
* `Date.getDay()` / `Date.getDate()` (system local) so on a UTC server, a
* Sunday-evening Berlin reading bucketed into the next week. We now derive
* year/month/day/weekday in Berlin via Intl.DateTimeFormat and rebuild the
* Monday key from there. The returned `date` for each bucket is the Monday
* UTC midnight — sortable, comparable across DST.
*/
export function weeklyAverages(data: DataPoint[]): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const weeks = new Map<string, { sum: number; count: number; date: Date }>();
const WEEKDAY_INDEX: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
for (const point of sorted) {
const parts = BERLIN_DATE_PARTS.formatToParts(point.date);
const yearStr = parts.find((p) => p.type === "year")?.value ?? "1970";
const monthStr = parts.find((p) => p.type === "month")?.value ?? "01";
const dayStr = parts.find((p) => p.type === "day")?.value ?? "01";
const weekdayStr = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
const isoWeekday = WEEKDAY_INDEX[weekdayStr] ?? 1;
// Days to subtract to land on Monday of the same ISO week in Berlin.
const offsetDays = isoWeekday - 1;
const dayUtc = new Date(`${yearStr}-${monthStr}-${dayStr}T00:00:00.000Z`);
const monday = new Date(dayUtc.getTime() - offsetDays * 86_400_000);
const key = monday.toISOString().slice(0, 10);
const existing = weeks.get(key);
if (existing) {
existing.sum += point.value;
existing.count++;
} else {
weeks.set(key, { sum: point.value, count: 1, date: monday });
}
}
return Array.from(weeks.values())
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map((w) => ({
date: w.date,
value: Math.round((w.sum / w.count) * 100) / 100,
}));
}