mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields — 30-day means over the [30, 60) and [365, 395) day windows. The existing `summarize()` helper computes them once for every metric the analytics API returns, so every dashboard tile picks them up automatically without extra DB queries. TrendCard gains optional `compareBaseline` + `compareDelta` props. When both are set the tile renders a "Δ −2.3 kg vs. last month" line below the latest value, colour-coded via the existing `directionSentiment` rules — down on an up-bad metric (weight, BP) paints green; down on an up-good metric (mood, sleep, steps) paints orange; neutral metrics stay muted; deltas under |0.05| read as stable and stay muted regardless. Mobile-friendly: the callout sits on its own line below the latest value so the tile stays scannable on Pixel-5 viewports without horizontal scroll. Suppressed entirely when comparison is off OR either side is missing data — strict regression guard for the default-off path. The dashboard's `tileCompareDelta()` helper resolves the prior-period field from the active comparison toggle and feeds every TrendCard the same delta value, so a single toggle flip in Settings → Dashboard updates every tile atomically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
299 lines
9.4 KiB
TypeScript
299 lines
9.4 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;
|
|
}
|
|
|
|
// ── Moving Average ───────────────────────────────────────
|
|
|
|
export function movingAverage(
|
|
data: DataPoint[],
|
|
windowDays: number,
|
|
): DataPoint[] {
|
|
if (data.length === 0) return [];
|
|
|
|
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
const result: DataPoint[] = [];
|
|
const windowMs = windowDays * 24 * 60 * 60 * 1000;
|
|
|
|
for (const point of sorted) {
|
|
const windowStart = point.date.getTime() - windowMs;
|
|
const windowPoints = sorted.filter(
|
|
(p) =>
|
|
p.date.getTime() > windowStart &&
|
|
p.date.getTime() <= point.date.getTime(),
|
|
);
|
|
const avg =
|
|
windowPoints.reduce((sum, p) => sum + p.value, 0) / windowPoints.length;
|
|
result.push({ date: point.date, value: Math.round(avg * 100) / 100 });
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── 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,
|
|
): TrendSlope | null {
|
|
if (data.length < 2) return null;
|
|
|
|
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
const cutoff = Date.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,
|
|
};
|
|
}
|
|
|
|
// ── Trend Line Points ────────────────────────────────────
|
|
|
|
/**
|
|
* Generate two data points representing the linear trend line endpoints.
|
|
* Used by chart components to overlay a regression line.
|
|
*/
|
|
export function trendLinePoints(
|
|
data: DataPoint[],
|
|
windowDays: number,
|
|
): { start: DataPoint; end: DataPoint } | null {
|
|
if (data.length < 2) return null;
|
|
|
|
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
const cutoff =
|
|
sorted[sorted.length - 1].date.getTime() - windowDays * 24 * 60 * 60 * 1000;
|
|
const window = sorted.filter((p) => p.date.getTime() >= cutoff);
|
|
|
|
if (window.length < 2) return null;
|
|
|
|
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;
|
|
|
|
const endDays = points[points.length - 1].x;
|
|
|
|
return {
|
|
start: {
|
|
date: window[0].date,
|
|
value: Math.round(intercept * 100) / 100,
|
|
},
|
|
end: {
|
|
date: window[window.length - 1].date,
|
|
value: Math.round((intercept + slope * endDays) * 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;
|
|
avg7: number | null;
|
|
avg30: number | null;
|
|
slope7: TrendSlope | null;
|
|
slope30: TrendSlope | null;
|
|
slope90: TrendSlope | null;
|
|
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,
|
|
avg7: null,
|
|
avg30: null,
|
|
slope7: null,
|
|
slope30: null,
|
|
slope90: 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);
|
|
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
|
|
|
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: Math.min(...values),
|
|
max: Math.max(...values),
|
|
mean: Math.round(mean * 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),
|
|
slope90: trendSlope(data, 90),
|
|
anomalyCount: detectAnomalies(data).length,
|
|
avg30LastMonth,
|
|
avg30LastYear,
|
|
};
|
|
}
|