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