mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge pull request #1307 from MBombeck/fix/rollup-coverage-history
Count a type as rollup-covered only when its buckets reach its oldest reading
This commit is contained in:
11 files changed
+126
-48
No files matched your search
@@ -866,8 +866,9 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept",
|
||||
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
|
||||
);
|
||||
const tip = page.locator('[data-slot="timeline-point-tip"]');
|
||||
// The chart redraws its targets once the marker's readings arrive, and a
|
||||
// target focused just before that is gone; focus the one that stays.
|
||||
// A CI run focused the target and no tip opened. The cause is not pinned
|
||||
// down (the tips layer unmounts while the chart has no layout, which is
|
||||
// one candidate); focus again until the tip opens.
|
||||
await expect(async () => {
|
||||
await target.focus();
|
||||
await expect(tip).toHaveAttribute("data-t", "fe-1", { timeout: 1_000 });
|
||||
|
||||
@@ -24,11 +24,14 @@ import { joinCoverageProbe } from "@/lib/rollups/coverage-inflight";
|
||||
import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
/**
|
||||
* Per-type coverage map. `true` means at least one DAY rollup row
|
||||
* exists for this `(user, type)` pair; the caller can safely compose
|
||||
* `count / min / max / mean` from the bucket table for this type.
|
||||
* `false` means the bucket table is empty for this type and the caller
|
||||
* must fall back to the live aggregate.
|
||||
* Per-type coverage map. `true` means the DAY buckets for this
|
||||
* `(user, type)` pair reach back to its oldest live reading inside the
|
||||
* fold window, so the caller can compose `count / min / max / mean` from
|
||||
* the bucket table for this type. `false` means they do not (no bucket at
|
||||
* all, or only the newest days: a write hook folded one day before the
|
||||
* history was folded) and the caller must fall back to the live aggregate.
|
||||
* A bucket set that merely exists is not coverage: with one fresh bucket
|
||||
* and years of unfolded readings, the composed count was that one day.
|
||||
*
|
||||
* Types the user has never logged are absent from the map — callers
|
||||
* never need to ask about a type with zero measurements.
|
||||
@@ -42,9 +45,12 @@ export type RollupCoverageMap = Map<string, boolean>;
|
||||
* The distinct-type set is read as a loose index scan: a recursive CTE
|
||||
* that hops from one type to the next along the partial
|
||||
* `(user_id, type, measured_at)` live index, one index descent per type
|
||||
* instead of a walk over every live row the user has. Coverage is an
|
||||
* `EXISTS` per type on the rollup index, which stops at the first DAY
|
||||
* bucket rather than counting them all.
|
||||
* instead of a walk over every live row the user has. Coverage compares,
|
||||
* per type, the oldest DAY bucket (one descent of the rollup index) with
|
||||
* the oldest live reading inside the fold window (one descent of the live
|
||||
* index): the bucket holding a reading starts at or before it, whatever
|
||||
* the account's time zone, so the oldest bucket starting no later than
|
||||
* the oldest reading means the fold reached the start of the history.
|
||||
*
|
||||
* The earlier shape, `SELECT DISTINCT type` joined to a `COUNT(r.*)`
|
||||
* over every DAY bucket, read all of a user's live rows and all of their
|
||||
@@ -62,6 +68,7 @@ export function probeRollupCoverage(
|
||||
}
|
||||
|
||||
async function readRollupCoverage(userId: string): Promise<RollupCoverageMap> {
|
||||
const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; has_buckets: boolean }>
|
||||
>`
|
||||
@@ -89,12 +96,22 @@ async function readRollupCoverage(userId: string): Promise<RollupCoverageMap> {
|
||||
)
|
||||
SELECT
|
||||
t."type"::text AS type,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM measurement_rollups r
|
||||
WHERE r.user_id = ${userId}
|
||||
AND r."type" = t."type"
|
||||
AND r.granularity = 'DAY'
|
||||
COALESCE(
|
||||
(
|
||||
SELECT MIN(r.bucket_start)
|
||||
FROM measurement_rollups r
|
||||
WHERE r.user_id = ${userId}
|
||||
AND r."type" = t."type"
|
||||
AND r.granularity = 'DAY'
|
||||
) <= (
|
||||
SELECT MIN(m.measured_at)
|
||||
FROM measurements m
|
||||
WHERE m.user_id = ${userId}
|
||||
AND m."type" = t."type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m.measured_at >= ${windowStart}
|
||||
),
|
||||
false
|
||||
) AS has_buckets
|
||||
FROM live_types t
|
||||
WHERE t."type" IS NOT NULL
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* The rollup coverage probe reads the distinct live types as a loose index
|
||||
* scan and coverage as a per-type `EXISTS`. The previous shape (a DISTINCT
|
||||
* over every live row joined to a COUNT over every DAY bucket) is kept here
|
||||
* as the oracle: both must return the same map on the same fixture, including
|
||||
* the edges that decide the read-swap — tombstoned types, types with only
|
||||
* coarser buckets, another account's buckets, and an account with nothing.
|
||||
* scan. The previous shape (a DISTINCT over every live row joined to a COUNT
|
||||
* over every DAY bucket) is kept here as the oracle for which types appear
|
||||
* and for the edges that decide the read-swap: tombstoned types, types with
|
||||
* only coarser buckets, another account's buckets, and an account with
|
||||
* nothing. Coverage itself is stricter than the oracle's "any DAY bucket":
|
||||
* the buckets must reach back to the type's oldest live reading, which the
|
||||
* second case pins.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -125,4 +127,50 @@ describe("probeRollupCoverage", () => {
|
||||
expect(empty.size).toBe(0);
|
||||
expect(empty).toEqual(await oracle(EMPTY));
|
||||
});
|
||||
|
||||
it("does not count a type as covered by buckets for its newest days only", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const day = 86_400_000;
|
||||
const base = Date.UTC(2026, 0, 10);
|
||||
// Three weight readings, but only the newest day folded: what a write
|
||||
// hook leaves when it runs before the history was folded.
|
||||
await prisma.measurement.createMany({
|
||||
data: [0, 1, 2].map((i) => ({
|
||||
userId: USER,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + i,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(base + i * day + 9 * 3_600_000),
|
||||
})),
|
||||
});
|
||||
const bucket = (i: number) => ({
|
||||
userId: USER,
|
||||
type: "WEIGHT" as const,
|
||||
granularity: "DAY" as const,
|
||||
bucketStart: new Date(base + i * day),
|
||||
source: "MANUAL" as const,
|
||||
count: 1,
|
||||
mean: 80 + i,
|
||||
minValue: 80 + i,
|
||||
maxValue: 80 + i,
|
||||
});
|
||||
await prisma.measurementRollup.create({ data: bucket(2) });
|
||||
expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(false);
|
||||
|
||||
// Once the fold reaches the oldest reading's day, the type is covered.
|
||||
await prisma.measurementRollup.createMany({ data: [bucket(0), bucket(1)] });
|
||||
expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(true);
|
||||
|
||||
// A reading older than the oldest bucket uncovers it again.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
type: "WEIGHT",
|
||||
value: 79,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(base - day),
|
||||
},
|
||||
});
|
||||
expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 123,
|
||||
"min": 123,
|
||||
"max": 137,
|
||||
"mean": 129.92,
|
||||
"mean": 130,
|
||||
"median": 130,
|
||||
"avg7": 131.14,
|
||||
"avg30": 130,
|
||||
@@ -49,11 +49,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_DIA": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 78,
|
||||
"min": 78,
|
||||
"max": 86,
|
||||
"mean": 81.96,
|
||||
"mean": 81.95,
|
||||
"median": 82,
|
||||
"avg7": 81.43,
|
||||
"avg30": 81.8,
|
||||
@@ -1425,7 +1425,7 @@
|
||||
"meta": {
|
||||
"analytics": {
|
||||
"slim_summaries": {
|
||||
"row_count": 566,
|
||||
"row_count": 624,
|
||||
"type_count": 11,
|
||||
"path": "rollup",
|
||||
"year_over_year_types": 0,
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 123,
|
||||
"min": 123,
|
||||
"max": 137,
|
||||
"mean": 129.92,
|
||||
"mean": 130,
|
||||
"median": 130,
|
||||
"avg7": 131.14,
|
||||
"avg30": 130,
|
||||
@@ -49,11 +49,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_DIA": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 78,
|
||||
"min": 78,
|
||||
"max": 86,
|
||||
"mean": 81.96,
|
||||
"mean": 81.95,
|
||||
"median": 82,
|
||||
"avg7": 81.43,
|
||||
"avg30": 81.8,
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 123,
|
||||
"min": 123,
|
||||
"max": 137,
|
||||
"mean": 129.92,
|
||||
"mean": 130,
|
||||
"median": 130,
|
||||
"avg7": 131.14,
|
||||
"avg30": 130,
|
||||
@@ -49,11 +49,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_DIA": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 78,
|
||||
"min": 78,
|
||||
"max": 86,
|
||||
"mean": 81.96,
|
||||
"mean": 81.95,
|
||||
"median": 82,
|
||||
"avg7": 81.43,
|
||||
"avg30": 81.8,
|
||||
@@ -1293,7 +1293,7 @@
|
||||
"bpInTargetPct": 11,
|
||||
"bpInTargetPct7d": 14,
|
||||
"bpInTargetPct30d": 17,
|
||||
"bpInTargetPctAllTime": 12,
|
||||
"bpInTargetPctAllTime": 13,
|
||||
"bpInTargetPctPriorMonth": 10,
|
||||
"bpInTargetPctPriorYear": null,
|
||||
"bpInTargetCount90": 90,
|
||||
|
||||
@@ -27,11 +27,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 123,
|
||||
"min": 123,
|
||||
"max": 137,
|
||||
"mean": 129.92,
|
||||
"mean": 130,
|
||||
"median": 130,
|
||||
"avg7": 131.14,
|
||||
"avg30": 130,
|
||||
@@ -49,11 +49,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_DIA": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 78,
|
||||
"min": 78,
|
||||
"max": 86,
|
||||
"mean": 81.96,
|
||||
"mean": 81.95,
|
||||
"median": 82,
|
||||
"avg7": 81.43,
|
||||
"avg30": 81.8,
|
||||
@@ -1293,7 +1293,7 @@
|
||||
"bpInTargetPct": 11,
|
||||
"bpInTargetPct7d": 14,
|
||||
"bpInTargetPct30d": 17,
|
||||
"bpInTargetPctAllTime": 12,
|
||||
"bpInTargetPctAllTime": 13,
|
||||
"bpInTargetPctPriorMonth": 10,
|
||||
"bpInTargetPctPriorYear": null,
|
||||
"bpInTargetCount90": 90,
|
||||
|
||||
+4
-4
@@ -27,11 +27,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 123,
|
||||
"min": 123,
|
||||
"max": 137,
|
||||
"mean": 129.92,
|
||||
"mean": 130,
|
||||
"median": 130,
|
||||
"avg7": 131.14,
|
||||
"avg30": 130,
|
||||
@@ -49,11 +49,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_DIA": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 78,
|
||||
"min": 78,
|
||||
"max": 86,
|
||||
"mean": 81.96,
|
||||
"mean": 81.95,
|
||||
"median": 82,
|
||||
"avg7": 81.43,
|
||||
"avg30": 81.8,
|
||||
|
||||
+4
-4
@@ -27,11 +27,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 123,
|
||||
"min": 123,
|
||||
"max": 137,
|
||||
"mean": 129.92,
|
||||
"mean": 130,
|
||||
"median": 130,
|
||||
"avg7": 131.14,
|
||||
"avg30": 130,
|
||||
@@ -49,11 +49,11 @@
|
||||
"avg30LastYear": null
|
||||
},
|
||||
"BLOOD_PRESSURE_DIA": {
|
||||
"count": 91,
|
||||
"count": 120,
|
||||
"latest": 78,
|
||||
"min": 78,
|
||||
"max": 86,
|
||||
"mean": 81.96,
|
||||
"mean": 81.95,
|
||||
"median": 82,
|
||||
"avg7": 81.43,
|
||||
"avg30": 81.8,
|
||||
|
||||
@@ -195,6 +195,10 @@ beforeAll(async () => {
|
||||
const rollups = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
// The whole history, as the boot backfill folds it: the read refresh
|
||||
// alone covers ninety days, and a type folded only that far is not
|
||||
// covered, so the walk would take the live path for it.
|
||||
await rollups.recomputeUserRollups(OWNER_ID);
|
||||
await rollups.ensureUserRollupsFresh(OWNER_ID);
|
||||
await settleBackgroundTasks();
|
||||
owner = await makeSession(ownerRow);
|
||||
|
||||
@@ -362,6 +362,10 @@ beforeAll(async () => {
|
||||
const rollups = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
// The whole history, as the boot backfill folds it: the read refresh
|
||||
// alone covers ninety days, and a type folded only that far is not
|
||||
// covered, so the walk would take the live path for it.
|
||||
await rollups.recomputeUserRollups(OWNER_ID);
|
||||
await rollups.ensureUserRollupsFresh(OWNER_ID);
|
||||
const moodRollups = await vi.importActual<
|
||||
typeof import("@/lib/rollups/mood-rollups")
|
||||
@@ -378,6 +382,10 @@ beforeAll(async () => {
|
||||
});
|
||||
await seedSparseRecord(SPARSE_ID);
|
||||
// Measurement rollups only: the mood tier stays empty on purpose.
|
||||
// The whole history, as the boot backfill folds it: the read refresh
|
||||
// alone covers ninety days, and a type folded only that far is not
|
||||
// covered, so the walk would take the live path for it.
|
||||
await rollups.recomputeUserRollups(SPARSE_ID);
|
||||
await rollups.ensureUserRollupsFresh(SPARSE_ID);
|
||||
await settleBackgroundTasks();
|
||||
sparse = await makeSession(sparseRow);
|
||||
|
||||
Reference in new issue
Block a user