mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page, between the stat strip and the chart, with a sentiment-coloured "vs prior period" delta caption beside it. New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y> returns the current-window aggregate, the previous comparable window, and the composed delta. Single metric type by construction, so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers (readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern the year-ago baseline already uses; count/min/max/mean/sum compose across buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is untouched so the hottest dashboard cache key never fragments. The delta caption reuses the dashboard's metric-aware up-good/up-bad colour logic — extracted from trend-card into a shared trend-sentiment module — with the metric's good direction sourced from the metric-status registry (target-band metrics render neutral, matching deviation framing). When the prior window has no data the caption shows "no prior-period data" rather than a misleading 0%. The range choice persists across metrics via a localStorage seam and defaults to 30d so nothing regresses. Cache keyed per (type, range) through the query-key factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
composeDelta,
|
|
rangeWindowDays,
|
|
sliceWindowDelta,
|
|
type WindowAggregate,
|
|
} from "@/lib/analytics/range-delta";
|
|
import type { RollupBucketRow } from "@/lib/rollups/measurement-read-wmy";
|
|
|
|
/**
|
|
* v1.9.0 — the period-over-period range delta is two reads composed into one
|
|
* caption. The composition (slice the buckets into the current vs previous
|
|
* halves, aggregate each, derive the delta) is the load-bearing logic and is
|
|
* pure over the bucket rows — pinned here without a DB.
|
|
*/
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function bucket(daysAgo: number, count: number, mean: number): RollupBucketRow {
|
|
return {
|
|
bucketStart: new Date(Date.now() - daysAgo * DAY_MS),
|
|
count,
|
|
mean,
|
|
sd: null,
|
|
slope: null,
|
|
r2: null,
|
|
sumValue: count * mean,
|
|
minValue: mean,
|
|
maxValue: mean,
|
|
};
|
|
}
|
|
|
|
describe("rangeWindowDays", () => {
|
|
it("maps each range onto its trailing-window length", () => {
|
|
expect(rangeWindowDays("7d")).toBe(7);
|
|
expect(rangeWindowDays("30d")).toBe(30);
|
|
expect(rangeWindowDays("90d")).toBe(90);
|
|
expect(rangeWindowDays("1y")).toBe(365);
|
|
});
|
|
});
|
|
|
|
describe("sliceWindowDelta", () => {
|
|
it("splits buckets into the current and previous halves by bucketStart", () => {
|
|
const now = Date.now();
|
|
// 30d window: current = [now-30, now), previous = [now-60, now-30).
|
|
const rows = [
|
|
bucket(5, 2, 80), // current
|
|
bucket(20, 3, 82), // current
|
|
bucket(40, 2, 70), // previous
|
|
bucket(55, 2, 74), // previous
|
|
];
|
|
const { current, previous } = sliceWindowDelta(rows, 30, now);
|
|
expect(current.count).toBe(5);
|
|
expect(previous.count).toBe(4);
|
|
// Count-weighted means compose linearly across buckets.
|
|
expect(current.mean).toBeCloseTo((2 * 80 + 3 * 82) / 5, 6);
|
|
expect(previous.mean).toBeCloseTo((2 * 70 + 2 * 74) / 4, 6);
|
|
});
|
|
|
|
it("excludes buckets outside the 2N span", () => {
|
|
const now = Date.now();
|
|
const rows = [
|
|
bucket(5, 1, 90), // current
|
|
bucket(40, 1, 80), // previous
|
|
bucket(200, 1, 50), // outside both halves
|
|
];
|
|
const { current, previous } = sliceWindowDelta(rows, 30, now);
|
|
expect(current.count).toBe(1);
|
|
expect(previous.count).toBe(1);
|
|
});
|
|
|
|
it("yields empty aggregates when no bucket falls in a half", () => {
|
|
const now = Date.now();
|
|
const rows = [bucket(5, 4, 100)]; // only current half populated
|
|
const { current, previous } = sliceWindowDelta(rows, 30, now);
|
|
expect(current.count).toBe(4);
|
|
expect(previous.count).toBe(0);
|
|
expect(previous.mean).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("composeDelta", () => {
|
|
const win = (count: number, mean: number | null): WindowAggregate => ({
|
|
count,
|
|
min: mean,
|
|
max: mean,
|
|
mean,
|
|
sum: mean === null ? null : count * mean,
|
|
});
|
|
|
|
it("computes the signed delta and percentage", () => {
|
|
const { delta, deltaPct } = composeDelta(win(5, 103), win(5, 100));
|
|
expect(delta).toBeCloseTo(3, 6);
|
|
expect(deltaPct).toBeCloseTo(0.03, 6);
|
|
});
|
|
|
|
it("returns null delta when either window is empty", () => {
|
|
expect(composeDelta(win(0, null), win(5, 100))).toEqual({
|
|
delta: null,
|
|
deltaPct: null,
|
|
});
|
|
expect(composeDelta(win(5, 100), win(0, null))).toEqual({
|
|
delta: null,
|
|
deltaPct: null,
|
|
});
|
|
});
|
|
|
|
it("returns null deltaPct (not Infinity) when the prior mean is zero", () => {
|
|
const { delta, deltaPct } = composeDelta(win(5, 4), win(3, 0));
|
|
expect(delta).toBeCloseTo(4, 6);
|
|
expect(deltaPct).toBeNull();
|
|
});
|
|
|
|
it("captures a negative move", () => {
|
|
const { delta, deltaPct } = composeDelta(win(5, 90), win(5, 100));
|
|
expect(delta).toBeCloseTo(-10, 6);
|
|
expect(deltaPct).toBeCloseTo(-0.1, 6);
|
|
});
|
|
});
|