Files
HealthLog/src/lib/analytics/trends.ts
T
Marc-André Bombeck 3b1314d1b0 Enforce the reminder nag cap, compare the disclaimer version, drop the unread analytics columns
- The daily Coach sweep now charges each ignored day against
  surfaceCount and auto-dismisses at three — the cap the schema comment
  promised since v1.22 but nothing enforced.
- The welcome gate compares the stored disclaimer acknowledgment
  version against the current one, so revised disclaimer text
  re-prompts; /api/auth/me carries the version for the client.
- slope90 and anomalyCount leave the analytics wire: slope90 had no
  reader anywhere (computed on every request, in SQL twice), and the
  slim path could never compute anomalyCount (it always reported 0).
  anomalyCount stays as an in-process field on summarize() for the
  insights feature builder, which is its only real consumer.
2026-08-14 05:19:05 +02:00

281 lines
10 KiB
TypeScript

/**
* Trend calculation utilities for health data.
* All functions are pure and work on sorted arrays of {date, value} pairs.
*/
export interface DataPoint {
date: Date;
value: number;
}
// ── Trend Slope ──────────────────────────────────────────
export interface TrendSlope {
slope: number; // units per day
direction: "up" | "down" | "stable";
confidence: number; // R² value 0-1
}
/**
* Linear regression slope over the last N days.
* Uses least squares fit.
*
* Window is anchored on `Date.now()`, NOT on the most recent point — so a
* stale series (no readings for weeks) returns `null` from this function the
* same way `summarize().avg7/avg30` returns `null`. The v3 audit caught the
* mismatch where `trendSlope` reported a "trend" from old data while the
* dashboard tile correctly hid the average.
*/
export function trendSlope(
data: DataPoint[],
windowDays: number,
now: number = Date.now(),
): TrendSlope | null {
if (data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const cutoff = now - windowDays * 24 * 60 * 60 * 1000;
const window = sorted.filter((p) => p.date.getTime() >= cutoff);
if (window.length < 2) return null;
// Convert dates to days from start
const startTime = window[0].date.getTime();
const points = window.map((p) => ({
x: (p.date.getTime() - startTime) / (24 * 60 * 60 * 1000),
y: p.value,
}));
const n = points.length;
const sumX = points.reduce((s, p) => s + p.x, 0);
const sumY = points.reduce((s, p) => s + p.y, 0);
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
// R² calculation
const meanY = sumY / n;
const ssTotal = points.reduce((s, p) => s + (p.y - meanY) ** 2, 0);
const ssResidual = points.reduce(
(s, p) => s + (p.y - (intercept + slope * p.x)) ** 2,
0,
);
const rSquared = ssTotal === 0 ? 0 : 1 - ssResidual / ssTotal;
const threshold = 0.01; // units per day threshold for "stable"
const direction: "up" | "down" | "stable" =
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
return {
slope: Math.round(slope * 1000) / 1000,
direction,
confidence: Math.round(rSquared * 100) / 100,
};
}
// ── Anomaly Detection ────────────────────────────────────
export interface Anomaly {
date: Date;
value: number;
zScore: number;
}
/**
* Simple z-score anomaly detection.
* Returns points with |z-score| > threshold.
*/
export function detectAnomalies(data: DataPoint[], threshold = 2.0): Anomaly[] {
if (data.length < 3) return [];
const values = data.map((p) => p.value);
const mean = values.reduce((s, v) => s + v, 0) / values.length;
const variance =
values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length;
const stdDev = Math.sqrt(variance);
if (stdDev === 0) return [];
return data
.map((p) => ({
date: p.date,
value: p.value,
zScore: Math.round(((p.value - mean) / stdDev) * 100) / 100,
}))
.filter((a) => Math.abs(a.zScore) > threshold);
}
// ── Summary Statistics ───────────────────────────────────
export interface DataSummary {
count: number;
latest: number | null;
/** Null when the series is empty (vs the previous 0 sentinel). */
min: number | null;
/** Null when the series is empty (vs the previous 0 sentinel). */
max: number | null;
/** Null when the series is empty (vs the previous 0 sentinel). */
mean: number | null;
/**
* v1.8.5 — 50th percentile (median) of the value series. Surfaced on
* the insights category-page stat strip alongside min / max / mean so
* a skewed series (a few very high or very low readings dragging the
* mean) reads honestly. Null when the series is empty.
*
* Window: caller-defined. `summarize()` computes the median over the
* full `DataPoint[]` it is handed, so the window is whatever the caller
* passed (full history for `/api/measurements/series`, per-day means
* for the snapshot builder, …). It is NOT guaranteed to be the trailing
* 90-day window. The insights stat strip does not read this value — its
* median comes from the SQL `summaries-slice` path, which fixes the
* window to the trailing 90 days (`PERCENTILE_CONT(0.5) … FILTER (WHERE
* measured_at >= NOW() - INTERVAL '90 days')`). A consumer comparing the
* two must account for the differing windows. The percentile *algorithm*
* matches (linear-interpolated midpoint), only the window differs.
*/
median: number | null;
avg7: number | null;
avg30: number | null;
slope7: TrendSlope | null;
slope30: TrendSlope | null;
/**
* In-process only — the insights feature builder reads it as the
* outlier/anomaly figure for its prompt blocks. The `/api/analytics`
* wire never serialises it (the slim SQL path cannot compute it and
* always reported 0), and no client component reads it. `slope90` was
* removed from this shape entirely in v1.37.19 for the same reason:
* computed on every request, read by nobody.
*/
anomalyCount?: number;
/**
* v1.4.16 phase B8 — average value over the 30-day window starting
* 30 days before today, i.e. the "last month" prior period the
* dashboard tile delta callout compares against. Null when the
* window has no data.
*/
avg30LastMonth?: number | null;
/**
* v1.4.16 phase B8 — average value over the 30-day window starting
* 365 days before today (one calendar year ago). Null when the
* window has no data.
*/
avg30LastYear?: number | null;
}
export function summarize(data: DataPoint[]): DataSummary {
if (data.length === 0) {
// Empty series — return null for stats so callers can render an explicit
// "no data" state instead of treating 0/0/0 as a real reading. v3 audit
// caught the previous {min:0,max:0,mean:0} producing nonsense chart axes.
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
};
}
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const values = sorted.map((p) => p.value);
// v1.4.33 P0 — single-pass fold for sum/min/max replaces the previous
// `Math.min(...values)` / `Math.max(...values)` spread. V8 caps
// function arity at ~125k-130k; Apple-Health-synced PULSE series for
// a multi-year power user routinely exceed that, and the spread
// surfaced as `RangeError: Maximum call stack size exceeded` from the
// `/api/analytics` route's per-type `Promise.all` aggregator. Folding
// the three reductions into one walk keeps the working set bounded
// (no transient argument array) and is also cheaper than three
// separate passes.
let sum = 0;
let minVal = values[0];
let maxVal = values[0];
for (const v of values) {
sum += v;
if (v < minVal) minVal = v;
if (v > maxVal) maxVal = v;
}
const mean = sum / values.length;
// v1.8.5 — median (50th percentile) over the FULL series handed to
// this function (the window is caller-defined; see the `median` doc on
// DataSummary). A separate numeric sort from the date-ordered `sorted`
// array above so the percentile is taken over values, not timestamps.
// Linear-interpolated midpoint on an even-length series (the standard
// PERCENTILE_CONT definition) so the *algorithm* matches the SQL
// `PERCENTILE_CONT(0.5)` the rollup-tier slim slice emits — but note
// the SQL path windows to the trailing 90 days while this path does
// not, so the two are byte-comparable only when the caller already
// passed a 90-day series.
const valueSorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(valueSorted.length / 2);
const median =
valueSorted.length % 2 === 0
? (valueSorted[mid - 1] + valueSorted[mid]) / 2
: valueSorted[mid];
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const last7 = sorted.filter((p) => now - p.date.getTime() < 7 * DAY);
const last30 = sorted.filter((p) => now - p.date.getTime() < 30 * DAY);
// v1.4.16 phase B8 — prior-period 30-day means powering the tile
// delta callout. The lastMonth window is days [30, 60) ago; the
// lastYear window is days [365, 395) ago. Both align to the 30-day
// shift the chart overlay uses so the tile delta and the chart
// overlay narrate the same comparison.
const meanOfWindow = (
minDaysAgo: number,
maxDaysAgo: number,
): number | null => {
const slice = sorted.filter((p) => {
const ageMs = now - p.date.getTime();
return ageMs >= minDaysAgo * DAY && ageMs < maxDaysAgo * DAY;
});
if (slice.length === 0) return null;
const sum = slice.reduce((s, p) => s + p.value, 0);
return Math.round((sum / slice.length) * 100) / 100;
};
const avg30LastMonth = meanOfWindow(30, 60);
const avg30LastYear = meanOfWindow(365, 395);
return {
count: data.length,
latest: sorted[sorted.length - 1].value,
min: minVal,
max: maxVal,
mean: Math.round(mean * 100) / 100,
median: Math.round(median * 100) / 100,
avg7:
last7.length > 0
? Math.round(
(last7.reduce((s, p) => s + p.value, 0) / last7.length) * 100,
) / 100
: null,
avg30:
last30.length > 0
? Math.round(
(last30.reduce((s, p) => s + p.value, 0) / last30.length) * 100,
) / 100
: null,
slope7: trendSlope(data, 7),
slope30: trendSlope(data, 30),
anomalyCount: detectAnomalies(data).length,
avg30LastMonth,
avg30LastYear,
};
}