perf(summaries-slice): wire long-window slice to monthly rollup buckets

Adds `computeLongWindowSummary(userId, type, windowDays)` — the
ready-to-use entry point for the v1.5 multi-year trend card and the
Coach drawer's "history" tile. Routes the requested window through
the largest granularity that still resolves it (90 d \u2192 DAY,
365 d \u2192 MONTH, 1095 d \u2192 YEAR) via
`readBestGranularityRollups`, then composes `count / min / max /
mean / sum` linearly from the bucket rows. SD / slope / r2 are
intentionally omitted because they do not compose across coarser
buckets.

The existing `computeSummariesSlice` shape is untouched — its
windowed columns cap at 90 d so the live SQL path is already
DAY-tier optimal. Wiring the all-time aggregate through YEAR
buckets would risk staleness on multi-year data because the
WEEK / MONTH / YEAR tiers update asynchronously via pg-boss while
DAY is kept fresh by the sync write-hook. Deferring that swap to
v1.5 (covered in the W-WMY phase report).
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 12:20:14 +02:00
1 parent 586def916c
commit 8763b3aaa7
6 files changed
+679 -1

No files matched your search

@@ -206,6 +206,93 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
// directly off the `measurement_rollups` row; pinning the
// returned shape proves the route hands `sum_value` through
// instead of reconstructing from `mean * count`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
_count: { _all: 7 },
_max: { measuredAt: new Date() },
},
] as never);
// First call: latest7d. Second call: sparkline. Third call:
// streakDays. Matches the Promise.all order documented in the
// route's perf-comment.
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
value: 9999,
measured_at: new Date(),
},
] as never)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date(),
mean: 2000,
count: 4,
// 8000 ≠ mean × count (4 × 2000 = 8000) but the SUM is the
// direct rollup column; the fallback path would also hit
// 8000 here. Use 8120 to prove the route did NOT reconstruct
// from mean × count.
sum_value: 8120,
},
] as never)
.mockResolvedValueOnce([] as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Array<{ id: string; sparkline: number[] }> };
};
const steps = body.data.metrics.find((m) => m.id === "steps");
expect(steps?.sparkline).toEqual([8120]);
});
it("falls back to mean * count when the legacy sum_value is null (v1.4.39 W-SUM)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
_count: { _all: 7 },
_max: { measuredAt: new Date() },
},
] as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
value: 9999,
measured_at: new Date(),
},
] as never)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date(),
mean: 2000,
count: 4,
sum_value: null,
},
] as never)
.mockResolvedValueOnce([] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { metrics: Array<{ id: string; sparkline: number[] }> };
};
const steps = body.data.metrics.find((m) => m.id === "steps");
// 4 × 2000 — legacy fallback for the boot-backfill convergence
// window keeps the chart non-empty for pre-v1.4.39 rows.
expect(steps?.sparkline).toEqual([8000]);
});
it("computes intake compliance for today", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
@@ -233,6 +233,65 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
expect(json.data.meta.aggregate).toBe("daily");
});
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
// ACTIVITY_STEPS rollup row with sum_value populated. The route
// must consume sumValue directly — NOT reconstruct from mean *
// count. Use a sum that is NOT mean × count so the test
// distinguishes the two paths.
const buckets = Array.from({ length: 3 }, (_, i) => ({
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
mean: 2000, // would yield 10000 if multiplied by count=5
count: 5,
sumValue: 11000 + i * 250, // distinct from mean × count
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(json.data.measurements.map((m) => m.value)).toEqual([
11000, 11250, 11500,
]);
});
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
// route falls back to mean × count so the chart never paints
// a hole during the convergence window.
const buckets = [
{
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1)),
mean: 2000,
count: 5,
sumValue: null,
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(json.data.measurements[0].value).toBe(10000);
});
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
// see a correct chart on their first render.