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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-26 07:46:50 +01:00
1 parent 467d5dcb48
commit 13860422cf
3 files changed
+194 -1

No files matched your search

+83 -1
View File
@@ -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<Exclude<AggregateGrain, "raw">, number> = {
daily: 1,
weekly: 7,
monthly: 28,
};
/** The live grain that matches a rollup tier's buckets. */
const TIER_GRAIN: Record<string, Exclude<AggregateGrain, "raw">> = {
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<Awaited<ReturnType<typeof readTieredRollupSeries>>>,
ctx: {
userId: string;
type: MeasurementType;
from: Date;
priorityJson: unknown;
opts: { timeZone?: string };
},
): Promise<DailySeriesRow[]> {
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<Exclude<AggregateGrain, "raw">, string> = {
daily: "day",
+3
View File
@@ -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,
@@ -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,
);
});
});