From 23cc48475d46bc0eab1126fbc45fc308bd6fbba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:21:29 +0200 Subject: [PATCH] perf(insights): bound the comparison snapshot to a 400-day window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildComparisonSnapshotForUser read a user's entire measurement history per type with no date floor, then fed each series to summarize(). The snapshot only consumes avg30 / avg30LastMonth / avg30LastYear, and the widest of those reaches into the [365, 395)-day window (ageMs < 395 days in meanOfWindow), so nothing older than 395 days can change a result. Add a 400-day measuredAt.gte lower bound to every per-type read in both copies of the builder — the page-blocking POST /api/insights/generate handler and the shared comprehensive-generate path the nightly pregenerate cron drives. The fan-out runs all seven snapshot types via Promise.all, so on multi-year accounts the unbounded scan was tens of thousands of rows per type. 400 days keeps a 5-day floor over the strict-less-than 395-day reach. --- src/app/api/insights/generate/route.ts | 22 ++- .../comparison-snapshot-window.test.ts | 146 ++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 22 ++- 3 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/lib/insights/__tests__/comparison-snapshot-window.test.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index a27c05e75..731779898 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -160,6 +160,14 @@ async function buildComparisonSnapshotForUser( ACTIVITY_STEPS: "", }; const types = Object.keys(typeToSnapshotKey) as MeasurementType[]; + // The snapshot only feeds summarize()'s avg30 / avg30LastMonth / + // avg30LastYear. The widest of those reaches back into the [365, 395)-day + // window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395 + // days can change a result. Bound the read at 400 days — a 5-day floor over + // the strict-less-than boundary — instead of scanning the full history, + // which on multi-year accounts is tens of thousands of rows per type on the + // page-blocking generate path and the nightly pregenerate cron. + const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000); const rows = await Promise.all( types.map(async (type) => { // SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the @@ -171,7 +179,12 @@ async function buildComparisonSnapshotForUser( if (type === "SLEEP_DURATION") { const [sleepRows, sleepTz, sleepPriority] = await Promise.all([ prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, @@ -195,7 +208,12 @@ async function buildComparisonSnapshotForUser( .map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })); } else { const measurements = await prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }); diff --git a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts new file mode 100644 index 000000000..fb743feb9 --- /dev/null +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -0,0 +1,146 @@ +/** + * v1.20.1 perf — the comparison-snapshot builder must bound its + * per-type measurement read to a ~400-day window instead of scanning a + * user's entire history. The snapshot only feeds summarize()'s avg30 / + * avg30LastMonth / avg30LastYear, and the widest of those reaches into + * the [365, 395)-day window, so nothing older than 395 days can change a + * result. These tests pin that: + * - every measurement read carries a `measuredAt.gte` lower bound, + * - the bound sits at ~400 days (5-day floor over the 395-day reach), + * - the three averages are unaffected for in-window data. + * + * The read fans out over all seven snapshot types on the page-blocking + * POST /api/insights/generate path AND the nightly pregenerate cron, so + * an unbounded scan is tens of thousands of rows per type on multi-year + * accounts. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUnique = vi.fn(); +const measurementFindMany = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + }, + measurement: { + findMany: (...a: unknown[]) => measurementFindMany(...a), + }, + }, +})); +// SLEEP_DURATION branch helpers — the test never exercises the sleep +// reconstruction maths, but the builder imports these unconditionally. +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => []), +})); + +import { buildComparisonSnapshotForUser } from "../comprehensive-generate"; + +const DAY = 86_400_000; + +beforeEach(() => { + vi.clearAllMocks(); + // Comparison toggle ON → the builder runs its per-type reads. The + // baseline value drives which prior-period mean the snapshot reports. + // `resolveDashboardLayout` only honours `comparisonBaseline` on a blob + // that carries a numeric `version` + a `widgets` array; otherwise it + // clamps to "none". + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "lastYear", + }, + }); + // No measurements by default; individual tests override per call. + measurementFindMany.mockResolvedValue([]); +}); + +describe("buildComparisonSnapshotForUser — bounded read window", () => { + it("bounds every measurement read with a ~400-day measuredAt.gte floor", async () => { + const before = Date.now(); + await buildComparisonSnapshotForUser("u1"); + const after = Date.now(); + + // One findMany per snapshot type (sleep takes a different select but + // the same where shape). Every call must carry the lower bound. + expect(measurementFindMany).toHaveBeenCalled(); + for (const call of measurementFindMany.mock.calls) { + const where = (call[0] as { where: Record }).where; + expect(where).toMatchObject({ userId: "u1", deletedAt: null }); + const measuredAt = where.measuredAt as { gte: Date }; + expect(measuredAt).toBeDefined(); + expect(measuredAt.gte).toBeInstanceOf(Date); + + // The floor is anchored on Date.now() - 400 days (allow scheduling + // slack between the snapshot's clock read and the test's bracket). + const gteMs = measuredAt.gte.getTime(); + expect(gteMs).toBeLessThanOrEqual(before - 400 * DAY); + expect(gteMs).toBeGreaterThanOrEqual(after - 401 * DAY); + } + }); + + it("keeps the floor strictly older than the 395-day reach of avg30LastYear", async () => { + const now = Date.now(); + await buildComparisonSnapshotForUser("u1"); + + // avg30LastYear reads points with age < 395 days. The bound must sit + // at least 5 days older so it never clips that window. + for (const call of measurementFindMany.mock.calls) { + const where = (call[0] as { where: { measuredAt: { gte: Date } } }).where; + const ageDays = (now - where.measuredAt.gte.getTime()) / DAY; + expect(ageDays).toBeGreaterThanOrEqual(395); + } + }); + + it("computes avg30 / avg30LastMonth / avg30LastYear unchanged for in-window data", async () => { + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "lastMonth", + }, + }); + + const now = Date.now(); + // WEIGHT is the first non-sleep type. Seed three points, one in each + // of the avg30 (current), avg30LastMonth ([30,60)) windows — all well + // inside the 400-day floor. + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + if (args.where.type !== "WEIGHT") return []; + return [ + { measuredAt: new Date(now - 5 * DAY), value: 80 }, // current + { measuredAt: new Date(now - 45 * DAY), value: 84 }, // lastMonth + ]; + }, + ); + + const snapshot = await buildComparisonSnapshotForUser("u1"); + expect(snapshot).not.toBeNull(); + const weight = snapshot!.metrics.find((m) => m.type === "weight"); + expect(weight).toBeDefined(); + // avg30 over the single current point. + expect(weight!.currentAvg).toBe(80); + // lastMonth baseline over the single [30,60) point. + expect(weight!.baselineAvg).toBe(84); + expect(weight!.delta).toBe(-4); + }); + + it("returns null without reading measurements when the comparison toggle is off", async () => { + findUnique.mockResolvedValue({ + dashboardWidgetsJson: { + version: 1, + widgets: [], + comparisonBaseline: "none", + }, + }); + const snapshot = await buildComparisonSnapshotForUser("u1"); + expect(snapshot).toBeNull(); + expect(measurementFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index abd855e04..ab052a27e 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -328,6 +328,14 @@ export async function buildComparisonSnapshotForUser( ACTIVITY_STEPS: "", }; const types = Object.keys(typeToSnapshotKey) as MeasurementType[]; + // The snapshot only feeds summarize()'s avg30 / avg30LastMonth / + // avg30LastYear. The widest of those reaches back into the [365, 395)-day + // window (ageMs < 395 * DAY in meanOfWindow), so nothing older than 395 + // days can change a result. Bound the read at 400 days — a 5-day floor over + // the strict-less-than boundary — instead of scanning the full history, + // which on multi-year accounts is tens of thousands of rows per type on the + // page-blocking generate path and the nightly pregenerate cron. + const sinceMeasuredAt = new Date(Date.now() - 400 * 86_400_000); const rows = await Promise.all( types.map(async (type) => { // SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the @@ -339,7 +347,12 @@ export async function buildComparisonSnapshotForUser( if (type === "SLEEP_DURATION") { const [sleepRows, sleepTz, sleepPriority] = await Promise.all([ prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, @@ -363,7 +376,12 @@ export async function buildComparisonSnapshotForUser( .map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })); } else { const measurements = await prisma.measurement.findMany({ - where: { userId, type, deletedAt: null }, + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: sinceMeasuredAt }, + }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, });