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