mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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.
|
||||
|
||||
Reference in new issue
Block a user