From aba147152aba6b61f0764b2f6f1727e6f300f47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 13:48:31 +0200 Subject: [PATCH] fix(dashboard): stop pre-window readings holding the second phase back The snapshot's second phase (blood pressure in target, the per-context glucose tiles, the Health Score) waits until weight, both blood-pressure halves and steps have DAY buckets. The fold writes buckets only inside the five-year window, so a type whose every reading is older than that never gets one and the phase stayed null for good. Since the Apple Health import now folds only inside the window too, an account that imported years-old blood pressure or steps and nothing since lost those tiles and its score with no explanation. An uncovered gate type now holds the phase back only when it has a reading inside the window, which is the case the backfill will still reach. The extra query runs on the miss path alone and asks one indexed EXISTS per uncovered type. --- src/lib/dashboard/__tests__/snapshot.test.ts | 5 + src/lib/dashboard/snapshot.ts | 33 +++- src/lib/rollups/measurement-coverage.ts | 34 ++++ ...ashboard-snapshot-pre-window-types.test.ts | 152 ++++++++++++++++++ 4 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 tests/integration/dashboard-snapshot-pre-window-types.test.ts diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 25de8ee14..3becb2ead 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -29,6 +29,11 @@ vi.mock("@/lib/analytics/summaries-slice", () => ({ vi.mock("@/lib/rollups/measurement-coverage", () => ({ probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a), isFullyCovered: (...a: unknown[]) => isFullyCovered(...a), + // Every uncovered type asked about has readings inside the fold window, so + // a miss here is a real miss. The pre-window case runs against Postgres in + // `tests/integration/dashboard-snapshot-pre-window-types.test.ts`. + typesWithReadingsInFoldWindow: async (_userId: string, types: string[]) => + new Set(types), })); vi.mock("@/lib/rollups/mood-rollups", () => ({ readMoodDayRollups: (...a: unknown[]) => readMoodDayRollups(...a), diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index d6d1e2baa..6d0f074a7 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -66,6 +66,7 @@ import { } from "@/lib/targets/weight-trend"; import { probeRollupCoverage, + typesWithReadingsInFoldWindow, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; @@ -1091,18 +1092,34 @@ function buildLayoutCatalogue( * cheap (empty reads) and absence must not cold the phase. A type * present-but-`false` has live rows without buckets — for these dense types * that is exactly the multi-second live fallback the snapshot must never - * wait on, so the phase stays `null` until the backfill converges. + * wait on, so the phase stays `null` until the backfill converges — unless + * none of its readings lies inside the fold window. The fold never writes a + * bucket older than `ROLLUP_FOLD_WINDOW_MS`, so such a type is not waiting + * for anything: counting it as a miss held the phase `null` for good on an + * account whose blood pressure, say, was all imported history. Its live reads + * cover only rows older than the window, and the window reads come back + * empty. The second query runs on the miss path alone. * An empty map is a fresh account with no measurements at all — nothing to * compute, so the thick phase stays `null`. */ -function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { +const THICK_PHASE_GATE_TYPES = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "ACTIVITY_STEPS", +] as const; + +async function isThickPhaseWarm( + userId: string, + coverage: RollupCoverageMap, +): Promise { if (coverage.size === 0) return false; - return ( - coverage.get("WEIGHT") !== false && - coverage.get("BLOOD_PRESSURE_SYS") !== false && - coverage.get("BLOOD_PRESSURE_DIA") !== false && - coverage.get("ACTIVITY_STEPS") !== false + const uncovered = THICK_PHASE_GATE_TYPES.filter( + (type) => coverage.get(type) === false, ); + if (uncovered.length === 0) return true; + const foldable = await typesWithReadingsInFoldWindow(userId, uncovered); + return foldable.size === 0; } /** @@ -1262,7 +1279,7 @@ export async function buildDashboardSnapshot( // paint-together vs slowest-wins). Unrelated uncovered types do NOT // cold the phase (see `isThickPhaseWarm`). const coverage = await time("coverage", () => probeRollupCoverage(user.id)); - const warm = isThickPhaseWarm(coverage); + const warm = await isThickPhaseWarm(user.id, coverage); // Resolve the stored layout once up front: the hero score rings read // the `selectedScoreRings` preference off it, and the module-gated diff --git a/src/lib/rollups/measurement-coverage.ts b/src/lib/rollups/measurement-coverage.ts index 46cd15a0b..04f19bc36 100644 --- a/src/lib/rollups/measurement-coverage.ts +++ b/src/lib/rollups/measurement-coverage.ts @@ -20,6 +20,7 @@ * read regardless of how many types the user has logged. */ import { prisma } from "@/lib/db"; +import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; /** * Per-type coverage map. `true` means at least one DAY rollup row @@ -82,3 +83,36 @@ export function isFullyCovered(coverage: RollupCoverageMap): boolean { } return true; } + +/** + * Of `types`, the ones this user has a live reading of inside the fold + * window — the only ones the fold can ever give a bucket. + * + * The fold writes buckets inside `ROLLUP_FOLD_WINDOW_MS` and nowhere else, so + * a type whose every reading is older than that reports `false` in + * `probeRollupCoverage` for good. A caller that waits for coverage before + * taking a path has to tell that apart from a type the backfill simply has + * not reached yet, or it waits forever. One indexed `EXISTS` per type on + * `(user_id, type, measured_at)`; callers ask only about the types their + * probe found uncovered. + */ +export async function typesWithReadingsInFoldWindow( + userId: string, + types: readonly string[], +): Promise> { + if (types.length === 0) return new Set(); + const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); + const rows = await prisma.$queryRaw>` + SELECT candidate."type" + FROM unnest(${[...types]}::text[]) AS candidate("type") + WHERE EXISTS ( + SELECT 1 + FROM measurements m + WHERE m.user_id = ${userId} + AND m."type" = candidate."type"::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${windowStart} + ) + `; + return new Set(rows.map((row) => row.type)); +} diff --git a/tests/integration/dashboard-snapshot-pre-window-types.test.ts b/tests/integration/dashboard-snapshot-pre-window-types.test.ts new file mode 100644 index 000000000..fecc3465e --- /dev/null +++ b/tests/integration/dashboard-snapshot-pre-window-types.test.ts @@ -0,0 +1,152 @@ +/** + * The dashboard's second phase (blood-pressure in target, the per-context + * glucose tiles, the Health Score) waits until the rollup tier covers the + * types it reads: weight, both blood-pressure halves and steps. The fold only + * ever writes buckets inside `ROLLUP_FOLD_WINDOW_MS`, by design, so a type + * whose every reading is older than that can never be covered. Counting it as + * "not yet covered" held the phase back for good: an account that imported + * years-old blood pressure and nothing since lost its glucose tiles, its + * in-target figure and its score, with nothing to say why. + * + * Real Postgres, real fold, real builder. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { buildDashboardSnapshot } from "@/lib/dashboard/snapshot"; +import { + recomputeUserRollups, + ROLLUP_FOLD_WINDOW_MS, +} from "@/lib/rollups/measurement-rollups"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function seed(options: { recentBloodPressure: boolean }) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "pre-window-types", + email: "pre-window-types@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + }); + const now = Date.now(); + const beforeWindow = new Date(now - ROLLUP_FOLD_WINDOW_MS - 400 * DAY_MS); + const bpAt = options.recentBloodPressure + ? new Date(now - 2 * DAY_MS) + : beforeWindow; + + await prisma.measurement.createMany({ + data: [ + { + userId: user.id, + type: "WEIGHT", + value: 78, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(now - DAY_MS), + }, + { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + value: 118, + unit: "mmHg", + source: "APPLE_HEALTH", + measuredAt: bpAt, + }, + { + userId: user.id, + type: "BLOOD_PRESSURE_DIA", + value: 76, + unit: "mmHg", + source: "APPLE_HEALTH", + measuredAt: bpAt, + }, + { + userId: user.id, + type: "ACTIVITY_STEPS", + value: 8421, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: beforeWindow, + }, + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source: "MANUAL", + measuredAt: new Date(now - DAY_MS), + glucoseContext: "FASTING", + }, + ], + }); + // The fold every path runs: the trailing window, nothing older. + await recomputeUserRollups(user.id); + return user; +} + +function snapshotFor(user: Awaited>) { + return buildDashboardSnapshot( + getPrismaClient(), + { + id: user.id, + username: user.username, + displayName: null, + timezone: user.timezone, + heightCm: null, + dateOfBirth: null, + gender: null, + glucoseUnit: null, + onboardingTourCompleted: true, + insightsCachedText: null, + insightsCachedAt: null, + insightsCachedLocale: null, + dashboardWidgetsJson: null, + thresholdsJson: null, + healthScoreConfigJson: null, + }, + { hasProvider: async () => false }, + ); +} + +describe("dashboard snapshot with readings older than the fold window", () => { + it("does not hold the second phase back for types the fold can never cover", async () => { + const user = await seed({ recentBloodPressure: false }); + + // The premise: the old types really have no buckets, and never will. + const buckets = await getPrismaClient().measurementRollup.findMany({ + where: { userId: user.id, granularity: "DAY" }, + select: { type: true }, + }); + const covered = new Set(buckets.map((b) => b.type)); + expect(covered.has("BLOOD_PRESSURE_SYS")).toBe(false); + expect(covered.has("ACTIVITY_STEPS")).toBe(false); + expect(covered.has("WEIGHT")).toBe(true); + + const snap = await snapshotFor(user); + + expect(snap.extras).not.toBeNull(); + expect(snap.extras?.glucoseByContext.FASTING?.count).toBe(1); + // The old readings are still the account's: the tile strip reports them. + expect(snap.tiles.summaries.BLOOD_PRESSURE_SYS?.latest).toBe(118); + }); + + it("still waits while a type inside the window has no buckets yet", async () => { + const user = await seed({ recentBloodPressure: true }); + // A reading the fold has not reached yet: inside the window, no bucket. + await getPrismaClient().measurementRollup.deleteMany({ + where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" }, + }); + + const snap = await snapshotFor(user); + + expect(snap.extras).toBeNull(); + }); +});