mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): bound the tiered rollup series to the requested window
The week/month/year rollup read anchored on now and always returned the trailing window, so a historic from/to range (API and iOS callers) came back with buckets outside the requested span. Thread the resolved from/to bounds through so the read is bounded on both ends, matching the day-tier and live-SQL fallback contract; tier selection is unchanged.
This commit is contained in:
1 parent
c9f8eb9423
commit
40859613aa
4 files changed
+108
-18
No files matched your search
@@ -112,7 +112,10 @@ describe("readDailySeries — long-range tier step-up", () => {
|
||||
});
|
||||
|
||||
expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.readTieredRollupSeries.mock.calls[0][0].windowDays).toBe(3650);
|
||||
// v1.26.0 SEAM-N2 — the resolved `[from, to]` bounds are threaded so the
|
||||
// tier reads the REQUESTED window, not a trailing "to now" slice.
|
||||
expect(mocks.readTieredRollupSeries.mock.calls[0][0].from).toBe(from);
|
||||
expect(mocks.readTieredRollupSeries.mock.calls[0][0].to).toBe(to);
|
||||
// Whole-history coverage: the earliest 2017 bucket survives.
|
||||
expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z");
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
@@ -79,7 +79,12 @@ export async function readDailySeries(opts: {
|
||||
const tiered = await readTieredRollupSeries({
|
||||
userId,
|
||||
type,
|
||||
windowDays: windowDaysFull,
|
||||
// v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier
|
||||
// reads the REQUESTED window (which may be entirely historic), not a
|
||||
// trailing "to now" slice. `windowDaysFull` still gates entry above and
|
||||
// drives the tier width inside the reader.
|
||||
from,
|
||||
to,
|
||||
priorityJson,
|
||||
});
|
||||
if (tiered && tiered.rows.length > 0) {
|
||||
|
||||
@@ -296,12 +296,23 @@ describe("readBestGranularityRollups — cross-consumer routing parity", () => {
|
||||
* miss; and that the wire shape mirrors the daily reader.
|
||||
*/
|
||||
describe("readTieredRollupSeries", () => {
|
||||
const DAY_MS = 86_400_000;
|
||||
// A fixed "now-ish" anchor for the current-window cases; the historic case
|
||||
// uses its own explicit past bounds. Nothing in the reader reads
|
||||
// `Date.now()` any more, so these are just span endpoints.
|
||||
const NOW = new Date("2026-06-21T00:00:00.000Z");
|
||||
const win = (days: number, to: Date = NOW) => ({
|
||||
from: new Date(to.getTime() - days * DAY_MS),
|
||||
to,
|
||||
});
|
||||
|
||||
it("returns null on a non-positive window without touching the db", async () => {
|
||||
expect(
|
||||
await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
windowDays: 0,
|
||||
from: NOW,
|
||||
to: NOW,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
@@ -320,7 +331,7 @@ describe("readTieredRollupSeries", () => {
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
windowDays: 3650,
|
||||
...win(3650),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -332,12 +343,59 @@ describe("readTieredRollupSeries", () => {
|
||||
expect(result?.rows.length).toBe(10);
|
||||
});
|
||||
|
||||
// v1.26.0 SEAM-N2 — a historic window entirely in the past must bound the
|
||||
// rollup read on BOTH ends. Pre-fix the reader anchored on `now − windowDays`
|
||||
// and would have queried `bucketStart >= now − 730d` (mid-2024 onward),
|
||||
// returning recent buckets for a 2020–2022 ask.
|
||||
it("bounds the rollup read on BOTH ends for a historic past window", async () => {
|
||||
const from = new Date("2020-01-01T00:00:00.000Z");
|
||||
const to = new Date("2022-01-01T00:00:00.000Z");
|
||||
const inWindow: RollupBucketRow[] = [
|
||||
bucket("2020-02-01T00:00:00.000Z", { count: 12, mean: 80 }),
|
||||
bucket("2021-06-01T00:00:00.000Z", { count: 12, mean: 81 }),
|
||||
bucket("2021-12-01T00:00:00.000Z", { count: 12, mean: 82 }),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(inWindow);
|
||||
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
// The `bucketStart` filter carries BOTH the requested lower AND upper
|
||||
// bound — not a trailing "to now" slice.
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.bucketStart).toEqual({ gte: from, lte: to });
|
||||
// Every returned bucket_start falls inside the requested window.
|
||||
for (const row of result?.rows ?? []) {
|
||||
const t = new Date(row.measuredAt).getTime();
|
||||
expect(t).toBeGreaterThanOrEqual(from.getTime());
|
||||
expect(t).toBeLessThanOrEqual(to.getTime());
|
||||
}
|
||||
// Tier width is 2 years (731 days) → still the MONTH tier for >730 d.
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
});
|
||||
|
||||
// The web-chart "All" path passes `to ≈ now`; prove that case still bounds
|
||||
// on both ends (no regression) — the historic fix must not perturb it.
|
||||
it("bounds a current-window (to = now) read on both ends too", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]);
|
||||
const { from, to } = win(3650);
|
||||
await readTieredRollupSeries({ userId: "u", type: "WEIGHT", from, to });
|
||||
expect(findMany.mock.calls[0][0].where.bucketStart).toEqual({
|
||||
gte: from,
|
||||
lte: to,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the WEEK tier for a 1–2 year window", async () => {
|
||||
findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]);
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
windowDays: 540,
|
||||
...win(540),
|
||||
});
|
||||
expect(result?.granularity).toBe("WEEK");
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK");
|
||||
@@ -351,7 +409,7 @@ describe("readTieredRollupSeries", () => {
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
windowDays: 3650,
|
||||
...win(3650),
|
||||
});
|
||||
expect(result?.granularity).toBe("DAY");
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
|
||||
@@ -364,7 +422,7 @@ describe("readTieredRollupSeries", () => {
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
windowDays: 3650,
|
||||
...win(3650),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
// MONTH, WEEK, DAY all probed (never coarser than the target).
|
||||
@@ -384,7 +442,7 @@ describe("readTieredRollupSeries", () => {
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "ACTIVITY_STEPS",
|
||||
windowDays: 3650,
|
||||
...win(3650),
|
||||
});
|
||||
expect(result?.rows[0].value).toBe(240_000);
|
||||
expect(result?.rows[0].minValue).toBeUndefined();
|
||||
@@ -403,7 +461,7 @@ describe("readTieredRollupSeries", () => {
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
windowDays: 3650,
|
||||
...win(3650),
|
||||
});
|
||||
expect(result?.rows[0].value).toBe(81.5);
|
||||
expect(result?.rows[0].minValue).toBe(78);
|
||||
|
||||
@@ -164,6 +164,10 @@ export async function readBestGranularityRollups(
|
||||
type,
|
||||
floor.granularity,
|
||||
since,
|
||||
// Trailing-window semantics: no upper bound. This router serves the
|
||||
// "last N days to now" probes (summaries-slice / health-score); the
|
||||
// requested-window bounding lives on `readTieredRollupSeries`.
|
||||
null,
|
||||
priority,
|
||||
);
|
||||
if (rows && rows.length > 0) {
|
||||
@@ -229,20 +233,27 @@ async function readGranularity(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
granularity: RollupGranularity,
|
||||
since: Date,
|
||||
from: Date,
|
||||
to: Date | null,
|
||||
userPriorityJson: unknown,
|
||||
): Promise<RollupBucketRow[] | null> {
|
||||
// Bounded `findMany`: `(userId, type, granularity, bucketStart, source)`
|
||||
// is the composite primary key so the planner picks the index path
|
||||
// every time. `bucketStart >= since` is the same shape
|
||||
// `readRollupBuckets` uses; we don't carry an upper bound because
|
||||
// the helpers are always reading "trailing window to now".
|
||||
// every time. When `to` is supplied the `bucketStart` filter is bounded
|
||||
// on BOTH ends (`gte from` AND `lte to`) so a caller asking for an
|
||||
// arbitrary historic window — `[2020-01-01, 2022-01-01]` — reads the
|
||||
// buckets INSIDE that window rather than the trailing "to now" slice.
|
||||
// This mirrors the DAY-tier `readRollup` in `daily-series-read.ts`
|
||||
// (`bucketStart: { gte: from, lte: to }`) so the tiered read reproduces
|
||||
// the same window contract the live-SQL fallback bounds on both ends.
|
||||
// `to === null` keeps the legacy trailing-window semantics for the
|
||||
// `readBestGranularityRollups` router.
|
||||
const rows = await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
bucketStart: { gte: since },
|
||||
bucketStart: to === null ? { gte: from } : { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
select: {
|
||||
@@ -346,20 +357,32 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"];
|
||||
* (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a
|
||||
* future tier ever risks an unbounded count it must step coarser rather
|
||||
* than truncate; no silent cap lives on this path.
|
||||
*
|
||||
* v1.26.0 SEAM-N2 — the reader is bounded to the REQUESTED `[from, to]`
|
||||
* window on BOTH ends, not a trailing "now − windowDays … now" slice.
|
||||
* The caller passes arbitrary ISO instants (a historic "All" range whose
|
||||
* `to` need not be ≈ now), so anchoring on `Date.now()` returned the wrong
|
||||
* buckets entirely. The tier SELECTION still keys off the window WIDTH
|
||||
* (`windowDays`, derived from the span) — only the window the tier READS
|
||||
* changed. This matches the DAY-tier `readRollup` + the live-SQL
|
||||
* `readLiveDaily` fallback, both of which bound on `[from, to]`.
|
||||
*/
|
||||
export async function readTieredRollupSeries(opts: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
windowDays: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
priorityJson?: unknown;
|
||||
}): Promise<{
|
||||
granularity: RollupGranularity;
|
||||
rows: TieredSeriesRow[];
|
||||
} | null> {
|
||||
const { userId, type, windowDays, priorityJson } = opts;
|
||||
const { userId, type, from, to, priorityJson } = opts;
|
||||
// Tier selection keys off the window WIDTH; derive it the same way the
|
||||
// caller (`daily-series-read`) does so the chosen tier is identical.
|
||||
const windowDays = Math.ceil((to.getTime() - from.getTime()) / 86_400_000);
|
||||
if (!Number.isFinite(windowDays) || windowDays <= 0) return null;
|
||||
|
||||
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
|
||||
const priority =
|
||||
priorityJson !== undefined
|
||||
? priorityJson
|
||||
@@ -375,7 +398,8 @@ export async function readTieredRollupSeries(opts: {
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
since,
|
||||
from,
|
||||
to,
|
||||
priority,
|
||||
);
|
||||
if (rows && rows.length > 0) {
|
||||
|
||||
Reference in new issue
Block a user