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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:33:00 +02:00
1 parent 675c084baa
commit 2987b8c99d
4 files changed
+505 -1

No files matched your search

@@ -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();
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
<MeasurementList />
</I18nProvider>,
);
}
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\)/);
});
});
@@ -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\]/,
);
});
});
@@ -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<unknown>) =>
cb({}),
),
} as unknown as PrismaClient & {
user: { findMany: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
};
}
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();
});
});