From 42e35ffbf95151d72ef9434ff95baab24b9cc30e 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 1/2] test(e2e): follow the restore to its job before reading the record The restore route answers 202 with a job id and leaves the work to the backup-restore queue. The journey still expected the synchronous 200 and read the record straight after the click. It now asserts the queued answer and its status route, polls that route until the job reports succeeded (a failed job ends the wait with its code), and only then checks what the restore put back and dropped. The job runs on the pg-boss worker inside the server under test, as the manual backup pass earlier in the same file already relies on. --- e2e/backup-restore-journey.spec.ts | 35 ++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts index ec8cb61bc..9db3722d2 100644 --- a/e2e/backup-restore-journey.spec.ts +++ b/e2e/backup-restore-journey.spec.ts @@ -532,7 +532,38 @@ test.describe("Backup and restore, through the settings surfaces", () => { await openBackupsConsole(page); const response = await restoreThroughDialog(page, requireSnapshot()); - expect(response.status()).toBe(200); + // The restore runs on the `backup-restore` queue: the request answers 202 + // with the job and its status route, and the worker inside the server + // under test does the work. Nothing below is read until the job says it + // is done, so every assertion is about the finished restore rather than + // a moment in the middle of it. + expect(response.status()).toBe(202); + const queued = (await response.json()) as { + data: { jobId: string; status: string; statusUrl: string }; + }; + expect(queued.data.status).toBe("queued"); + expect(queued.data.statusUrl).toBe( + `/api/admin/backups/restores/${queued.data.jobId}`, + ); + await expect + .poll( + async () => { + const res = await page.request.get(queued.data.statusUrl); + const body = (await res.json()) as { + data: { + status: string; + failure: { code: string } | null; + } | null; + }; + // A failed job ends the wait with its code in the message instead + // of running the clock out on a state that will never change. + return body.data?.status === "failed" + ? `failed: ${body.data.failure?.code ?? "unknown"}` + : body.data?.status; + }, + { timeout: 60_000, intervals: [250, 500, 1_000] }, + ) + .toBe("succeeded"); const afterRestore = await storedMeasurements(); @@ -564,7 +595,7 @@ test.describe("Backup and restore, through the settings surfaces", () => { // And the reading that arrived after the snapshot is gone. // `docs/ops/backup-restore.md` says the restore "replaces the account's - // data tables", and the route's own words are "one transaction replaces + // data tables", and `restore-backup.ts` puts it as "one transaction replaces // every serialized owner-scoped class". Replacing is not merging: whoever // restores yesterday's copy loses today's readings, and that is the // documented behaviour rather than an accident of the implementation. 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 2/2] 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(); + }); +});