fix(sleep): read a night of any age and the whole series range

The night view read a year of stage rows whatever date it was asked
for, so an older night came back with no main session while a year of
rows sat in the heap; it now reads a day and a half either side of the
night, and the latest night from the latest asleep row. A day of naps
alone answers with its naps and main null.

The series cut "All" to a year for sleep without saying so. Postgres
now names the nights and the rows are reconstructed ninety nights at a
time, so the range reaches back as far as the history does.

The metric status read the latest 2190 stage rows, a few weeks on a
stage-rich watch, starting mid-night; it now reads the latest 365
nights the same way.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:23:10 +02:00
1 parent 3da72136ba
commit b7e48e933c
6 files changed
+125 -113

No files matched your search

@@ -341,8 +341,17 @@ describe("GET /api/measurements/series", () => {
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// series must return ONE point per night carrying the TIME-ASLEEP
// total in hours, not a point per stage.
// total in hours, not a point per stage. Postgres names the night and
// its span; the stage rows of that span are reconstructed.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
night: "2026-06-04",
span_start: new Date("2026-06-03T15:00:00.000Z"),
span_end: new Date("2026-06-04T05:00:00.000Z"),
total: 1,
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "s1",
+11 -39
View File
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
@@ -55,13 +55,6 @@ import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
* window is capped to one year even when the client requests the 3650-day
* "Alle" range. Matches the slim slice's `withSleepNightTotals` sleep read.
*/
const SLEEP_SERIES_MAX_DAYS = 365;
/**
* v1.28.25 — sample-dense kinds read raw rows only up to this window.
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
@@ -221,7 +214,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
if (!parsed.success) {
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// v1.4.43 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
@@ -295,42 +288,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
// dual-source night to one canonical source via the user's `sleep`
// priority ladder.
//
// v1.11.4 — sleep is the one kind read row-per-stage (≈5 rows/night ×
// source-count) rather than from the day-bucketed rollup, so an
// unbounded "Alle" (days = 3650) request on a multi-year dual-source
// account would walk a six-figure row set into JS. Cap the sleep read
// at SLEEP_SERIES_MAX_DAYS (365 d) — the same one-year bound the slim
// slice's `withSleepNightTotals` uses — so the window stays small
// regardless of the requested range. Other kinds read from the rollup
// tier and keep the full 3650-day range.
const sleepSince = new Date(
Date.now() - Math.min(days, SLEEP_SERIES_MAX_DAYS) * 86_400_000,
);
// Sleep is the one kind read row-per-stage rather than from the rollup
// tier, so the nights are named in Postgres and reconstructed a chunk at
// a time (`readSleepNights`): "All" reaches as far back as the history
// does, and the stage rows in the heap stay a chunk's worth.
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
]);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
measuredAt: { gte: sleepSince },
deletedAt: null,
},
orderBy: { measuredAt: "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,
},
const nights = await readSleepNights(user.id, tz, priorityJson, {
from: since,
// A window of `days` days holds at most one night more than that.
maxNights: days + 1,
});
unit = "h";
points = reconstructSleepNights(rows, tz, priorityJson)
points = nights
.filter((n) => n.asleepMinutes > 0)
.map((n) => {
const stageHours = Object.fromEntries(
@@ -3,7 +3,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
measurement: { findMany: vi.fn(), findFirst: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
@@ -76,6 +76,7 @@ function stage(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
@@ -138,12 +139,48 @@ describe("GET /api/sleep/night", () => {
stage("2026-06-02T03:00:00.000Z", "CORE", 200),
stage("2026-06-04T03:00:00.000Z", "CORE", 300),
] as never);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
measuredAt: new Date("2026-06-04T03:00:00.000Z"),
} as never);
const res = await GET(req(""));
const body = await res.json();
expect(body.data.night).toBe("2026-06-04");
expect(body.data.main.asleepMinutes).toBe(300);
});
it("reads a night of any age from a day and a half either side of it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2019-03-01T06:00:00.000Z", "CORE", 420),
] as never);
const res = await GET(req("date=2019-03-01"));
const body = await res.json();
// Years past any trailing window, the night is still served.
expect(body.data.main.asleepMinutes).toBe(420);
const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]!
.where as { AND: Array<{ measuredAt: { gte: Date; lte: Date } }> };
expect(where.AND[0].measuredAt.gte.toISOString()).toBe(
"2019-02-27T12:00:00.000Z",
);
expect(where.AND[0].measuredAt.lte.toISOString()).toBe(
"2019-03-03T12:00:00.000Z",
);
});
it("answers a day of naps alone with its naps and no main night", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
// 13:00 to 14:00 UTC, nothing overnight.
stage("2026-06-04T14:00:00.000Z", "CORE", 60),
] as never);
const res = await GET(req("date=2026-06-04"));
const body = await res.json();
expect(body.data.night).toBe("2026-06-04");
expect(body.data.main).toBeNull();
expect(body.data.naps).toHaveLength(1);
expect(body.data.naps[0].asleepMinutes).toBe(60);
});
it("surfaces a daytime nap separately from the main night", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
@@ -169,7 +206,7 @@ describe("GET /api/sleep/night", () => {
expect(body.data.naps).toEqual([]);
});
// A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows
// A reconstruction edge (a session of only bare ASLEEP / stage-less rows
// alongside a granular partition for the same span) must return a valid empty
// night, NEVER a 500 from the unguarded `segments[0]` access.
it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => {
+19 -41
View File
@@ -17,13 +17,16 @@
* (WHOOP) — the client renders the hypnogram but labels it an approximate
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
*
* `date` omitted → the most recent night in the trailing-year window.
* `date` omitted → the most recent night that holds sleep, however old.
*
* Only the stage rows within a day and a half of the night are read, as the
* list's drill-down reads them (`readNightSessions`), so any past night is
* answered and a long history never reaches the heap.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -32,18 +35,18 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
type SleepSession,
} from "@/lib/analytics/sleep-night";
import {
latestSleepNight,
readNightSessions,
} from "@/lib/measurements/list/sleep-nights";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isCalendarDateKey } from "@/lib/tz/date-only";
/** Sleep is read row-per-stage, so the window is bounded to one year. */
const SLEEP_NIGHT_MAX_DAYS = 365;
const querySchema = z.object({
// Optional wake-day key; when omitted the latest night is returned.
date: z
@@ -127,36 +130,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
loadUserSourcePriority(user.id),
]);
const since = new Date(Date.now() - SLEEP_NIGHT_MAX_DAYS * 86_400_000);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: {
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,
},
});
const sessions = reconstructSleepSessions(rows, tz, priorityJson);
// Resolve the target wake day: the requested `date`, else the most
// recent wake day that has a scorable session.
const scorable = sessions.filter((s) => s.asleepMinutes > 0);
const targetDay =
date ??
(scorable.length > 0 ? scorable[scorable.length - 1].night : undefined);
const daySessions = sessions.filter((s) => s.night === targetDay);
const { main, naps } = pickMainNightAndNaps(daySessions);
date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined;
const { main, naps } = targetDay
? pickMainNightAndNaps(
(await readNightSessions(user.id, targetDay, tz, priorityJson))
.sessions,
)
: { main: null, naps: [] };
annotate({
action: { name: "sleep.night" },
@@ -167,13 +148,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
if (!main) {
return apiSuccess({ night: targetDay ?? null, main: null, naps: [] });
}
// A day of naps alone answers with its naps and no main night.
return apiSuccess({
night: main.night,
main: serializeSession(main),
night: targetDay ?? null,
main: main ? serializeSession(main) : null,
naps: naps.map(serializeSession),
});
});
@@ -110,6 +110,38 @@ function stubCompletion(
);
}
/**
* Postgres names the nights the sleep read reconstructs; answer the night
* page query with the one night the stage rows make, and every other raw
* query with nothing.
*/
function nightPageOver(rows: Array<{ value: number; measuredAt: Date }>) {
const start = Math.min(
...rows.map((r) => r.measuredAt.getTime() - r.value * 60_000),
);
const end = Math.max(...rows.map((r) => r.measuredAt.getTime()));
vi.mocked(prisma.$queryRaw).mockImplementation(((
_strings: TemplateStringsArray,
...values: unknown[]
) =>
Promise.resolve(
values.some((v) =>
String((v as { sql?: string })?.sql ?? "").includes("sess AS"),
)
? [
{
night: new Date(end).toLocaleDateString("sv-SE", {
timeZone: "Europe/Berlin",
}),
span_start: new Date(start),
span_end: new Date(end),
total: 1,
},
]
: [],
)) as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
@@ -167,6 +199,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
stageRows as never,
);
nightPageOver(stageRows);
const capture = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
@@ -190,7 +223,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
expect(prompt).not.toContain('"value": 90');
});
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum (A4)", async () => {
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum", async () => {
// The ~20.3 h symptom: a source writes BOTH a bare ASLEEP aggregate AND the
// granular CORE/DEEP/REM partition for the same span. Pre-fix the graded
// `series` came from `buildGradedSeriesWithRollups`, which folds bare + each
@@ -249,6 +282,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
stageRows as never,
);
nightPageOver(stageRows);
const capture = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
+11 -29
View File
@@ -71,10 +71,7 @@ import {
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import {
@@ -295,31 +292,16 @@ export async function generateMetricStatus(args: {
const points: Array<{ measuredAt: Date; value: number }> = [];
let measurements: Array<{ measuredAt: Date }> = [];
if (isSleep) {
const [priorityJson, sleepRows] = await Promise.all([
loadUserSourcePriority(args.userId),
prisma.measurement.findMany({
where: {
userId: args.userId,
type: "SLEEP_DURATION",
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
]);
const nights = reconstructSleepNights(
sleepRows as SleepStageRow[],
userTz,
priorityJson,
// The latest 365 nights, the sleep twin of the 365 latest readings the
// other types read. A cut on stage rows instead (2190 of them) reached
// back a few weeks on a stage-rich watch and began mid-night.
const nights = (
await readSleepNights(
args.userId,
userTz,
await loadUserSourcePriority(args.userId),
{ maxNights: 365, newest: true },
)
).filter((n) => n.asleepMinutes > 0);
for (const n of nights) {
points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes });