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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 13:48:31 +02:00
1 parent 42e35ffbf9
commit aba147152a
4 files changed
+216 -8

No files matched your search

@@ -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),
+25 -8
View File
@@ -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<boolean> {
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
+34
View File
@@ -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<Set<string>> {
if (types.length === 0) return new Set();
const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
const rows = await prisma.$queryRaw<Array<{ type: string }>>`
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));
}
@@ -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<ReturnType<typeof seed>>) {
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();
});
});