diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index b3c48261d..105c3420d 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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((( diff --git a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts index 973b255b9..c2bb37dc8 100644 --- a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts +++ b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts @@ -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. diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 7e62c2925..4b379d71d 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -27,7 +27,10 @@ vi.mock("@/lib/logging/context", () => ({ })); import { prisma } from "@/lib/db"; -import { computeSummariesSlice } from "../summaries-slice"; +import { + computeLongWindowSummary, + computeSummariesSlice, +} from "../summaries-slice"; const RAW = prisma.$queryRaw as unknown as ReturnType; const MEASUREMENT_FIND_FIRST = @@ -292,3 +295,137 @@ describe("computeSummariesSlice", () => { }); }); }); + +/** + * v1.4.39 W-WMY — long-window summary helper. Pins: + * - the helper routes a 365-day window through MONTH buckets, + * - the linearly-composable aggregate (count / min / max / mean / + * sum) lands at the same numbers `aggregateWmyBuckets` produces + * directly, so the rollup math survives round-tripping through + * the summaries-slice entry point, + * - a coverage miss surfaces as a `null` return instead of a + * partial-shape leak. + */ +describe("computeLongWindowSummary", () => { + it("routes a 365-day window through MONTH buckets and composes the aggregate", async () => { + // The first findMany call is the per-tier probe inside + // `readBestGranularityRollups`. With windowDays=365 the router + // walks YEAR (skipped, floor=731) → MONTH (floor=181, matched). + // The MONTH call returns two buckets; the helper composes the + // window aggregate from them. + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: new Date("2026-04-01T00:00:00.000Z"), + count: 10, + mean: 82, + sd: 1, + slope: -0.01, + r2: 0.3, + sumValue: null, + minValue: 79, + maxValue: 84, + }, + { + bucketStart: new Date("2026-05-01T00:00:00.000Z"), + count: 20, + mean: 80, + sd: 1.5, + slope: 0.02, + r2: 0.4, + sumValue: null, + minValue: 77, + maxValue: 83, + }, + ]); + + const result = await computeLongWindowSummary("user-1", "WEIGHT", 365); + + expect(result).not.toBeNull(); + expect(result?.granularity).toBe("MONTH"); + expect(result?.bucketCount).toBe(2); + expect(result?.count).toBe(30); + expect(result?.min).toBe(77); + expect(result?.max).toBe(84); + // Σ(count × mean) / Σcount = (10×82 + 20×80) / 30 = 80.6666… + expect(result?.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5); + expect(result?.sum).toBeNull(); // no sumValue on these buckets + + // The router asked MONTH first (365 d clears the 181-day MONTH + // floor and falls short of the 731-day YEAR floor). + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1); + expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH"); + }); + + it("routes a 1095-day window through YEAR with a fall-through to MONTH on miss", async () => { + // YEAR coverage miss → router falls back to MONTH. + ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // YEAR miss + .mockResolvedValueOnce([ + { + bucketStart: new Date("2024-06-01T00:00:00.000Z"), + count: 100, + mean: 81, + sd: 1.2, + slope: 0.005, + r2: 0.15, + sumValue: null, + minValue: 76, + maxValue: 86, + }, + ]); + + const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095); + + expect(result?.granularity).toBe("MONTH"); + expect(result?.bucketCount).toBe(1); + expect(result?.count).toBe(100); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); + }); + + it("composes the cumulative sum when every bucket carries sumValue", async () => { + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: new Date("2026-04-01T00:00:00.000Z"), + count: 28, + mean: 8_200, + sd: 1_000, + slope: 0, + r2: 0, + sumValue: 229_600, + minValue: 5_000, + maxValue: 12_000, + }, + { + bucketStart: new Date("2026-05-01T00:00:00.000Z"), + count: 30, + mean: 9_100, + sd: 1_100, + slope: 0, + r2: 0, + sumValue: 273_000, + minValue: 6_000, + maxValue: 12_500, + }, + ]); + + const result = await computeLongWindowSummary( + "user-1", + "ACTIVITY_STEPS", + 365, + ); + + expect(result?.sum).toBe(229_600 + 273_000); + }); + + it("returns null when every granularity misses", async () => { + ROLLUP_FIND_MANY.mockResolvedValue([]); + const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095); + expect(result).toBeNull(); + }); + + it("returns null on a zero / negative / NaN window without hitting the DB", async () => { + expect(await computeLongWindowSummary("user-1", "WEIGHT", 0)).toBeNull(); + expect(await computeLongWindowSummary("user-1", "WEIGHT", -10)).toBeNull(); + expect(await computeLongWindowSummary("user-1", "WEIGHT", NaN)).toBeNull(); + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 20015e50d..dee43ceee 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -53,6 +53,8 @@ * 100`. Slope `direction` is derived from `slope`'s sign with the * same 0.01-units-per-day "stable" threshold the JS helper uses. */ +import type { MeasurementType, RollupGranularity } from "@/generated/prisma/client"; + import { prisma } from "@/lib/db"; import type { DataSummary } from "@/lib/analytics/trends"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -62,6 +64,11 @@ import { isFullyCovered, probeRollupCoverage, } from "@/lib/measurements/rollup-coverage"; +import { + aggregateWmyBuckets, + readBestGranularityRollups, + type RollupBucketRow, +} from "@/lib/measurements/rollup-read-wmy"; /** * Heavy aggregate row — used on the cold-mount fallback path where the @@ -560,3 +567,96 @@ async function computeFromLiveAggregate( return { summaries, bmi: null, lastSeenByType }; } + +/** + * v1.4.39 W-WMY — long-window summary for a single `(userId, type)` + * pair, served from the WEEK / MONTH / YEAR rollup tier when the + * window justifies it. + * + * Why this helper exists + * ---------------------- + * The slim `computeSummariesSlice` caps its windowed columns at 90 d + * (`slope7 / slope30 / slope90`). The v1.5 multi-year trend feature + * + the Coach drawer's "history" tile need linearly composable stats + * (count / min / max / mean / sum) over much larger windows — 1 y, + * 2 y, 3 y. Hitting the live `measurements` table for a 3-year span + * walks every row the user has ever logged for that type, which on + * Marc's tenant is tens of thousands of rows per type. + * + * The WMY rollup tier already carries the per-bucket stats we need: + * the writer mints WEEK / MONTH / YEAR buckets via pg-boss on every + * measurement write and the boot backfill fills the long tail. This + * helper routes the requested window into the largest granularity + * that still resolves it (via `readBestGranularityRollups`) and + * composes the trailing-window aggregate in JS — typically 3-15 + * bucket rows on a multi-year window, vs the thousands of raw rows + * the live aggregator would scan. + * + * Compositional caveats + * --------------------- + * `count / min / max / mean / sum` are mathematically exact across + * any granularity (linearly composable). `sd / slope / r2` are NOT + * exact across coarser buckets — this helper deliberately omits them + * so callers cannot accidentally consume stale slope data. The v1.5 + * multi-year trend card derives its slope from the per-bucket `mean` + * series instead. + * + * Coverage-miss policy + * -------------------- + * `readBestGranularityRollups` falls back from YEAR → MONTH → WEEK + * → DAY on per-tier coverage miss. Returns `null` only when the + * user has zero buckets in the entire window — the caller treats + * that as "no data" rather than "stale rollup". The route is + * intentionally NOT wired to live SQL fallback here; the all-time + * aggregate path inside `computeSummariesSlice` already covers the + * "rollup tier empty" cold-mount case, and v1.5's multi-year card + * accepts a `null` "no data yet" state. + */ +export async function computeLongWindowSummary( + userId: string, + type: MeasurementType, + windowDays: number, +): Promise<{ + granularity: RollupGranularity; + bucketStart: Date | null; + bucketEnd: Date | null; + bucketCount: number; + count: number; + min: number | null; + max: number | null; + mean: number | null; + sum: number | null; +} | null> { + const resolved = await readBestGranularityRollups(userId, type, windowDays); + if (!resolved) return null; + const rows: RollupBucketRow[] = resolved.rows; + const aggregate = aggregateWmyBuckets(rows); + // Annotate which granularity served the window so the operator can + // verify the routing in production wide-events without redeploying + // instrumentation. Mirrors the `slim_summaries.path` annotate + // shape inside `computeFromRollups` / `computeFromLiveAggregate`. + annotate({ + meta: { + analytics: { + long_window_summary: { + type, + window_days: windowDays, + granularity: resolved.granularity, + bucket_count: rows.length, + row_count: aggregate.count, + }, + }, + }, + }); + return { + granularity: resolved.granularity, + bucketStart: rows[0]?.bucketStart ?? null, + bucketEnd: rows[rows.length - 1]?.bucketStart ?? null, + bucketCount: rows.length, + count: aggregate.count, + min: aggregate.min, + max: aggregate.max, + mean: aggregate.mean, + sum: aggregate.sum, + }; +} diff --git a/src/lib/measurements/__tests__/rollup-read-cumulative.test.ts b/src/lib/measurements/__tests__/rollup-read-cumulative.test.ts new file mode 100644 index 000000000..79fd9f161 --- /dev/null +++ b/src/lib/measurements/__tests__/rollup-read-cumulative.test.ts @@ -0,0 +1,195 @@ +/** + * v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers. + * + * `prisma.measurementRollup.findMany` is mocked so the tests pin the + * exact `where` shape the consumer sends (cumulative type set, + * granularity, since-bound) and the legacy-NULL fallback semantics of + * `resolveBucketSum`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurementRollup: { + findMany: mocks.findMany, + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + isCumulativeType, + readCumulativeDaySums, + readCumulativeDaySumsBatch, + resolveBucketSum, +} from "../rollup-read-cumulative"; + +beforeEach(() => { + mocks.findMany.mockReset(); +}); + +describe("isCumulativeType", () => { + it("recognises the five cumulative HK types", () => { + expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true); + expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true); + expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true); + expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true); + expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true); + }); + + it("rejects spot metrics", () => { + expect(isCumulativeType("WEIGHT")).toBe(false); + expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false); + expect(isCumulativeType("PULSE")).toBe(false); + }); +}); + +describe("readCumulativeDaySums", () => { + it("queries the rollup table with the cumulative type + DAY granularity", async () => { + const since = new Date("2026-04-15T00:00:00.000Z"); + const rows = [ + { + bucketStart: new Date("2026-04-15T00:00:00.000Z"), + sumValue: 8120, + count: 4, + mean: 2030, + }, + { + bucketStart: new Date("2026-04-16T00:00:00.000Z"), + sumValue: 12480, + count: 5, + mean: 2496, + }, + ]; + mocks.findMany.mockResolvedValueOnce(rows); + + const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since); + + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + bucketStart: new Date("2026-04-15T00:00:00.000Z"), + sumValue: 8120, + count: 4, + mean: 2030, + }); + // Single round-trip — eliminates the legacy per-type chunked + // findMany loop in analytics A2. + expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); + const args = mocks.findMany.mock.calls[0][0]; + expect(args.where).toMatchObject({ + userId: "user-1", + type: "ACTIVITY_STEPS", + granularity: "DAY", + bucketStart: { gte: since }, + }); + expect(args.orderBy).toEqual({ bucketStart: "asc" }); + }); + + it("propagates legacy NULL sum_value rows so the caller can fall back", async () => { + mocks.findMany.mockResolvedValueOnce([ + { + bucketStart: new Date("2026-04-15T00:00:00.000Z"), + sumValue: null, + count: 3, + mean: 1500, + }, + ]); + + const [row] = await readCumulativeDaySums( + "user-1", + "ACTIVITY_STEPS", + new Date("2026-04-15T00:00:00.000Z"), + ); + expect(row.sumValue).toBeNull(); + // Algebraic fallback parity: pre-v1.4.39 buckets still surface + // a usable daily total during the boot-backfill convergence + // window. + expect(resolveBucketSum(row)).toBe(4500); + }); +}); + +describe("readCumulativeDaySumsBatch", () => { + it("groups by type and seeds empty arrays for missing types", async () => { + const since = new Date("2026-04-15T00:00:00.000Z"); + mocks.findMany.mockResolvedValueOnce([ + { + type: "ACTIVITY_STEPS", + bucketStart: new Date("2026-04-15T00:00:00.000Z"), + sumValue: 8120, + count: 4, + mean: 2030, + }, + { + type: "ACTIVITY_STEPS", + bucketStart: new Date("2026-04-16T00:00:00.000Z"), + sumValue: 12480, + count: 5, + mean: 2496, + }, + { + type: "FLIGHTS_CLIMBED", + bucketStart: new Date("2026-04-15T00:00:00.000Z"), + sumValue: 22, + count: 3, + mean: 7.33, + }, + ]); + + const out = await readCumulativeDaySumsBatch( + "user-1", + ["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"], + since, + ); + + expect(out.get("ACTIVITY_STEPS")).toHaveLength(2); + expect(out.get("FLIGHTS_CLIMBED")).toHaveLength(1); + // Type with zero rollup rows still surfaces as an empty array so + // the caller can avoid a `.get(...) ?? []` fallback per branch. + expect(out.get("TIME_IN_DAYLIGHT")).toEqual([]); + // Single findMany over the IN-list — replaces the per-type loop + // that drove the A2 cold path on Marc's tenant. + expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); + const args = mocks.findMany.mock.calls[0][0]; + expect(args.where.type).toEqual({ + in: ["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"], + }); + }); + + it("returns an empty map without querying when types is empty", async () => { + const out = await readCumulativeDaySumsBatch( + "user-1", + [], + new Date("2026-04-15T00:00:00.000Z"), + ); + expect(out.size).toBe(0); + expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("resolveBucketSum", () => { + it("returns sumValue when populated", () => { + expect( + resolveBucketSum({ + bucketStart: new Date(), + sumValue: 7500, + count: 3, + mean: 2500, + }), + ).toBe(7500); + }); + + it("falls back to mean * count when sumValue is null (legacy row)", () => { + expect( + resolveBucketSum({ + bucketStart: new Date(), + sumValue: null, + count: 3, + mean: 2500, + }), + ).toBe(7500); + }); +}); diff --git a/src/lib/measurements/__tests__/rollups.test.ts b/src/lib/measurements/__tests__/rollups.test.ts index a761b92ef..06382789e 100644 --- a/src/lib/measurements/__tests__/rollups.test.ts +++ b/src/lib/measurements/__tests__/rollups.test.ts @@ -155,6 +155,10 @@ describe("recomputeBucketsForMeasurement", () => { mean: 82.5, min_value: 80.0, max_value: 85.0, + // v1.4.39 W-SUM — writer always populates `sum_value` (AVG + // and SUM compose in the same $queryRaw aggregate). 3 * 82.5 + // = 247.5 so the round-trips line up with `mean * count`. + sum_value: 247.5, sd: 2.0, slope: -0.1, r2: 0.9, @@ -184,6 +188,11 @@ describe("recomputeBucketsForMeasurement", () => { ).toBe("DAY"); expect(upsertArg.create.count).toBe(3); expect(upsertArg.create.mean).toBe(82.5); + // v1.4.39 W-SUM — sumValue flows through on both create + update + // halves of the upsert. Cumulative read paths (steps, flights, + // distance, daylight, active-energy) consume this directly. + expect(upsertArg.create.sumValue).toBe(247.5); + expect(upsertArg.update.sumValue).toBe(247.5); // WEEK / MONTH / YEAR — three enqueues against the worker queue. expect(bossSend).toHaveBeenCalledTimes(3); @@ -195,6 +204,73 @@ describe("recomputeBucketsForMeasurement", () => { } }); + it("writes sum_value for cumulative ACTIVITY_STEPS buckets", async () => { + // 5 step samples in a day summing to 12480: emulates an iOS + // batch of HealthKit slices reaching the rollup aggregator. + queryRawUnsafe.mockResolvedValueOnce([ + { + type: "ACTIVITY_STEPS", + bucket_start: new Date("2026-05-10T00:00:00.000Z"), + count: BigInt(5), + mean: 2496, + min_value: 100, + max_value: 9000, + // 100 + 2500 + 9000 + 500 + 380 = 12480 + sum_value: 12480, + sd: 3200, + slope: null, + r2: null, + }, + ]); + getGlobalBossMock.mockReturnValue(null); + + await recomputeBucketsForMeasurement( + "user-7", + "ACTIVITY_STEPS", + new Date("2026-05-10T14:30:00.000Z"), + ); + + expect(upsert).toHaveBeenCalledTimes(1); + const arg = upsert.mock.calls[0][0]; + expect(arg.create.sumValue).toBe(12480); + // mean × count algebraic equivalence — the writer carries SUM + // directly because the aggregator computed it once. Asserting + // both gives parity coverage for the consumer-side fallback. + expect(arg.create.mean * arg.create.count).toBe(12480); + }); + + it("passes through null sum_value when the aggregator returns NULL", async () => { + // Defensive: an empty WHERE-clause window would NEVER return a + // row here (the route filters by day boundaries), but a future + // aggregator change that adds a HAVING clause should still + // surface NULL cleanly rather than coercing to 0. + queryRawUnsafe.mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: new Date("2026-05-10T00:00:00.000Z"), + count: BigInt(1), + mean: 82.5, + min_value: 82.5, + max_value: 82.5, + sum_value: null, + sd: null, + slope: null, + r2: null, + }, + ]); + getGlobalBossMock.mockReturnValue(null); + + await recomputeBucketsForMeasurement( + "user-1", + "WEIGHT", + new Date("2026-05-10T14:30:00.000Z"), + ); + + const arg = upsert.mock.calls[0][0]; + expect(arg.create.sumValue).toBeNull(); + expect(arg.update.sumValue).toBeNull(); + }); + it("deletes the DAY row when the post-mutation aggregate is empty", async () => { // Post-delete recompute: the day now has zero rows. queryRawUnsafe.mockResolvedValueOnce([]); @@ -466,4 +542,28 @@ describe("enqueueBootTimeRollupBackfill", () => { expect(result).toEqual({ enqueued: 0, skipped: 0, error: null }); expect(bossSend).not.toHaveBeenCalled(); }); + + // v1.4.39 W-SUM — the discovery query now unions in users whose + // existing DAY rollup rows carry `sum_value IS NULL`. Re-folding + // converges those rows because `persistRollupRows` always writes the + // new column on upsert; the union keeps the per-type missing-coverage + // gap (v1.4.38.5) and the legacy-NULL backfill on the same indexed + // pass. + it("includes the sum_value IS NULL branch in the discovery query", async () => { + getGlobalBossMock.mockReturnValue({ send: bossSend }); + queryRaw.mockResolvedValueOnce([{ id: "user-with-legacy-rollups" }]); + bossSend.mockResolvedValueOnce("job-id"); + + const result = await enqueueBootTimeRollupBackfill(); + + expect(result).toEqual({ enqueued: 1, skipped: 0, error: null }); + // The discovery SQL surfaces both per-type missing coverage AND + // legacy NULL sum_value rows under one UNION. The text-anchor is + // the only durable assertion at the unit level — the integration + // suite covers the planner shape on real Postgres. + const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray; + const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : ""; + expect(sqlText).toContain("sum_value"); + expect(sqlText).toContain("UNION"); + }); });