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:
Marc-André Bombeck committed 2026-07-02 00:16:02 +02:00
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);
+6 -1
View File
@@ -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);
+33 -9
View File
@@ -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) {