mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user