From 13860422cf82656f1b0519a17958445466358b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 07:46:50 +0100 Subject: [PATCH] fix(series): show a record's history older than the rollups on the All range A range longer than a year reads its series from the week or month rollups, which are kept for the last five years. When the rollups held any rows the read returned them alone, so on a record that goes back further the All range started where the rollups start: a weight history from 2015 charted from late 2021. The readings older than the first rollup day are folded live at the tier's grain and lead the series; the head ends where the first tier bucket begins, so no month is counted twice. One indexed lookup when there are none. --- src/lib/measurements/daily-series-read.ts | 84 +++++++++++++- src/lib/rollups/measurement-read-wmy.ts | 3 + .../series-all-range-history.test.ts | 108 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/integration/series-all-range-history.test.ts diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 702af8782..9b40b2ca5 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -104,7 +104,7 @@ export async function readDailySeries(opts: { }); } if (tiered && tiered.rows.length > 0) { - return tiered.rows; + return withLiveHead(tiered, { userId, type, from, priorityJson, opts }); } // Coverage miss at every tier — fall through to the daily path, which // folds the live table. The daily fallback still caps at `cap`; the @@ -146,6 +146,88 @@ export async function readDailySeries(opts: { return rows; } +/** The shortest bucket of a live grain, in days (a month is at least 28). */ +const GRAIN_MIN_DAYS: Record, number> = { + daily: 1, + weekly: 7, + monthly: 28, +}; + +/** The live grain that matches a rollup tier's buckets. */ +const TIER_GRAIN: Record> = { + DAY: "daily", + WEEK: "weekly", + MONTH: "monthly", +}; + +/** + * The tiered rollup rows, with the window's older history in front of them. + * + * Rollups are kept for the last five years (`ROLLUP_FOLD_WINDOW_MS`), so on + * a record that goes back further the tier read starts where they start, and + * the "All" range showed nothing before it: a weight history from 2015 + * charted from late 2021. The readings older than the first rollup day are + * folded live, at the tier's grain, and lead the series. One indexed lookup + * when there are none. + */ +async function withLiveHead( + tiered: NonNullable>>, + ctx: { + userId: string; + type: MeasurementType; + from: Date; + priorityJson: unknown; + opts: { timeZone?: string }; + }, +): Promise { + const { userId, type, from, priorityJson } = ctx; + const grain = TIER_GRAIN[tiered.granularity]; + // The first tier row's bucket holds the first rollup day; the head ends + // where that bucket starts, so no reading is counted in both. + const firstRowAt = new Date(tiered.rows[0]!.measuredAt); + const headEnd = new Date( + Math.min(tiered.firstDay.getTime(), firstRowAt.getTime() - 12 * 3_600_000), + ); + if (!grain || headEnd <= from) return tiered.rows; + const older = await prisma.measurement.findFirst({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: from, lt: headEnd }, + }, + select: { id: true }, + }); + if (!older) return tiered.rows; + const timeZone = ctx.opts.timeZone ?? (await resolveUserTimezone(userId)); + const head = await readLiveBuckets({ + userId, + type, + from, + to: new Date(headEnd.getTime() - 1), + // Enough buckets for the whole head (the grain's default cap is sized + // for a window, and the head of an "All" range can be years long). + cap: Math.max( + BUCKET_CAP[grain], + Math.ceil( + (headEnd.getTime() - from.getTime()) / + (GRAIN_MIN_DAYS[grain] * 86_400_000), + ) + 2, + ), + priorityJson, + grain, + timeZone, + }); + annotate({ + meta: { + tiered_series_live_head: head.length, + type, + granularity: tiered.granularity, + }, + }); + return [...head, ...tiered.rows]; +} + /** SQL `date_trunc` unit per aggregate grain (a closed map, never input). */ const TRUNC_UNIT: Record, string> = { daily: "day", diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 5dacae788..7ba0e5648 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -394,6 +394,8 @@ export async function readTieredRollupSeries(opts: { }): Promise<{ granularity: RollupGranularity; rows: TieredSeriesRow[]; + /** The first DAY bucket in the window: where the rollups' history starts. */ + firstDay: Date; } | null> { const { userId, type, from, to, priorityJson } = opts; // Tier selection keys off the window WIDTH; derive it the same way the @@ -454,6 +456,7 @@ export async function readTieredRollupSeries(opts: { }); return { granularity, + firstDay: first, rows: rows.map((r) => ({ type, // The chart's quantity at every tier: the average day of the bucket, diff --git a/tests/integration/series-all-range-history.test.ts b/tests/integration/series-all-range-history.test.ts new file mode 100644 index 000000000..c173948bb --- /dev/null +++ b/tests/integration/series-all-range-history.test.ts @@ -0,0 +1,108 @@ +/** + * The "All" range reaches a record's whole history. + * + * Rollups are kept for the last five years. A range longer than a year reads + * its series from the week or month rollups, and on a record that goes back + * further it used to start where the rollups start: a weight history from + * 2018 charted from five years ago. The older readings are folded live at + * the same grain and lead the series, without a month counted twice where + * the two meet. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { readDailySeries } from "@/lib/measurements/daily-series-read"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY = 86_400_000; + +beforeAll(async () => { + await truncateAllTables(prisma); +}); + +afterAll(async () => { + await truncateAllTables(prisma); +}); + +describe("the All range over a history older than the rollups", () => { + it("leads with the readings the rollups do not hold", async () => { + const user = await makeUser({ timezone: "Europe/Berlin" }); + const now = Date.now(); + // A weekly weight from eight years ago until now. + const weeks = Math.floor((8 * 365) / 7); + await prisma.measurement.createMany({ + data: Array.from({ length: weeks }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80 + (i % 10) / 10, + unit: "kg", + measuredAt: new Date(now - (weeks - i) * 7 * DAY), + source: "MANUAL" as const, + })), + }); + await recomputeUserRollups(user.id, { types: ["WEIGHT"] }); + const oldestRollup = await prisma.measurementRollup.aggregate({ + where: { userId: user.id, type: "WEIGHT", granularity: "DAY" }, + _min: { bucketStart: true }, + }); + // The setup holds what production holds: no rollup older than five years. + expect(oldestRollup._min.bucketStart!.getTime()).toBeGreaterThan( + now - 5 * 366 * DAY, + ); + + const rows = await readDailySeries({ + userId: user.id, + type: "WEIGHT", + from: new Date(now - 3650 * DAY), + to: new Date(now), + priorityJson: null, + }); + + const first = new Date(rows[0]!.measuredAt).getTime(); + expect(first).toBeLessThan(now - 7.5 * 365 * DAY); + const last = new Date(rows[rows.length - 1]!.measuredAt).getTime(); + expect(last).toBeGreaterThan(now - 62 * DAY); + + // Monthly buckets, in order, each month once. + const months = rows.map((row) => + new Date(row.measuredAt).toISOString().slice(0, 7), + ); + expect(new Set(months).size).toBe(months.length); + expect([...months].sort()).toEqual(months); + // Eight years of weekly readings fill about 96 months. + expect(months.length).toBeGreaterThanOrEqual(94); + }); + + it("reads the rollups alone when the history starts inside them", async () => { + const user = await makeUser({ timezone: "Europe/Berlin" }); + const now = Date.now(); + await prisma.measurement.createMany({ + data: Array.from({ length: 150 }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80, + unit: "kg", + measuredAt: new Date(now - (150 - i) * 7 * DAY), + source: "MANUAL" as const, + })), + }); + await recomputeUserRollups(user.id, { types: ["WEIGHT"] }); + const rows = await readDailySeries({ + userId: user.id, + type: "WEIGHT", + from: new Date(now - 3650 * DAY), + to: new Date(now), + priorityJson: null, + }); + const months = rows.map((row) => + new Date(row.measuredAt).toISOString().slice(0, 7), + ); + expect(new Set(months).size).toBe(months.length); + expect(new Date(rows[0]!.measuredAt).getTime()).toBeGreaterThan( + now - 151 * 7 * DAY - 31 * DAY, + ); + }); +});