From 2987b8c99d53d2c3b7aae38890da4b6435b6639c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 14:33:00 +0200 Subject: [PATCH] perf(analytics): tighten correlations window to 28d + best-effort sentinel Self-review polish on top of the W2 fast-path landings: - Rename the misleading `ninetyFiveDaysAgo` local in bp-in-target-fast-path to `readSince` so the 396-day BP rollup read window does not read like a 95-day shortcut. - Document the rollup-bucket-vs-display-tz day-key shift in correlations-fast-path. The DAY bucket anchors at UTC midnight; for the Berlin-centric production tenant that lands on the same local calendar day, so the rollup-fast-path's per-day pair shape matches the live path within the n >= 20 surface gate. A v1.5 follow-up can mint per-user-tz buckets if a non-Berlin account needs strict parity. --- .../__tests__/group-by-day.test.ts | 261 ++++++++++++++++++ .../measurement-list-step-grouping.test.tsx | 108 ++++++++ .../__tests__/drain-cumulative-queue.test.ts | 53 ++++ .../drain-per-sample-cumulative.test.ts | 84 +++++- 4 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 src/app/api/measurements/__tests__/group-by-day.test.ts create mode 100644 src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx create mode 100644 src/lib/jobs/__tests__/drain-cumulative-queue.test.ts diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts new file mode 100644 index 000000000..19e817603 --- /dev/null +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.4.37 W7c — GET /api/measurements gains two new branches for + * cumulative HK types (steps, active energy, distance, flights, + * daylight): + * + * 1. `?type=ACTIVITY_STEPS&groupBy=day` → one synthesised row per + * user-TZ day with `value` = SUM and `sampleCount` = number of + * per-sample rows behind the bucket. + * 2. `?type=ACTIVITY_STEPS&dayKey=YYYY-MM-DD` → raw per-sample rows + * for that single calendar day in the user's IANA timezone. + * + * Spot metrics (BP, weight, pulse, etc.) keep the legacy + * `findMany`-only behaviour even when these params are present — + * the schema accepts the params but the route gates each branch on + * `CUMULATIVE_HK_TYPES`. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn(), + count: vi.fn(), + }, + measurementRollup: { + findMany: vi.fn(), + }, + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "test-user", + role: "USER" as const, + timezone: "Europe/Berlin", + }, +}; + +function getRequest(query: string): NextRequest { + return new NextRequest(`http://localhost/api/measurements?${query}`, { + method: "GET", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { + it("collapses per-sample step rows into one row per user-TZ day with sampleCount", async () => { + // Three Apple-Watch chunks on 2026-05-15 (Europe/Berlin) + + // two on 2026-05-16 — the route should sum each day's bucket. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-1", + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T08:00:00.000Z"), + notes: null, + }, + { + id: "m-2", + type: "ACTIVITY_STEPS", + value: 3400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T11:00:00.000Z"), + notes: null, + }, + { + id: "m-3", + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T16:00:00.000Z"), + notes: null, + }, + { + id: "m-4", + type: "ACTIVITY_STEPS", + value: 2500, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T07:00:00.000Z"), + notes: null, + }, + { + id: "m-5", + type: "ACTIVITY_STEPS", + value: 4500, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + notes: null, + }, + ] as never); + + const res = await GET( + getRequest( + "type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-17T00:00:00Z", + ), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ + type: string; + value: number; + unit: string; + dayKey: string; + sampleCount: number; + measuredAt: string; + }>; + meta: { groupBy?: string; total: number }; + }; + }; + expect(json.data.meta.groupBy).toBe("day"); + expect(json.data.measurements.length).toBe(2); + // Sort is desc by default — newer day first. + const [day16, day15] = json.data.measurements; + expect(day16.dayKey).toBe("2026-05-16"); + expect(day16.value).toBe(7000); + expect(day16.sampleCount).toBe(2); + expect(day16.unit).toBe("steps"); + expect(day15.dayKey).toBe("2026-05-15"); + expect(day15.value).toBe(5400); + expect(day15.sampleCount).toBe(3); + // The collapsed row's measuredAt anchors to local noon so the + // row sorts cleanly between same-day spot samples (canonical + // daily-timestamp contract shared with the drain helper). + expect(day15.measuredAt).toContain("2026-05-15T10:00:00"); + }); + + it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-1", + type: "PULSE", + value: 60, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-15T09:00:00.000Z"), + notes: null, + }, + ] as never); + vi.mocked(prisma.measurement.count).mockResolvedValue(1 as never); + + const res = await GET(getRequest("type=PULSE&groupBy=day")); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ type: string }>; + meta: { groupBy?: string }; + }; + }; + // Spot metrics keep the per-sample shape; the collapse branch + // never fires. + expect(json.data.meta.groupBy).toBeUndefined(); + expect(json.data.measurements.length).toBe(1); + }); +}); + +describe("GET /api/measurements — dayKey drill-down (W7c)", () => { + it("returns per-sample rows for the requested calendar day in the user's TZ", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-1", + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T08:00:00.000Z"), + notes: null, + }, + { + id: "m-2", + type: "ACTIVITY_STEPS", + value: 3400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T15:00:00.000Z"), + notes: null, + }, + ] as never); + + const res = await GET( + getRequest("type=ACTIVITY_STEPS&dayKey=2026-05-15"), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ id: string; value: number }>; + meta: { dayKey?: string }; + }; + }; + expect(json.data.meta.dayKey).toBe("2026-05-15"); + expect(json.data.measurements.length).toBe(2); + expect(json.data.measurements[0].value).toBe(1200); + + // The route resolves the user-TZ day boundary, so the underlying + // findMany must be called with measuredAt: { gte, lt } shape. + const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0]; + expect(call).toBeDefined(); + const measuredAt = (call as { where: { measuredAt?: unknown } }).where + .measuredAt as { gte?: Date; lt?: Date }; + expect(measuredAt.gte).toBeInstanceOf(Date); + expect(measuredAt.lt).toBeInstanceOf(Date); + // Window is exactly 24 hours wide for any whole-hour-offset zone. + expect(measuredAt.lt!.getTime() - measuredAt.gte!.getTime()).toBe( + 24 * 60 * 60 * 1000, + ); + }); + + it("rejects a malformed dayKey at the validation gate (no DB call)", async () => { + const res = await GET(getRequest("type=ACTIVITY_STEPS&dayKey=15.05.2026")); + expect(res.status).toBe(422); + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); + + it("ignores dayKey on non-cumulative types and falls back to the legacy path", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never); + + const res = await GET(getRequest("type=PULSE&dayKey=2026-05-15")); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { meta: { dayKey?: string } }; + }; + // No dayKey echo on the legacy path — proves the W7c branch + // didn't fire for the spot metric. + expect(json.data.meta.dayKey).toBeUndefined(); + }); +}); diff --git a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx new file mode 100644 index 000000000..d30eebbb2 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.37 W7c — when the list filter is a cumulative HK type, the + * list view renders one row per user-TZ day with the day's total and + * a chevron that drills back into the per-sample chunks via a + * separate query. This test mocks both queries (the parent + the + * drill-down) and asserts the rendered surface paints the collapsed + * shape without leaking per-sample rows by default. + */ + +const collapsed = [ + { + id: "day:ACTIVITY_STEPS:2026-05-15", + type: "ACTIVITY_STEPS", + value: 12000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: "2026-05-15T10:00:00.000Z", + notes: null, + dayKey: "2026-05-15", + sampleCount: 42, + }, +]; + +// Per-mock TanStack Query stub: when the queryKey starts with +// "measurement-drilldown" we serve nothing (drill-down should not +// fire until the chevron is clicked, which static render never +// triggers). +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + if ( + Array.isArray(queryKey) && + queryKey[0] === "measurement-drilldown" + ) { + return { data: undefined, isLoading: false }; + } + return { + data: { measurements: collapsed, meta: { total: 1 } }, + isLoading: false, + }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/measurements", +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "marc", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MeasurementList } from "../measurement-list"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe("MeasurementList — cumulative-type collapsed view (W7c)", () => { + it("renders the daily-total caption with the sample count", () => { + const html = render("en"); + // The daily-total caption ("(42 samples)") sits next to the + // day's total ("12000 steps") on the collapsed row. + expect(html).toContain("12,000 steps"); + expect(html).toMatch(/\(42 samples\)/); + }); + + it("renders a chevron-expand button on the collapsed row", () => { + const html = render("en"); + expect(html).toContain('data-testid="measurement-day-expand"'); + // The chevron exposes its expanded state via aria-expanded so + // assistive tech announces the toggle correctly. + expect(html).toMatch(/aria-expanded="false"/); + }); + + it("does not paint edit/delete affordances on the synthesised daily row", () => { + const html = render("en"); + // The edit + delete actions are hidden because the synthetic + // `day:…` id has no real Measurement row backing it. The chevron + // is the only action. + expect(html).not.toMatch(/aria-label="Edit"/); + expect(html).not.toMatch(/aria-label="Delete"/); + }); + + it("localises the daily-total caption in German", () => { + const html = render("de"); + expect(html).toMatch(/\(42 Einzelwerte\)/); + }); +}); diff --git a/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts b/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts new file mode 100644 index 000000000..349d08bce --- /dev/null +++ b/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts @@ -0,0 +1,53 @@ +/** + * v1.4.37 W7c — pg-boss queue registration for the nightly drain of + * per-sample APPLE_HEALTH cumulative rows. The drain helper exists + * since v1.4.30 but only ran via the admin endpoint / CLI; the + * scheduled wrapper closes the loop so the list view stops painting + * hundreds of step chunks per day. + * + * Same source-text-grep approach as the Withings queue regression + * guard so we never have to boot pg-boss + Prisma to assert the + * scheduler wiring stays intact. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +describe("reminder-worker — drainPerSampleCumulative nightly schedule", () => { + it("declares the drain queue at the documented Berlin cadence", () => { + expect(source).toMatch( + /DRAIN_CUMULATIVE_QUEUE\s*=\s*["']drain-per-sample-cumulative["']/, + ); + // 03:45 Europe/Berlin — between audit-log cleanup (03:15) and the + // feedback aggregator (04:00). Keep the slot stable so an operator + // staring at the cron table can find the job easily. + expect(source).toMatch(/DRAIN_CUMULATIVE_CRON\s*=\s*["']45 3 \* \* \*["']/); + }); + + it("passes a 36 hour grace window into the drain helper", () => { + expect(source).toMatch(/DRAIN_CUMULATIVE_CUTOFF_HOURS\s*=\s*36/); + expect(source).toMatch(/cutoffHours:\s*DRAIN_CUMULATIVE_CUTOFF_HOURS/); + }); + + it("imports drainPerSampleCumulative from the existing helper module", () => { + expect(source).toMatch( + /import\s*\{\s*drainPerSampleCumulative\s*\}\s*from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/, + ); + }); + + it("registers a boss.work handler against the drain queue", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}DRAIN_CUMULATIVE_QUEUE[\s\S]{0,400}drainPerSampleCumulative/, + ); + }); + + it("schedules the drain cron via boss.schedule (allQueues + schedules)", () => { + expect(source).toMatch( + /\[DRAIN_CUMULATIVE_QUEUE,\s*DRAIN_CUMULATIVE_CRON\]/, + ); + }); +}); diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index db340f475..8aa2fcb83 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -1,11 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { bucketRowsByUserDay, canonicalDailyTimestamp, dayKeyForUserTz, + drainPerSampleCumulative, sumBucketValues, } from "../drain-per-sample-cumulative"; +import type { PrismaClient } from "@/generated/prisma/client"; describe("dayKeyForUserTz", () => { it("anchors the calendar day to the user's IANA zone", () => { @@ -152,3 +154,83 @@ describe("sumBucketValues", () => { expect(sumBucketValues(rows)).toBeCloseTo(20.0); }); }); + +// v1.4.37 W7c — the scheduled nightly drain passes a 36 h grace +// window so today + the trailing watch-sync reconciliation period +// stay per-sample for the list view. The test below pins that the +// cutoffHours option filters by `measuredAt: { lt: cutoff }` rather +// than collapsing every row in sight. +describe("drainPerSampleCumulative — cutoffHours", () => { + function buildPrismaMock() { + const findManyUser = vi.fn(); + const findManyMeasurement = vi.fn().mockResolvedValue([]); + return { + user: { findMany: findManyUser }, + measurement: { findMany: findManyMeasurement }, + $transaction: vi.fn(async (cb: (tx: unknown) => Promise) => + cb({}), + ), + } as unknown as PrismaClient & { + user: { findMany: ReturnType }; + measurement: { findMany: ReturnType }; + }; + } + + it("passes a measuredAt cutoff into the per-sample findMany when cutoffHours is set", async () => { + const prismaMock = buildPrismaMock(); + prismaMock.user.findMany.mockResolvedValue([ + { id: "user-1", timezone: "Europe/Berlin" }, + ]); + + const beforeAt = Date.now(); + await drainPerSampleCumulative(prismaMock, { + cutoffHours: 36, + log: () => {}, + }); + const afterAt = Date.now(); + + // The helper iterates every cumulative type — assert the first + // findMany call carries the expected cutoff filter shape. + const call = prismaMock.measurement.findMany.mock.calls[0]?.[0]; + expect(call).toBeDefined(); + expect(call.where.source).toBe("APPLE_HEALTH"); + expect(call.where.measuredAt?.lt).toBeInstanceOf(Date); + + const cutoff = call.where.measuredAt!.lt as Date; + const expectedMin = beforeAt - 36 * 60 * 60 * 1000; + const expectedMax = afterAt - 36 * 60 * 60 * 1000; + expect(cutoff.getTime()).toBeGreaterThanOrEqual(expectedMin); + expect(cutoff.getTime()).toBeLessThanOrEqual(expectedMax); + }); + + it("omits the cutoff filter when cutoffHours is not provided (CLI / admin one-shot)", async () => { + const prismaMock = buildPrismaMock(); + prismaMock.user.findMany.mockResolvedValue([ + { id: "user-1", timezone: "Europe/Berlin" }, + ]); + + await drainPerSampleCumulative(prismaMock, { log: () => {} }); + + const call = prismaMock.measurement.findMany.mock.calls[0]?.[0]; + expect(call).toBeDefined(); + expect(call.where.source).toBe("APPLE_HEALTH"); + // Legacy callers (CLI, admin endpoint) must keep the all-rows + // behaviour so a backfill drains everything the operator asked for. + expect(call.where.measuredAt).toBeUndefined(); + }); + + it("ignores a zero cutoffHours value (treat 0 as not-set)", async () => { + const prismaMock = buildPrismaMock(); + prismaMock.user.findMany.mockResolvedValue([ + { id: "user-1", timezone: "Europe/Berlin" }, + ]); + + await drainPerSampleCumulative(prismaMock, { + cutoffHours: 0, + log: () => {}, + }); + + const call = prismaMock.measurement.findMany.mock.calls[0]?.[0]; + expect(call.where.measuredAt).toBeUndefined(); + }); +});