refactor(measurements): move the list modes out of the route and page every night

The 1500-line list route carried the day-grouped list, the sleep list
and its SQL night page, each with its own copy of the window-total
paging. They now live in src/lib/measurements/list/ with one paging
helper, and the route dispatches.

The sleep list gives every page night a row. A night whose
reconstruction held no main sleep (a day of naps alone) used to be
dropped while meta.total still counted it, which left short pages; its
row now carries 0 minutes and its naps. The SQL night cut splices the
asleep stages from ASLEEP_STAGES and folds rows into hour buckets
before the window functions, which cannot move a session boundary and
sorts a handful of rows per night instead of every stage. A drill-down
reads its night the way the night view does.

groupBy=day refuses valueMin / valueMax with 422: a value range filters
single readings, and the rows promise whole days.

An integration test runs the SQL page and reconstructSleepSessions over
one set of awkward shapes and holds them to the same nights, and the
list, the night view and the series to one answer per day. The unit
test of the day-grouped branch now pins only the branch; what the rows
hold is pinned against Postgres.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:22:53 +02:00
1 parent 6328117bd2
commit 3da72136ba
10 files changed
+1178 -841

No files matched your search

@@ -2,22 +2,29 @@ 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):
* GET /api/measurements has two 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.
* user-TZ day, built by `dayGroupedListResponse`. What the rows hold
* (whole days, the source ladder, paging, the day cut in the user's
* zone) is pinned against Postgres in
* `tests/integration/measurements-day-grouped-paging.test.ts`; this
* suite pins only which branch a request reaches.
* 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
* Spot metrics (BP, weight, pulse, etc.) keep the plain list even when
* these params are present: the route gates each branch on
* `CUMULATIVE_HK_TYPES`.
*/
vi.mock("@/lib/measurements/list/day-grouped", () => ({
dayGroupedListResponse: vi.fn(async () =>
Response.json({ data: { measurements: [], meta: {} }, error: null }),
),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
@@ -32,7 +39,7 @@ vi.mock("@/lib/db", () => ({
// blob via `loadUserSourcePriority` (prisma.user.findUnique). `null`
// resolves to the default ladders.
user: { findUnique: vi.fn() },
// v1.4.43 W6 — validation-failed paths write an audit breadcrumb.
// v1.4.43 — validation-failed paths write an audit breadcrumb.
auditLog: { create: vi.fn().mockResolvedValue({}) },
$queryRaw: vi.fn(),
},
@@ -55,6 +62,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -74,71 +82,10 @@ function getRequest(query: string): NextRequest {
});
}
interface Group {
day_key: string;
source: string;
device_type: string | null;
unit: string;
total: number;
n: number;
}
/** One SQL fold row: a day's readings of one source, device and unit. */
function group(
day_key: string,
total: number,
n: number,
over: Partial<Group> = {},
): Group {
return {
day_key,
source: "APPLE_HEALTH",
device_type: null,
unit: "steps",
total,
n,
...over,
};
}
/**
* The day-grouped branch answers from SQL alone: one query picks the page's
* days, each row carrying the distinct-day total from a window count; a
* second folds those days into source groups. A page past the end has no row
* to carry the total, so only then is it counted on its own. Answer each
* query by its text.
*/
function mockDayPage(
days: string[],
total = days.length,
groups: Group[] = [],
) {
vi.mocked(prisma.$queryRaw).mockImplementation(((
strings: TemplateStringsArray,
) => {
const text = strings.join("");
if (text.includes("COUNT(DISTINCT")) return Promise.resolve([{ total }]);
if (text.includes("COUNT(*) OVER ()")) {
return Promise.resolve(days.map((day_key) => ({ day_key, total })));
}
if (text.includes("SUM(")) return Promise.resolve(groups);
return Promise.resolve([]);
}) as never);
}
/** The `$queryRaw` calls whose text contains `needle`. */
function rawCallsWith(needle: string) {
return vi
.mocked(prisma.$queryRaw)
.mock.calls.filter(([strings]) =>
(strings as unknown as string[]).join("").includes(needle),
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// v1.4.43 W6 — validation-failed paths fire a best-effort audit-row
// v1.4.43 — validation-failed paths fire a best-effort audit-row
// write; the route swallows rejections so the test only needs a
// resolved mock to keep the catch-block silent.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
@@ -150,176 +97,36 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null 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) summing to 5400
// and two on 2026-05-16 summing to 7000, as SQL folds them.
mockDayPage(["2026-05-16", "2026-05-15"], 2, [
group("2026-05-15", 5400, 3),
group("2026-05-16", 7000, 2),
]);
describe("GET /api/measurements — groupBy=day", () => {
it("hands a cumulative type to the day-grouped list with the request's filters", async () => {
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-17T00:00:00Z",
"type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=50&sourceEq=APPLE_HEALTH&from=2026-05-01T00: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");
});
// #1245 — the fold covers exactly the page's days: from the start of the
// oldest page day to the end of the newest, in the user's zone, on top of
// the request's own filters. Nothing outside the page is scanned, and no
// raw row is read into the process at all.
it("folds only the page's days, in SQL", async () => {
mockDayPage(["2026-05-16", "2026-05-15"], 40);
await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
const [fold] = rawCallsWith("SUM(");
const instants = (fold ?? [])
.slice(1)
.filter((v): v is Date => v instanceof Date)
.map((d) => d.toISOString());
// Europe/Berlin is UTC+2 in May.
expect(instants).toEqual([
"2026-05-14T22:00:00.000Z",
"2026-05-16T22:00:00.000Z",
]);
expect(dayGroupedListResponse).toHaveBeenCalledWith(
expect.objectContaining({ id: "user-1" }),
expect.objectContaining({
type: "ACTIVITY_STEPS",
limit: 25,
offset: 50,
sortDir: "desc",
sourceEq: "APPLE_HEALTH",
from: new Date("2026-05-01T00:00:00Z"),
}),
);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("folds nothing when the page holds no day", async () => {
mockDayPage([], 3);
// A day-grouped row is a whole day's sum; a value range filters single
// readings and would leave partial days behind, so the pair is refused.
it("refuses a value range beside groupBy=day", async () => {
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"),
getRequest("type=ACTIVITY_STEPS&groupBy=day&valueMin=100"),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: unknown[]; meta: { total: number } };
};
expect(json.data.measurements).toEqual([]);
expect(json.data.meta.total).toBe(3);
expect(rawCallsWith("SUM(")).toHaveLength(0);
// Past the end, and only there, the total is counted on its own.
expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(1);
});
// v1.29.6 — a day with both an Apple Health AND a Withings write for the
// same cumulative metric must collapse to the ladder-canonical source
// before summing, exactly like the dashboard rollup tile
// (`collapseRollupRowsBySource`) does. Without the collapse, 9000
// Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 —
// a number the tile never showed.
it("collapses cross-source rows to the ladder-canonical source before summing", async () => {
mockDayPage(["2026-05-15"], 1, [
group("2026-05-15", 8800, 1, { source: "WITHINGS" }),
group("2026-05-15", 9000, 1),
]);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-16T00:00:00Z",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{
dayKey: string;
value: number;
source: string;
sampleCount: number;
}>;
meta: { droppedDuplicates?: number };
};
};
expect(json.data.measurements.length).toBe(1);
const [day] = json.data.measurements;
// APPLE_HEALTH beats WITHINGS on the default `steps` ladder — the
// Withings row drops out of the sum entirely rather than adding on top.
expect(day.value).toBe(9000);
expect(day.source).toBe("APPLE_HEALTH");
expect(day.sampleCount).toBe(1);
expect(json.data.meta.droppedDuplicates).toBe(1);
});
// #1245 — `limit` / `offset` page over days and reach the SQL that picks
// them; `meta.total` is the distinct-day count, not the page length, and
// no row is marked partial because every page day is read whole.
it("pages over days and reports the distinct-day total", async () => {
mockDayPage(["2026-04-20"], 40, [group("2026-04-20", 3400, 1)]);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{ dayKey: string; partial?: boolean }>;
meta: { total: number; limit: number; offset: number };
};
};
expect(json.data.meta).toMatchObject({ total: 40, limit: 25, offset: 25 });
expect(json.data.measurements.map((m) => m.dayKey)).toEqual(["2026-04-20"]);
expect(json.data.measurements[0].partial).toBeUndefined();
const [pageCall] = rawCallsWith("COUNT(*) OVER ()");
expect(pageCall?.slice(1)).toEqual(expect.arrayContaining([25]));
expect(pageCall?.slice(1).filter((v) => v === 25)).toHaveLength(2);
// The total rode on the page's own rows; there was no second pass over
// the history, only the fold of the page's one day.
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(0);
});
// A device split inside the winning source keeps the top device's
// groups only, the same narrowing the picker applies to raw rows.
it("keeps the top-ranked device's groups within the winning source", async () => {
mockDayPage(["2026-05-15"], 1, [
group("2026-05-15", 6000, 40, { device_type: "watch" }),
group("2026-05-15", 5500, 12, { device_type: "phone" }),
]);
const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
const json = (await res.json()) as {
data: {
measurements: Array<{ value: number; sampleCount: number }>;
meta: { droppedDuplicates: number };
};
};
expect(json.data.measurements).toHaveLength(1);
expect(json.data.measurements[0]).toMatchObject({
value: 6000,
sampleCount: 40,
});
expect(json.data.meta.droppedDuplicates).toBe(12);
expect(res.status).toBe(422);
expect(dayGroupedListResponse).not.toHaveBeenCalled();
});
it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => {
@@ -351,7 +158,7 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
});
});
describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
describe("GET /api/measurements — dayKey drill-down", () => {
it("returns per-sample rows for the requested calendar day in the user's TZ", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
@@ -402,7 +209,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
);
});
// v1.4.37 W10 H-1 — DST awareness. The drill-down window must honour
// v1.4.37 — DST awareness. The drill-down window must honour
// the day's true local span (23 h on spring-forward, 25 h on
// fall-back) so the row count matches what the collapsed daily row
// displays. Previous shape (`canonicalDailyTimestamp ± 12h`) leaked
@@ -451,24 +258,23 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
const json = (await res.json()) as {
data: { meta: { dayKey?: string } };
};
// No dayKey echo on the legacy path — proves the W7c branch
// No dayKey echo on the legacy path — proves the drill-down branch
// didn't fire for the spot metric.
expect(json.data.meta.dayKey).toBeUndefined();
});
});
describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
describe("GET /api/measurements — schema rejections", () => {
// #1245 — the day-grouped mode pages over days, so an offset is a real
// page request now rather than a refused combination.
it("accepts offset>0 when groupBy=day is set", async () => {
mockDayPage([], 0);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&offset=25"),
);
expect(res.status).toBe(200);
});
// v1.4.37 W10 H1 — same restriction for the drill-down branch; it
// v1.4.37 — same restriction for the drill-down branch; it
// returns a single bounded page per dayKey, not a cursor.
it("rejects offset>0 when dayKey is set", async () => {
const res = await GET(
@@ -478,7 +284,7 @@ describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
// v1.4.37 W10 H2 — `2026-02-30` is a YYYY-MM-DD string but not a
// v1.4.37 — `2026-02-30` is a YYYY-MM-DD string but not a
// real calendar date. `new Date()` silently overflows it to March
// 2, so the drill-down would point at the wrong day. The validator
// refine must reject it at parse time.
+14 -595
View File
@@ -29,12 +29,6 @@ import {
type AggregateGrain,
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import {
canonicalDailyTimestamp,
dayKeyForUserTz,
} from "@/lib/measurements/drain-per-sample-cumulative";
import { localDayWindow } from "@/lib/tz/local-day";
import { withIdempotency } from "@/lib/idempotency";
import { isP2002 } from "@/lib/prisma-errors";
@@ -50,11 +44,8 @@ import {
readDailySeries,
readLiveBuckets,
} from "@/lib/measurements/daily-series-read";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
SESSION_GAP_MS,
} from "@/lib/analytics/sleep-night";
import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped";
import { sleepListResponse } from "@/lib/measurements/list/sleep-list";
import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver";
import {
isNamedSleepStageBody,
@@ -72,12 +63,10 @@ import {
oppositeMergeSource,
} from "@/lib/measurements/cross-source-merge";
import { NextRequest } from "next/server";
import {
Prisma,
type MeasurementType,
type MeasurementSource,
type GlucoseContext,
type SleepStage,
import type {
MeasurementType,
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -86,7 +75,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
if (!parsed.success) {
// v1.4.43 W6 — surface every Zod issue under `details.issues` so a
// v1.4.43 — surface every Zod issue under `details.issues` so a
// multi-field iOS query (chart loader bug, drill-down regression)
// doesn't iterate one error per round-trip. Best-effort audit-
// ledger breadcrumb keyed `measurements.list.validation-failed`.
@@ -133,7 +122,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const where = {
userId: user.id,
// v1.4.40 W-DELETED — soft-deleted readings are invisible to every
// v1.4.40 — soft-deleted readings are invisible to every
// list / count / aggregate read. The dedicated `sync/state` route
// keeps surfacing them via its own filter so iOS can reconcile
// tombstones; every other consumer (list view, analytics fan-out,
@@ -185,7 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
// v1.4.37 W7c — list-view drill-down to per-sample rows for a single
// v1.4.37 — list-view drill-down to per-sample rows for a single
// calendar day in the user's IANA timezone. Resolves the day's UTC
// bounds against `User.timezone`, then runs a plain `findMany` so the
// expandable list row shows every per-sample chunk the daily collapse
@@ -196,7 +185,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// drill-down opens exactly the day the row summed.
const tz = await resolveStoredTimezone(user.timezone);
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
// the user's local zone. v1.4.37 — `localDayWindow` honours
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
// silently leaked or hid one hour of samples on spring-forward
// (23-h day) and fall-back (25-h day) by always spanning exactly
@@ -232,7 +221,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
// v1.4.37 W7c — collapsed "one row per day" mode for cumulative
// v1.4.37 — collapsed "one row per day" mode for cumulative
// types. The list page renders the day's SUM as a single synthesised
// row with a `sampleCount` chevron that drills back into the
// per-sample rows via the `dayKey=…` branch above. `limit` / `offset`
@@ -248,26 +237,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
offset,
sortDir,
sourceEq,
valueMin,
valueMax,
from,
to,
});
}
// v1.4.28 FB-D2 — server-side aggregation. Gated on an explicit
// v1.4.28 — server-side aggregation. Gated on an explicit
// `aggregate` param so the iOS contract (raw `MeasurementWireDTO`
// shape on `GET /api/measurements`) is byte-stable for any caller
// that omits the new query parameter. The chart-data client opts
// in; iOS does not.
//
// R4-CODE-C1 — `take` no longer applies BEFORE bucketising. The
// `take` no longer applies BEFORE bucketising. The
// aggregation runs in Postgres via `date_trunc`, the bucket cap is
// applied AFTER, so a 1-year `aggregate=daily` window walks every
// row in the window and returns up to 365 buckets per type instead
// of truncating to the first N raw rows.
//
// SD-H1 — "All time" range semantics. When the chart's "All" tab
// "All time" range semantics. When the chart's "All" tab
// is active, the client passes `from` = the user's earliest
// measurement (or a sentinel like 1970-01-01) plus `to` = now plus
// `aggregate=monthly` (or `weekly` when full history < 2 years).
@@ -369,574 +356,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
/**
* The day-grouped list for a cumulative type, paged over local days.
*
* Everything heavy runs in Postgres. The first query picks the page: the
* distinct local days that hold a reading, sorted and cut by `limit` /
* `offset`, each row carrying the distinct-day count of the whole filtered
* set as `meta.total` from a window count. The second folds only the page's
* days, one row per day, source, device and unit with its sum and sample
* count, and the source ladder picks among those groups exactly as it would
* among the raw rows (it keeps or drops whole source and device groups per
* day, never single rows). So a page costs its own days whatever the length
* of the history, every day is complete, and no raw row reaches the heap.
*
* Until v1.43.6 the branch read the newest `limit` raw rows (the web asked
* for 5000), bucketed them and returned every bucket. A long history came
* back as thousands of rows in one response, and every day older than the
* 5000th raw row was missing with nothing to say so.
*
* A day exists for the page when any reading of the type falls on it; the
* source collapse narrows a day's groups but never empties one, so the count
* and the folded rows agree.
*/
async function dayGroupedListResponse(
user: { id: string; timezone?: string | null },
opts: {
type: MeasurementType;
limit: number;
offset: number;
sortDir: "asc" | "desc";
sourceEq?: MeasurementSource;
valueMin?: number;
valueMax?: number;
from?: Date;
to?: Date;
},
) {
const { type, limit, offset, sortDir } = opts;
// One resolver for the stored zone, shared with the drill-down and the
// sleep list: a usable IANA name or the instance default, never a value
// Postgres would refuse. The drill-down must cut the same days.
const tz = await resolveStoredTimezone(user.timezone);
// The request's filters, as SQL: the same ones the plain list applies.
const filters = Prisma.sql`
m."user_id" = ${user.id}
AND m."type" = ${type}::measurement_type
AND m."deleted_at" IS NULL
${opts.sourceEq ? Prisma.sql`AND m."source" = ${opts.sourceEq}::measurement_source` : Prisma.empty}
${opts.valueMin != null ? Prisma.sql`AND m."value" >= ${opts.valueMin}` : Prisma.empty}
${opts.valueMax != null ? Prisma.sql`AND m."value" <= ${opts.valueMax}` : Prisma.empty}
${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty}
${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty}
`;
const localDay = Prisma.sql`to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD')`;
// Direction comes from the Zod enum, never from raw input. The window
// count runs before LIMIT, so every row of the page carries the
// distinct-day total of the whole filtered set.
const direction = Prisma.raw(sortDir === "asc" ? "ASC" : "DESC");
const pageDays = await prisma.$queryRaw<
Array<{ day_key: string; total: number }>
>`
SELECT d.day_key, (COUNT(*) OVER ())::int AS total
FROM (
SELECT ${localDay} AS day_key
FROM measurements m
WHERE ${filters}
GROUP BY 1
) d
ORDER BY d.day_key ${direction}
LIMIT ${limit} OFFSET ${offset}
`;
// A page past the end has no row to carry the total; only then is it
// counted on its own.
const total =
pageDays.length > 0
? Number(pageDays[0].total)
: offset > 0
? Number(
(
await prisma.$queryRaw<Array<{ total: number }>>`
SELECT COUNT(DISTINCT ${localDay})::int AS total
FROM measurements m
WHERE ${filters}
`
)[0]?.total ?? 0,
)
: 0;
const dayKeys = pageDays.map((d) => d.day_key);
// The page's days are consecutive in the sorted day set, so the span from
// the first one's start to the last one's end holds exactly their rows.
let groups: Array<{
day_key: string;
source: MeasurementSource;
device_type: string | null;
unit: string;
total: number;
n: number;
}> = [];
if (dayKeys.length > 0) {
const sorted = [...dayKeys].sort();
const spanStart = localDayWindow(sorted[0], tz).dayStart;
const spanEnd = localDayWindow(sorted[sorted.length - 1], tz).dayEnd;
// Newest group first within a day, so the day's label comes from its
// latest reading, as it did when raw rows were read newest first.
groups = await prisma.$queryRaw`
SELECT
${localDay} AS day_key,
m."source"::text AS source,
m."device_type" AS device_type,
m."unit" AS unit,
SUM(m."value")::float8 AS total,
COUNT(*)::int AS n
FROM measurements m
WHERE ${filters}
AND m."measured_at" >= ${spanStart}
AND m."measured_at" < ${spanEnd}
GROUP BY 1, 2, 3, 4
ORDER BY 1, MAX(m."measured_at") DESC, 2, 3, 4
`;
}
// v1.29.6 — collapse overlapping sources to the ladder-canonical
// reading per day BEFORE summing. Without this, a day with both an
// Apple Health AND a Withings write for the same cumulative metric
// (e.g. steps) summed BOTH sources into one inflated total — a day
// the dashboard tile (which reads through the source-collapsed
// rollup, `collapseRollupRowsBySource` in
// `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the
// same `metricKeyForType` ladder lookup the rollup collapse uses, so
// this list and the tile agree on which source wins. A type with no
// ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse
// against — every group passes through unchanged. Each group stands at
// its day's local noon, so the picker files it under the day SQL cut.
const groupRows = groups.map((g) => ({
...g,
type,
deviceType: g.device_type,
measuredAt: canonicalDailyTimestamp(g.day_key, tz),
}));
const metricKey = metricKeyForType(type);
const priorityJson = metricKey ? await loadUserSourcePriority(user.id) : null;
const canonicalGroups = metricKey
? pickCanonicalSourceRows(groupRows, metricKey, priorityJson, (d) =>
dayKeyForUserTz(d, tz),
).canonicalRows
: groupRows;
// Sum per local day; unit and source come from the day's first kept group
// so the rendered row carries a sensible label even when sources mix.
const buckets = new Map<
string,
{ total: number; sampleCount: number; unit: string; source: string }
>();
for (const g of canonicalGroups) {
const slot = buckets.get(g.day_key);
if (!slot) {
buckets.set(g.day_key, {
total: g.total,
sampleCount: g.n,
unit: g.unit,
source: g.source,
});
} else {
slot.total += g.total;
slot.sampleCount += g.n;
}
}
const rowsScanned = groups.reduce((sum, g) => sum + g.n, 0);
const rowsKept = canonicalGroups.reduce((sum, g) => sum + g.n, 0);
// Rows follow the page's day order from SQL.
const measurements = dayKeys.flatMap((dayKey) => {
const b = buckets.get(dayKey);
if (!b) return [];
return [
{
// Synthetic id so the list-row key stays stable across pages
// and the row never collides with a real `Measurement.id`.
id: `day:${type}:${dayKey}`,
type,
value: b.total,
unit: b.unit,
source: b.source,
// Anchor the row's timestamp to local noon so the row sorts
// cleanly between same-day spot samples — matches the
// `canonicalDailyTimestamp` convention used by the drain.
measuredAt: canonicalDailyTimestamp(dayKey, tz).toISOString(),
notes: null,
dayKey,
sampleCount: b.sampleCount,
},
];
});
annotate({
action: { name: "measurement.list" },
meta: {
total,
type,
groupBy: "day",
days: measurements.length,
rowsScanned,
droppedDuplicates: rowsScanned - rowsKept,
},
});
return apiSuccess({
measurements,
meta: {
total,
limit,
offset,
groupBy: "day",
droppedDuplicates: rowsScanned - rowsKept,
},
});
}
/**
* A sleep page holds at most this many nights. The nights' stage rows are
* read and reconstructed in the process, so the bound sits on the nights;
* the applied value comes back as `meta.limit`, and `meta.total` still counts
* every night, so a caller asking for more pages on rather than losing any.
* Until v1.43.6 the list read no more than a year of nights however large
* the limit, so this is a widening, never a new cut.
*/
const SLEEP_NIGHT_PAGE_MAX = 1000;
/**
* v1.11.5 — per-night collapse for the SLEEP_DURATION list. Sleep is
* stored one row per STAGE per night (minutes); a plain list leaks every
* stage segment. This collapses the raw rows into the canonical per-night
* shape used by the chart series + dashboard tile.
*
* Two modes:
* - `dayKey` set → drill-down: return the night's stage SEGMENTS (the
* canonical source's per-stage spans) so the list row can reveal the
* hypnogram's underlying rows, mirroring the cumulative-type drill-down.
* - no `dayKey` → one synthetic row per night, value = TIME ASLEEP in
* minutes (the canonical unit), with the per-night stage breakdown,
* nap count, and awakenings in the row so the UI can caption the night
* without a second fetch.
*
* The nights are paged in Postgres (`sleepNightPage`), like the days of the
* cumulative list, and only the stage rows of the page's nights are read.
* Until v1.43.6 the read stopped at a year before `to` and the list ended
* there without saying so to the client.
*/
async function sleepListResponse(
user: { id: string; timezone?: string | null },
opts: {
from?: Date;
to?: Date;
limit: number;
offset: number;
sortDir: "asc" | "desc";
dayKey?: string;
},
) {
const { from, to, offset, sortDir, dayKey } = opts;
const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX);
const [tz, priorityJson] = await Promise.all([
resolveStoredTimezone(user.timezone),
loadUserSourcePriority(user.id),
]);
const page = await sleepNightPage(user.id, tz, {
from,
to,
sortDir,
...(dayKey ? { night: dayKey, limit: 1, offset: 0 } : { limit, offset }),
});
const nightKeys = page.nights.map((n) => n.night);
// The page's sessions lie between the first one's start and the last one's
// end, and every other session is more than the session gap away from that
// span, so this read holds the page's sessions whole and no part of any
// other.
let rows: Array<{
id: string;
value: number;
measuredAt: Date;
sleepStage: SleepStage | null;
source: MeasurementSource;
deviceType: string | null;
}> = [];
if (page.nights.length > 0) {
let spanStart = page.nights[0].spanStart;
let spanEnd = page.nights[0].spanEnd;
for (const n of page.nights) {
if (n.spanStart < spanStart) spanStart = n.spanStart;
if (n.spanEnd > spanEnd) spanEnd = n.spanEnd;
}
rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
deletedAt: null,
AND: [
{ measuredAt: { gte: spanStart, lte: spanEnd } },
...(from ? [{ measuredAt: { gte: from } }] : []),
...(to ? [{ measuredAt: { lte: to } }] : []),
],
},
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: {
id: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one source (watch
// stages vs phone in-bed) must not blend into one night.
deviceType: true,
},
});
}
// Each reconstructed session is filed under the page night whose span
// holds it, and takes that night's label. Matching by the reconstruction's
// own label lost a night whenever the two named different days: the page
// counted it under one, the reconstruction filed it under the other, and
// the filter dropped it from both.
const sessions = reconstructSleepSessions(rows, tz, priorityJson).flatMap(
(session) => {
// A second of slack absorbs the sub-millisecond rounding between the
// two start computations; sessions sit hours apart, so it can never
// reach a neighbour.
const home = page.nights.find(
(n) =>
n.spanStart.getTime() - 1000 <= session.start.getTime() &&
session.end.getTime() <= n.spanEnd.getTime() + 1000,
);
return home ? [{ ...session, night: home.night }] : [];
},
);
// Drill-down — return the requested night's canonical stage segments.
if (dayKey) {
const { main, naps } = pickMainNightAndNaps(
sessions.filter((s) => s.night === dayKey),
);
const dayUnit = getUnitForType("SLEEP_DURATION");
// One row per stage segment of the main night + each nap, so the
// drill-down reveals exactly the rows that summed into the headline.
const segments = [main, ...naps]
.filter((s): s is NonNullable<typeof s> => s != null)
.flatMap((s) => s.segments);
const measurements = segments
.sort((a, b) =>
sortDir === "asc"
? a.start.getTime() - b.start.getTime()
: b.start.getTime() - a.start.getTime(),
)
.map((seg, i) => ({
id: `sleep-seg:${dayKey}:${i}`,
type: "SLEEP_DURATION",
value: Math.round(seg.minutes),
unit: dayUnit,
source: main?.source ?? naps[0]?.source ?? "MANUAL",
measuredAt: seg.end.toISOString(),
sleepStage: seg.stage,
notes: null,
}));
annotate({
action: { name: "measurement.list" },
meta: {
total: measurements.length,
type: "SLEEP_DURATION",
dayKey,
mode: "sleep-drill-down",
},
});
return apiSuccess({
measurements,
meta: { total: measurements.length, limit, offset: 0, dayKey },
});
}
// Per-night collapse — one synthetic row per wake day. The headline
// value is the MAIN night's TIME ASLEEP (the longest session that day),
// per the nap convention: naps are surfaced as a count, never folded
// into the main night's headline.
const byDay = new Map<string, typeof sessions>();
for (const s of sessions) {
const list = byDay.get(s.night) ?? [];
list.push(s);
byDay.set(s.night, list);
}
const dayUnit = getUnitForType("SLEEP_DURATION");
// Rows follow the page's night order from SQL.
const measurements = nightKeys.flatMap((day) => {
const { main, naps } = pickMainNightAndNaps(byDay.get(day) ?? []);
if (!main) return [];
return [
{
// Synthetic id mirrors the series route's `sleep:<night>` shape.
id: `sleep:${day}`,
type: "SLEEP_DURATION",
value: Math.round(main.asleepMinutes),
unit: dayUnit,
source: main.source ?? "MANUAL",
measuredAt: main.end.toISOString(),
notes: null,
dayKey: day,
// Drives the drill-down chevron — number of stage segments behind
// the night, matching the cumulative-type `sampleCount` contract.
sampleCount: main.segments.length,
sleepStages: main.stages as Record<string, number>,
inBedMinutes: main.inBedMinutes,
awakeMinutes: main.awakeMinutes,
napCount: naps.length,
napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0),
awakenings: main.awakenings,
},
];
});
annotate({
action: { name: "measurement.list" },
meta: {
total: page.total,
type: "SLEEP_DURATION",
mode: "sleep-per-night",
nights: measurements.length,
rowsScanned: rows.length,
},
});
return apiSuccess({
measurements,
meta: {
total: page.total,
limit,
offset,
groupBy: "night",
},
});
}
/**
* One page of sleep nights, chosen in Postgres.
*
* The stage rows are cut into sessions the way `reconstructSleepSessions`
* cuts them: ordered by start (`measured_at` minus the minutes), a new
* session wherever a row starts more than `SESSION_GAP_MS` after every
* earlier row has ended. A session counts when it holds an asleep row (a
* bare or granular asleep stage, or a stage-less one, with minutes), and its
* night is the local day its last row ends on. Nights are grouped, sorted
* and cut by `limit` / `offset`; `total` is the night count of the whole
* filtered set from the same query. `spanStart` / `spanEnd` bound the
* night's counting sessions, so the caller reads exactly their rows.
*
* This label is the list's own: the page is cut by it, a row carries it and
* a drill-down asks by it. It is taken over every writer, where the JS
* reconstruction takes the winning writer only, so the two can name
* different days (a losing phone's in-bed span running past midnight while
* the watch stops before it). The caller therefore never matches the two by
* label: it files each reconstructed session under the night whose span
* holds it, which is exact, because a reconstructed session is one of these
* sessions narrowed to its winner.
*
* With `night` set (a drill-down) the scan is cut to a window of 36 hours
* either side of that local day. Sessions are split at a three-hour gap, so
* a session ending on that day starts inside the window unless it lasted
* over a day and a half, and every session the cut truncates ends on another
* day.
*/
/** How far a drill-down's session scan reaches either side of its day. */
const DRILL_DOWN_REACH_MS = 36 * 60 * 60 * 1000;
async function sleepNightPage(
userId: string,
tz: string,
opts: {
from?: Date;
to?: Date;
sortDir: "asc" | "desc";
limit: number;
offset: number;
night?: string;
},
): Promise<{
nights: Array<{ night: string; spanStart: Date; spanEnd: Date }>;
total: number;
}> {
const gapSeconds = SESSION_GAP_MS / 1000;
const window = opts.night ? localDayWindow(opts.night, tz) : null;
// Direction comes from the Zod enum, never from raw input.
const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC");
const nightsCte = Prisma.sql`
WITH r AS (
SELECT
m."measured_at" AS e,
m."measured_at" - m."value" * interval '1 minute' AS s,
(m."value" > 0 AND (m."sleep_stage" IS NULL
OR m."sleep_stage" IN ('ASLEEP', 'REM', 'CORE', 'DEEP'))) AS asleep
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = 'SLEEP_DURATION'::measurement_type
AND m."deleted_at" IS NULL
${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty}
${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty}
${
window
? Prisma.sql`AND m."measured_at" >= ${new Date(window.dayStart.getTime() - DRILL_DOWN_REACH_MS)}
AND m."measured_at" < ${new Date(window.dayEnd.getTime() + DRILL_DOWN_REACH_MS)}`
: Prisma.empty
}
), o AS (
SELECT r.*, MAX(e) OVER (
ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
) AS prev_end
FROM r
), g AS (
SELECT o.*, SUM(
CASE WHEN prev_end IS NULL
OR s - prev_end > make_interval(secs => ${gapSeconds}::double precision) THEN 1 ELSE 0 END
) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid
FROM o
), sess AS (
SELECT MIN(s) AS s, MAX(e) AS e
FROM g
GROUP BY sid
HAVING BOOL_OR(asleep)
), nights AS (
SELECT
to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night,
MIN(s) AS span_start,
MAX(e) AS span_end
FROM sess
GROUP BY 1
)
`;
const rows = await prisma.$queryRaw<
Array<{ night: string; span_start: Date; span_end: Date; total: number }>
>`
${nightsCte}
SELECT night, span_start, span_end, (COUNT(*) OVER ())::int AS total
FROM nights
${opts.night ? Prisma.sql`WHERE night = ${opts.night}` : Prisma.empty}
ORDER BY night ${direction}
LIMIT ${opts.limit} OFFSET ${opts.offset}
`;
// A page past the end has no row to carry the total; only then is it
// counted on its own.
const total =
rows.length > 0
? Number(rows[0].total)
: opts.offset > 0
? Number(
(
await prisma.$queryRaw<Array<{ total: number }>>`
${nightsCte}
SELECT COUNT(*)::int AS total FROM nights
`
)[0]?.total ?? 0,
)
: 0;
return {
nights: rows.map((r) => ({
night: r.night,
spanStart: r.span_start,
spanEnd: r.span_end,
})),
total,
};
}
/**
* Does this body attribute a source to any reading — any source at all?
*