Files
HealthLog/src/lib/analytics/__tests__/range-delta.test.ts
T
Marc-André Bombeck 03925ddece feat(insights): time-range pills with period-over-period deltas on metric pages
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)
2026-06-02 16:45:14 +02:00

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);
});
});