From 3d5d5fc4ff9cacac04d8fde5a4d9849b8d6850ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 31 Oct 2026 11:25:04 +0100 Subject: [PATCH 1/3] fix(rollups): count a type as covered only when its buckets reach its oldest reading The coverage probe called a type covered as soon as one DAY bucket existed. A write hook folds the day it writes, so a type whose history was not yet folded (an account before its backfill, or a read racing the detached refresh) had one fresh bucket, took the bucket-composed path, and its summary counted that one day: three weight readings read as one. A type is now covered when its oldest DAY bucket starts no later than its oldest live reading inside the fold window. The bucket holding a reading starts at or before it in any time zone, so the comparison holds without knowing the account's day boundaries. Both sides are one index descent per type; anything not covered falls back to the live aggregate as before. --- src/lib/rollups/measurement-coverage.ts | 45 +++++++++----- .../integration/rollup-coverage-probe.test.ts | 58 +++++++++++++++++-- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/lib/rollups/measurement-coverage.ts b/src/lib/rollups/measurement-coverage.ts index 9a28ff997..d9703e9b0 100644 --- a/src/lib/rollups/measurement-coverage.ts +++ b/src/lib/rollups/measurement-coverage.ts @@ -24,11 +24,14 @@ import { joinCoverageProbe } from "@/lib/rollups/coverage-inflight"; import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; /** - * Per-type coverage map. `true` means at least one DAY rollup row - * exists for this `(user, type)` pair; the caller can safely compose - * `count / min / max / mean` from the bucket table for this type. - * `false` means the bucket table is empty for this type and the caller - * must fall back to the live aggregate. + * Per-type coverage map. `true` means the DAY buckets for this + * `(user, type)` pair reach back to its oldest live reading inside the + * fold window, so the caller can compose `count / min / max / mean` from + * the bucket table for this type. `false` means they do not (no bucket at + * all, or only the newest days: a write hook folded one day before the + * history was folded) and the caller must fall back to the live aggregate. + * A bucket set that merely exists is not coverage: with one fresh bucket + * and years of unfolded readings, the composed count was that one day. * * Types the user has never logged are absent from the map — callers * never need to ask about a type with zero measurements. @@ -42,9 +45,12 @@ export type RollupCoverageMap = Map; * The distinct-type set is read as a loose index scan: a recursive CTE * that hops from one type to the next along the partial * `(user_id, type, measured_at)` live index, one index descent per type - * instead of a walk over every live row the user has. Coverage is an - * `EXISTS` per type on the rollup index, which stops at the first DAY - * bucket rather than counting them all. + * instead of a walk over every live row the user has. Coverage compares, + * per type, the oldest DAY bucket (one descent of the rollup index) with + * the oldest live reading inside the fold window (one descent of the live + * index): the bucket holding a reading starts at or before it, whatever + * the account's time zone, so the oldest bucket starting no later than + * the oldest reading means the fold reached the start of the history. * * The earlier shape, `SELECT DISTINCT type` joined to a `COUNT(r.*)` * over every DAY bucket, read all of a user's live rows and all of their @@ -62,6 +68,7 @@ export function probeRollupCoverage( } async function readRollupCoverage(userId: string): Promise { + const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); const rows = await prisma.$queryRaw< Array<{ type: string; has_buckets: boolean }> >` @@ -89,12 +96,22 @@ async function readRollupCoverage(userId: string): Promise { ) SELECT t."type"::text AS type, - EXISTS ( - SELECT 1 - FROM measurement_rollups r - WHERE r.user_id = ${userId} - AND r."type" = t."type" - AND r.granularity = 'DAY' + COALESCE( + ( + SELECT MIN(r.bucket_start) + FROM measurement_rollups r + WHERE r.user_id = ${userId} + AND r."type" = t."type" + AND r.granularity = 'DAY' + ) <= ( + SELECT MIN(m.measured_at) + FROM measurements m + WHERE m.user_id = ${userId} + AND m."type" = t."type" + AND m."deleted_at" IS NULL + AND m.measured_at >= ${windowStart} + ), + false ) AS has_buckets FROM live_types t WHERE t."type" IS NOT NULL diff --git a/tests/integration/rollup-coverage-probe.test.ts b/tests/integration/rollup-coverage-probe.test.ts index 2d4b118ee..05f99cf44 100644 --- a/tests/integration/rollup-coverage-probe.test.ts +++ b/tests/integration/rollup-coverage-probe.test.ts @@ -1,10 +1,12 @@ /** * The rollup coverage probe reads the distinct live types as a loose index - * scan and coverage as a per-type `EXISTS`. The previous shape (a DISTINCT - * over every live row joined to a COUNT over every DAY bucket) is kept here - * as the oracle: both must return the same map on the same fixture, including - * the edges that decide the read-swap — tombstoned types, types with only - * coarser buckets, another account's buckets, and an account with nothing. + * scan. The previous shape (a DISTINCT over every live row joined to a COUNT + * over every DAY bucket) is kept here as the oracle for which types appear + * and for the edges that decide the read-swap: tombstoned types, types with + * only coarser buckets, another account's buckets, and an account with + * nothing. Coverage itself is stricter than the oracle's "any DAY bucket": + * the buckets must reach back to the type's oldest live reading, which the + * second case pins. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -125,4 +127,50 @@ describe("probeRollupCoverage", () => { expect(empty.size).toBe(0); expect(empty).toEqual(await oracle(EMPTY)); }); + + it("does not count a type as covered by buckets for its newest days only", async () => { + const prisma = getPrismaClient(); + const day = 86_400_000; + const base = Date.UTC(2026, 0, 10); + // Three weight readings, but only the newest day folded: what a write + // hook leaves when it runs before the history was folded. + await prisma.measurement.createMany({ + data: [0, 1, 2].map((i) => ({ + userId: USER, + type: "WEIGHT" as const, + value: 80 + i, + unit: "kg", + measuredAt: new Date(base + i * day + 9 * 3_600_000), + })), + }); + const bucket = (i: number) => ({ + userId: USER, + type: "WEIGHT" as const, + granularity: "DAY" as const, + bucketStart: new Date(base + i * day), + source: "MANUAL" as const, + count: 1, + mean: 80 + i, + minValue: 80 + i, + maxValue: 80 + i, + }); + await prisma.measurementRollup.create({ data: bucket(2) }); + expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(false); + + // Once the fold reaches the oldest reading's day, the type is covered. + await prisma.measurementRollup.createMany({ data: [bucket(0), bucket(1)] }); + expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(true); + + // A reading older than the oldest bucket uncovers it again. + await prisma.measurement.create({ + data: { + userId: USER, + type: "WEIGHT", + value: 79, + unit: "kg", + measuredAt: new Date(base - day), + }, + }); + expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(false); + }); }); From 2d1f3fef1ec1a7bce1826d7dc61f15b3fe046741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 31 Oct 2026 11:25:41 +0100 Subject: [PATCH 2/3] test(e2e): say what is known about the missed lab-reading focus The comment named a cause that was never observed. --- e2e/timeline.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 4ade3ce8b..ad5f3d0c7 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -866,8 +866,9 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept", '[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]', ); const tip = page.locator('[data-slot="timeline-point-tip"]'); - // The chart redraws its targets once the marker's readings arrive, and a - // target focused just before that is gone; focus the one that stays. + // A CI run focused the target and no tip opened. The cause is not pinned + // down (the tips layer unmounts while the chart has no layout, which is + // one candidate); focus again until the tip opens. await expect(async () => { await target.focus(); await expect(tip).toHaveAttribute("data-t", "fe-1", { timeout: 1_000 }); From defa43d46c2ad24500de4b5d14c4d8ce5432d708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 31 Oct 2026 11:54:28 +0100 Subject: [PATCH 3/3] test(parity): fold the whole fixture history so the walk keeps the rollup path The fixtures built their rollups with the read refresh, which folds ninety days. Under the stricter coverage a type folded only that far takes the live path; the fixtures now fold the whole history first, as the boot backfill does, so the walk still reads the rollup tier. The analytics snapshots change only in what the old coverage hid: the summaries count all 120 weight and blood-pressure readings instead of the 91 the folded window held, with the means and the all-time in-target share that follow from them. --- .../analytics.02-summaries-cold.json.snap | 10 +++++----- .../analytics.03-summaries-warm.json.snap | 8 ++++---- .../__snapshots__/analytics.04-default-cold.json.snap | 10 +++++----- .../__snapshots__/analytics.05-default-warm.json.snap | 10 +++++----- .../analytics.06-summaries-modules-off.json.snap | 8 ++++---- .../analytics.07-default-de-personal-target.json.snap | 8 ++++---- .../integration/routes/parity/analytics.parity.test.ts | 4 ++++ .../parity/insights-comprehensive.parity.test.ts | 8 ++++++++ 8 files changed, 39 insertions(+), 27 deletions(-) diff --git a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap index ff13f7158..f4c49c6c7 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap @@ -27,11 +27,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_SYS": { - "count": 91, + "count": 120, "latest": 123, "min": 123, "max": 137, - "mean": 129.92, + "mean": 130, "median": 130, "avg7": 131.14, "avg30": 130, @@ -49,11 +49,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_DIA": { - "count": 91, + "count": 120, "latest": 78, "min": 78, "max": 86, - "mean": 81.96, + "mean": 81.95, "median": 82, "avg7": 81.43, "avg30": 81.8, @@ -1425,7 +1425,7 @@ "meta": { "analytics": { "slim_summaries": { - "row_count": 566, + "row_count": 624, "type_count": 11, "path": "rollup", "year_over_year_types": 0, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap index 5a7a94006..08cc16339 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap @@ -27,11 +27,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_SYS": { - "count": 91, + "count": 120, "latest": 123, "min": 123, "max": 137, - "mean": 129.92, + "mean": 130, "median": 130, "avg7": 131.14, "avg30": 130, @@ -49,11 +49,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_DIA": { - "count": 91, + "count": 120, "latest": 78, "min": 78, "max": 86, - "mean": 81.96, + "mean": 81.95, "median": 82, "avg7": 81.43, "avg30": 81.8, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap index b53383052..f077c24f2 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap @@ -27,11 +27,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_SYS": { - "count": 91, + "count": 120, "latest": 123, "min": 123, "max": 137, - "mean": 129.92, + "mean": 130, "median": 130, "avg7": 131.14, "avg30": 130, @@ -49,11 +49,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_DIA": { - "count": 91, + "count": 120, "latest": 78, "min": 78, "max": 86, - "mean": 81.96, + "mean": 81.95, "median": 82, "avg7": 81.43, "avg30": 81.8, @@ -1293,7 +1293,7 @@ "bpInTargetPct": 11, "bpInTargetPct7d": 14, "bpInTargetPct30d": 17, - "bpInTargetPctAllTime": 12, + "bpInTargetPctAllTime": 13, "bpInTargetPctPriorMonth": 10, "bpInTargetPctPriorYear": null, "bpInTargetCount90": 90, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap index c84149b13..90a9545cb 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap @@ -27,11 +27,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_SYS": { - "count": 91, + "count": 120, "latest": 123, "min": 123, "max": 137, - "mean": 129.92, + "mean": 130, "median": 130, "avg7": 131.14, "avg30": 130, @@ -49,11 +49,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_DIA": { - "count": 91, + "count": 120, "latest": 78, "min": 78, "max": 86, - "mean": 81.96, + "mean": 81.95, "median": 82, "avg7": 81.43, "avg30": 81.8, @@ -1293,7 +1293,7 @@ "bpInTargetPct": 11, "bpInTargetPct7d": 14, "bpInTargetPct30d": 17, - "bpInTargetPctAllTime": 12, + "bpInTargetPctAllTime": 13, "bpInTargetPctPriorMonth": 10, "bpInTargetPctPriorYear": null, "bpInTargetCount90": 90, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap index 2e0f796dc..7cf513ac4 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap @@ -27,11 +27,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_SYS": { - "count": 91, + "count": 120, "latest": 123, "min": 123, "max": 137, - "mean": 129.92, + "mean": 130, "median": 130, "avg7": 131.14, "avg30": 130, @@ -49,11 +49,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_DIA": { - "count": 91, + "count": 120, "latest": 78, "min": 78, "max": 86, - "mean": 81.96, + "mean": 81.95, "median": 82, "avg7": 81.43, "avg30": 81.8, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap index e19efe263..191bb9d1a 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap @@ -27,11 +27,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_SYS": { - "count": 91, + "count": 120, "latest": 123, "min": 123, "max": 137, - "mean": 129.92, + "mean": 130, "median": 130, "avg7": 131.14, "avg30": 130, @@ -49,11 +49,11 @@ "avg30LastYear": null }, "BLOOD_PRESSURE_DIA": { - "count": 91, + "count": 120, "latest": 78, "min": 78, "max": 86, - "mean": 81.96, + "mean": 81.95, "median": 82, "avg7": 81.43, "avg30": 81.8, diff --git a/tests/integration/routes/parity/analytics.parity.test.ts b/tests/integration/routes/parity/analytics.parity.test.ts index 72fb3c738..d68a489d9 100644 --- a/tests/integration/routes/parity/analytics.parity.test.ts +++ b/tests/integration/routes/parity/analytics.parity.test.ts @@ -195,6 +195,10 @@ beforeAll(async () => { const rollups = await vi.importActual< typeof import("@/lib/rollups/measurement-rollups") >("@/lib/rollups/measurement-rollups"); + // The whole history, as the boot backfill folds it: the read refresh + // alone covers ninety days, and a type folded only that far is not + // covered, so the walk would take the live path for it. + await rollups.recomputeUserRollups(OWNER_ID); await rollups.ensureUserRollupsFresh(OWNER_ID); await settleBackgroundTasks(); owner = await makeSession(ownerRow); diff --git a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts index e8275e128..72c1ae5b9 100644 --- a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts +++ b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts @@ -362,6 +362,10 @@ beforeAll(async () => { const rollups = await vi.importActual< typeof import("@/lib/rollups/measurement-rollups") >("@/lib/rollups/measurement-rollups"); + // The whole history, as the boot backfill folds it: the read refresh + // alone covers ninety days, and a type folded only that far is not + // covered, so the walk would take the live path for it. + await rollups.recomputeUserRollups(OWNER_ID); await rollups.ensureUserRollupsFresh(OWNER_ID); const moodRollups = await vi.importActual< typeof import("@/lib/rollups/mood-rollups") @@ -378,6 +382,10 @@ beforeAll(async () => { }); await seedSparseRecord(SPARSE_ID); // Measurement rollups only: the mood tier stays empty on purpose. + // The whole history, as the boot backfill folds it: the read refresh + // alone covers ninety days, and a type folded only that far is not + // covered, so the walk would take the live path for it. + await rollups.recomputeUserRollups(SPARSE_ID); await rollups.ensureUserRollupsFresh(SPARSE_ID); await settleBackgroundTasks(); sparse = await makeSession(sparseRow);