diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 1d8be88a7..7b59aa155 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index b3abf5504..78425d08c 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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( diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index 1e28ffd85..c5e1f8f34 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 9a856f88f..8c3f4a01d 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -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), }); }); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 3fe6a8620..32394cbb1 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -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; diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 1da61d7e5..7df75048c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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 });