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, + ); + }); +});