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