From 63bbf0931c4d212e0372bf05da8cd692feb6f1db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 09:28:40 +0200 Subject: [PATCH] fix(sleep): one day total for every sleep reader, and the latest night is a night A day's sleep is the main night plus its naps (daySleepMinutes). The series, the sleep score and the composition already read it that way; the sleep list, the CSV export and the correlation sleep channel read the main night alone, so a day of naps listed as 0 h and an average over the list counted it as a night without sleep. The list now carries the day total in value and the main night alone in the new mainAsleepMinutes field; the nap caption reads as included. GET /api/sleep/night without a date answered the latest day holding any sleep, so a nap after the last night left the hypnogram with no night to draw. It now walks back to the latest night with a main sleep; nap days stay reachable by date. The SQL night page bounded a night by its asleep sessions only, so an in-bed-only session of the same night fell outside a chunked read that the one-night read counted. The span now covers every session of the night. --- docs/api/openapi.yaml | 73 +++++++++-- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- .../api/sleep/night/__tests__/route.test.ts | 18 ++- src/app/api/sleep/night/route.ts | 10 +- .../measurements/measurement-list.tsx | 6 +- src/lib/analytics/sleep-night.ts | 18 +++ src/lib/export.ts | 33 ++--- .../insights/correlation-channel-series.ts | 9 +- src/lib/measurements/list/sleep-list.ts | 19 +-- src/lib/measurements/list/sleep-nights.ts | 80 ++++++------ src/lib/openapi/routes/measurements.ts | 27 +++- .../measurements-sleep-list.test.ts | 8 +- .../sleep-night-sql-parity.test.ts | 116 +++++++++++++++++- 20 files changed, 339 insertions(+), 94 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3230be4c9..89de3e04e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4207,11 +4207,13 @@ paths: With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way - over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0 - with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers - for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as - asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value - applied. + over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its + naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone + in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps + carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=` + answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes + `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the + value applied. parameters: - in: query name: type @@ -4732,16 +4734,18 @@ paths: source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` - omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut. - A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type." + omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over + and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is + answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new + measurement type." parameters: - in: query name: date schema: - description: Wake-day key (YYYY-MM-DD). Omit for the most recent night. + description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep. type: string pattern: ^\d{4}-\d{2}-\d{2}$ - description: Wake-day key (YYYY-MM-DD). Omit for the most recent night. + description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep. responses: "200": description: Reconstructed night. @@ -46284,6 +46288,23 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + mainAsleepMinutes: + description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the + day's total, the main night plus its naps. + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" + napCount: + description: Naps on the same wake day. + type: integer + minimum: 0 + maximum: 9007199254740991 + napAsleepMinutes: + description: Time asleep across those naps, in minutes. + type: number + minimum: 0 required: - id - type @@ -47108,6 +47129,23 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + mainAsleepMinutes: + description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the + day's total, the main night plus its naps. + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" + napCount: + description: Naps on the same wake day. + type: integer + minimum: 0 + maximum: 9007199254740991 + napAsleepMinutes: + description: Time asleep across those naps, in minutes. + type: number + minimum: 0 required: - id - type @@ -47289,6 +47327,23 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + mainAsleepMinutes: + description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the + day's total, the main night plus its naps. + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" + napCount: + description: Naps on the same wake day. + type: integer + minimum: 0 + maximum: 9007199254740991 + napAsleepMinutes: + description: Time asleep across those naps, in minutes. + type: number + minimum: 0 status: type: string enum: diff --git a/messages/de.json b/messages/de.json index b2667a7de..b2f45cd89 100644 --- a/messages/de.json +++ b/messages/de.json @@ -845,7 +845,7 @@ "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", "openMetricInsights": "{type}-Auswertung öffnen", - "sleepNapCaption": "+{count} Nickerchen", + "sleepNapCaption": "inkl. {count} Nickerchen", "sleepAwakeningsCaption": "{count} Wachphasen", "expandDay": "Einzelwerte dieses Tages anzeigen", "collapseDay": "Einzelwerte dieses Tages ausblenden", diff --git a/messages/en.json b/messages/en.json index 3e30108aa..b24c7614c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -845,7 +845,7 @@ "nextPage": "Next page", "dailyTotalCaption": "({count} samples)", "openMetricInsights": "Open {type} insights", - "sleepNapCaption": "+{count} nap(s)", + "sleepNapCaption": "incl. {count} nap(s)", "sleepAwakeningsCaption": "{count} awakening(s)", "expandDay": "Show samples for this day", "collapseDay": "Hide samples for this day", diff --git a/messages/es.json b/messages/es.json index a7e92f85e..ad0911981 100644 --- a/messages/es.json +++ b/messages/es.json @@ -845,7 +845,7 @@ "nextPage": "Página siguiente", "dailyTotalCaption": "({count} muestras)", "openMetricInsights": "Abrir análisis de {type}", - "sleepNapCaption": "+{count} siesta(s)", + "sleepNapCaption": "incl. {count} siesta(s)", "sleepAwakeningsCaption": "{count} despertar(es)", "expandDay": "Mostrar las muestras de este día", "collapseDay": "Ocultar las muestras de este día", diff --git a/messages/fr.json b/messages/fr.json index 2626bfb40..c646bb00d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -845,7 +845,7 @@ "nextPage": "Page suivante", "dailyTotalCaption": "({count} échantillons)", "openMetricInsights": "Ouvrir l'analyse {type}", - "sleepNapCaption": "+{count} sieste(s)", + "sleepNapCaption": "dont {count} sieste(s)", "sleepAwakeningsCaption": "{count} réveil(s)", "expandDay": "Afficher les échantillons de ce jour", "collapseDay": "Masquer les échantillons de ce jour", diff --git a/messages/it.json b/messages/it.json index 9f6082888..ed4265878 100644 --- a/messages/it.json +++ b/messages/it.json @@ -845,7 +845,7 @@ "nextPage": "Pagina successiva", "dailyTotalCaption": "({count} campioni)", "openMetricInsights": "Apri l'analisi {type}", - "sleepNapCaption": "+{count} pisolino/i", + "sleepNapCaption": "incl. {count} pisolino/i", "sleepAwakeningsCaption": "{count} risveglio/i", "expandDay": "Mostra i campioni di questa giornata", "collapseDay": "Nascondi i campioni di questa giornata", diff --git a/messages/ko.json b/messages/ko.json index 291d55291..a4a77cdba 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -845,7 +845,7 @@ "nextPage": "다음 쪽", "dailyTotalCaption": "({count}개 샘플)", "openMetricInsights": "{type} 인사이트 열기", - "sleepNapCaption": "+낮잠 {count}회", + "sleepNapCaption": "낮잠 {count}회 포함", "sleepAwakeningsCaption": "깬 횟수 {count}회", "expandDay": "이 날의 샘플 보기", "collapseDay": "이 날의 샘플 숨기기", diff --git a/messages/nl.json b/messages/nl.json index 21d713639..5ea71537a 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -845,7 +845,7 @@ "nextPage": "Volgende pagina", "dailyTotalCaption": "({count} metingen)", "openMetricInsights": "Inzichten voor {type} openen", - "sleepNapCaption": "+{count} dutje(s)", + "sleepNapCaption": "incl. {count} dutje(s)", "sleepAwakeningsCaption": "{count} keer wakker", "expandDay": "Metingen van deze dag tonen", "collapseDay": "Metingen van deze dag verbergen", diff --git a/messages/pl.json b/messages/pl.json index 4735fd572..b41dfa858 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -845,7 +845,7 @@ "nextPage": "Następna strona", "dailyTotalCaption": "({count} pomiarów)", "openMetricInsights": "Otwórz analizę {type}", - "sleepNapCaption": "+{count} drzemka/i", + "sleepNapCaption": "w tym {count} drzemka/i", "sleepAwakeningsCaption": "{count} przebudzenia", "expandDay": "Pokaż pomiary z tego dnia", "collapseDay": "Ukryj pomiary z tego dnia", diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index c5e1f8f34..483f888ee 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -28,6 +28,17 @@ vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled: vi.fn(async () => ({ enabled: true })), })); +// The latest-night walk pages nights in SQL; its own behaviour is pinned by +// the SQL parity integration suite. Here it is stubbed to a fixed day so the +// route's "no date" arm can be read. +vi.mock("@/lib/measurements/list/sleep-nights", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/measurements/list/sleep-nights") + >(); + return { ...actual, latestMainSleepNight: vi.fn(async () => null) }; +}); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -49,6 +60,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { latestMainSleepNight } from "@/lib/measurements/list/sleep-nights"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -133,15 +145,13 @@ describe("GET /api/sleep/night", () => { expect(Number.isInteger(body.data.main.awakeMinutes)).toBe(true); }); - it("defaults to the most recent scorable night when date omitted", async () => { + it("defaults to the latest main night when date omitted", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ 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); + vi.mocked(latestMainSleepNight).mockResolvedValueOnce("2026-06-04"); const res = await GET(req("")); const body = await res.json(); expect(body.data.night).toBe("2026-06-04"); diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 8c3f4a01d..0d909d975 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -17,7 +17,9 @@ * (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 that holds sleep, however old. + * `date` omitted → the most recent night with a main sleep, however old; a + * run of nap-only days since is stepped over (each stays reachable by its + * `date`). An account that has only ever napped gets its latest nap day. * * 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 @@ -39,7 +41,7 @@ import { type SleepSession, } from "@/lib/analytics/sleep-night"; import { - latestSleepNight, + latestMainSleepNight, readNightSessions, } from "@/lib/measurements/list/sleep-nights"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -131,7 +133,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ]); const targetDay = - date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined; + date ?? + (await latestMainSleepNight(user.id, tz, priorityJson)) ?? + undefined; const { main, naps } = targetDay ? pickMainNightAndNaps( (await readNightSessions(user.id, targetDay, tz, priorityJson)) diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 044567114..15c01cef7 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1989,9 +1989,9 @@ function DayDrillDown({ } /** - * v1.11.5 — sub-caption for a per-night sleep row: surfaces the nap count - * (naps are separate from the main night per the nap convention) and the - * night's mid-sleep awakenings when present. + * Sub-caption for a per-night sleep row: the row's value is the day's sleep, + * main night plus naps, so the caption says how many naps it includes, and + * the night's mid-sleep awakenings when present. */ function SleepNightCaption({ m }: { m: Measurement }) { const { t } = useTranslations(); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 732424e68..71d82ee7a 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -1282,6 +1282,24 @@ export function separateNightsAndNaps( return days.sort((a, b) => a.night.localeCompare(b.night)); } +/** + * A wake day's sleep total, the one definition every reader states: the main + * night plus every nap of the day. It equals `SleepNight.asleepMinutes` for + * the same rows (both sum each session's canonical-writer asleep minutes), + * which is what the series, the sleep score and the composition read; the + * main night alone stays available as `main.asleepMinutes` / + * `SleepNight.mainSession`. A day of naps alone totals its naps, not 0. + */ +export function daySleepMinutes(day: { + main: SleepSession | null; + naps: readonly SleepSession[]; +}): number { + return ( + (day.main?.asleepMinutes ?? 0) + + day.naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0) + ); +} + export interface SleepNightSummary { /** * A `DataSummary` whose every value is a per-night TIME-ASLEEP total diff --git a/src/lib/export.ts b/src/lib/export.ts index f2ad30b28..80718d53a 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -17,6 +17,7 @@ import { formatInUserTz } from "./tz/format"; import { reconstructSleepSessions, + daySleepMinutes, pickMainNightAndNaps, type SleepStageRow, } from "./analytics/sleep-night"; @@ -142,9 +143,9 @@ interface ExportMeasurement { * * v1.11.5 — SLEEP_DURATION is stored one row per STAGE per night. A flat * export emitted a wall of per-stage minutes with no night attribution. - * By default sleep rows are now COLLAPSED to one record per night (the - * main session's TIME ASLEEP per the nap convention) carrying the stage - * breakdown in `notes` so the single uniform CSV reads as one night, not + * By default sleep rows are now COLLAPSED to one record per wake day (the + * day's TIME ASLEEP, main night plus naps, `daySleepMinutes`) carrying the + * main night's stage breakdown and the naps in `notes` so the single uniform CSV reads as one night, not * many stages. Pass `granularity: "raw"` to keep the per-stage rows for * power users. Storage is untouched either way. */ @@ -224,11 +225,14 @@ export function formatMeasurementsForExport( const nightRecords: Array<{ at: Date; rec: ExportableRecord }> = []; for (const daySessions of byDay.values()) { const { main, naps } = pickMainNightAndNaps(daySessions); - if (!main) continue; - const stageParts = (Object.entries(main.stages) as Array<[string, number]>) - .filter(([, mins]) => mins > 0) - .map(([stage, mins]) => `${stage}=${Math.round(mins)}m`) - .join(" "); + const lead = main ?? naps.at(-1); + if (!lead) continue; + const stageParts = main + ? (Object.entries(main.stages) as Array<[string, number]>) + .filter(([, mins]) => mins > 0) + .map(([stage, mins]) => `${stage}=${Math.round(mins)}m`) + .join(" ") + : ""; const napNote = naps.length > 0 ? ` naps=${naps.length}(${Math.round( @@ -236,16 +240,17 @@ export function formatMeasurementsForExport( )}m)` : ""; const awakeningNote = - main.awakenings > 0 ? ` awakenings=${main.awakenings}` : ""; + main && main.awakenings > 0 ? ` awakenings=${main.awakenings}` : ""; nightRecords.push({ - at: main.end, + at: lead.end, rec: { type: "SLEEP_DURATION", - // Headline value = the main night's TIME ASLEEP in minutes. - value: Math.round(main.asleepMinutes), + // Headline value = the day's TIME ASLEEP in minutes, the main night + // plus its naps (`daySleepMinutes`), as the list and the series read. + value: Math.round(daySleepMinutes({ main, naps })), unit: "minutes", - measuredAt: formatTimestamp(main.end, userTz), - source: main.source ?? "", + measuredAt: formatTimestamp(lead.end, userTz), + source: lead.source ?? "", notes: `${stageParts}${napNote}${awakeningNote}`.trim(), glucoseContext: "", }, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 7c8382edf..47ea68dbb 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -58,6 +58,7 @@ import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { reconstructSleepSessions, + daySleepMinutes, pickMainNightAndNaps, type SleepStageRow, } from "@/lib/analytics/sleep-night"; @@ -831,11 +832,13 @@ function buildSleepDailySeries( byNight.set(s.night, list); } + // A day's sleep is the main night plus its naps (`daySleepMinutes`), the + // total the series route and the sleep score read. const points: DailySeriesPoint[] = []; for (const [night, nightSessions] of byNight) { - const { main } = pickMainNightAndNaps(nightSessions); - if (!main) continue; - points.push({ day: night, value: main.asleepMinutes }); + const day = pickMainNightAndNaps(nightSessions); + if (!day.main && day.naps.length === 0) continue; + points.push({ day: night, value: daySleepMinutes(day) }); } return points.sort((a, b) => (a.day < b.day ? -1 : 1)); } diff --git a/src/lib/measurements/list/sleep-list.ts b/src/lib/measurements/list/sleep-list.ts index 32b205de8..288849d35 100644 --- a/src/lib/measurements/list/sleep-list.ts +++ b/src/lib/measurements/list/sleep-list.ts @@ -9,17 +9,20 @@ * Two modes: * - `dayKey` set → drill-down: the night's stage SEGMENTS (the canonical * source's per-stage spans), read exactly as the night view reads them. - * - no `dayKey` → one synthetic row per night, value = the main sleep's 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. + * - no `dayKey` → one synthetic row per night, value = the day's TIME ASLEEP + * in minutes (the canonical unit): the main night plus that day's naps, + * `daySleepMinutes`, the same total the series and the sleep score read. + * The main night alone rides as `mainAsleepMinutes`, beside its stage + * breakdown, nap count and awakenings, so the UI can caption the night + * without a second fetch. * * The nights are paged in Postgres (`sleepNightPage`) and only the stage rows * of the page's nights are read. Every page night gets a row, so a page is as * long as its limit and `meta.total` counts exactly the rows a full walk sees. * A day whose only sleep is daytime naps has no main night: its row carries - * 0 minutes asleep and its naps, as `/api/sleep/night` answers the same day - * with `main: null` and the naps. + * the naps' minutes as its value and `mainAsleepMinutes: null`, as + * `/api/sleep/night?date=` answers the same day with `main: null` and the + * naps. */ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; @@ -27,6 +30,7 @@ import { getUnitForType } from "@/lib/validations/measurement"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveStoredTimezone } from "@/lib/tz/resolver"; import { + daySleepMinutes, pickMainNightAndNaps, reconstructSleepSessions, type SleepSession, @@ -131,7 +135,8 @@ export async function sleepListResponse( // Synthetic id mirrors the series route's `sleep:` shape. id: `sleep:${night}`, type: "SLEEP_DURATION", - value: main ? Math.round(main.asleepMinutes) : 0, + value: Math.round(daySleepMinutes({ main, naps })), + mainAsleepMinutes: main ? Math.round(main.asleepMinutes) : null, unit: dayUnit, source: main?.source ?? naps[0]?.source ?? "MANUAL", measuredAt: (main?.end ?? lastNap?.end ?? spanEnd).toISOString(), diff --git a/src/lib/measurements/list/sleep-nights.ts b/src/lib/measurements/list/sleep-nights.ts index ee6f3608a..156f9c308 100644 --- a/src/lib/measurements/list/sleep-nights.ts +++ b/src/lib/measurements/list/sleep-nights.ts @@ -15,6 +15,7 @@ import { SESSION_GAP_MS, reconstructSleepNights, reconstructSleepSessions, + separateNightsAndNaps, type SleepNight, type SleepSession, } from "@/lib/analytics/sleep-night"; @@ -117,38 +118,44 @@ export async function readNightSessions( return { sessions, rows }; } +/** How many nights one step of the latest-night walk reads. */ +const LATEST_WALK_STEP = 14; + /** - * The wake day of the most recent night that holds sleep, or null. Found - * from the latest asleep row: the night holding it is the newest one, and - * its sessions lie within the one-night reach of that row. + * The wake day of the most recent night with a main sleep, or, on an account + * that has only ever recorded naps, the most recent day of naps; null with no + * sleep at all. A day of naps alone is a real day of sleep, but it is not + * "last night": answering it would leave a hypnogram with no night to draw. + * Postgres names the nights newest first and the walk reconstructs them a + * step at a time until one holds a main sleep, so a run of nap-only days + * however long is stepped over and the heap holds a step's rows. */ -export async function latestSleepNight( +export async function latestMainSleepNight( userId: string, tz: string, priorityJson: unknown, ): Promise { - const latest = await prisma.measurement.findFirst({ - where: { - userId, - type: "SLEEP_DURATION", - deletedAt: null, - value: { gt: 0 }, - OR: [{ sleepStage: null }, { sleepStage: { in: [...ASLEEP_STAGES] } }], - }, - orderBy: { measuredAt: "desc" }, - select: { measuredAt: true }, - }); - if (!latest) return null; - const at = latest.measuredAt.getTime(); - const rows = await readSleepRows( - userId, - new Date(at - NIGHT_READ_REACH_MS), - new Date(at + NIGHT_READ_REACH_MS), - ); - const scorable = reconstructSleepSessions(rows, tz, priorityJson).filter( - (s) => s.asleepMinutes > 0, - ); - return scorable.at(-1)?.night ?? null; + let latestNapDay: string | null = null; + for (let offset = 0; ; offset += LATEST_WALK_STEP) { + const page = await sleepNightPage(userId, tz, { + sortDir: "desc", + limit: LATEST_WALK_STEP, + offset, + }); + if (page.nights.length === 0) return latestNapDay; + const keys = new Set(page.nights.map((n) => n.night)); + const rows = await readNightSpanRows(userId, page.nights); + const days = separateNightsAndNaps( + reconstructSleepSessions(rows, tz, priorityJson).filter((s) => + keys.has(s.night), + ), + ); + for (let i = days.length - 1; i >= 0; i -= 1) { + if (days[i].main) return days[i].night; + latestNapDay ??= days[i].night; + } + if (page.nights.length < LATEST_WALK_STEP) return latestNapDay; + } } /** One night of a page: its wake day and the span its sessions cover. */ @@ -164,13 +171,16 @@ export interface SleepNightSpan { * 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 (an - * `ASLEEP_STAGES` stage, or a stage-less row, with minutes), and its night is - * the local day its last row ends on: the reconstruction's own rule, so a - * page night and a reconstructed night name the same day. Nights are grouped, - * sorted and cut by `limit` / `offset`; `total` counts the whole filtered set. - * `spanStart` / `spanEnd` bound the night's counting sessions, so the caller - * reads exactly their rows. + * earlier row has ended. A session's night is the local day its last row + * ends on: the reconstruction's own rule, so a page night and a + * reconstructed night name the same day. A night counts when one of its + * sessions holds an asleep row (an `ASLEEP_STAGES` stage, or a stage-less + * row, with minutes). Nights are grouped, sorted and cut by `limit` / + * `offset`; `total` counts the whole filtered set. `spanStart` / `spanEnd` + * bound every session of the night, the in-bed-only ones included, because + * the reconstruction adds their in-bed time to the night: a span over the + * asleep sessions alone left an evening's in-bed block out of a chunked read + * that the one-night read, reaching a day and a half, counted. * * Before the cut, rows are folded into hour buckets by their end: min start, * max end, any asleep. That cannot move a session boundary. Two rows whose @@ -218,10 +228,9 @@ export async function sleepNightPage( ) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid FROM o ), sess AS ( - SELECT MIN(s) AS s, MAX(e) AS e + SELECT MIN(s) AS s, MAX(e) AS e, BOOL_OR(asleep) AS asleep 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, @@ -229,6 +238,7 @@ export async function sleepNightPage( MAX(e) AS span_end FROM sess GROUP BY 1 + HAVING BOOL_OR(asleep) ) `; const { rows, total } = await pageWithWindowTotal( diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index a2e25ad6d..e93227d1f 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -328,6 +328,27 @@ export const measurementResource = z // Synthesised-row extras — present only on the collapsed day-sum mode. dayKey: z.string().optional(), sampleCount: z.number().int().optional(), + // Per-night extras — present only on `type=SLEEP_DURATION` list rows. + mainAsleepMinutes: z + .number() + .int() + .nonnegative() + .nullable() + .optional() + .describe( + "The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the day's total, the main night plus its naps.", + ), + napCount: z + .number() + .int() + .nonnegative() + .optional() + .describe("Naps on the same wake day."), + napAsleepMinutes: z + .number() + .nonnegative() + .optional() + .describe("Time asleep across those naps, in minutes."), }) .meta({ id: "MeasurementResource", @@ -804,7 +825,7 @@ export const measurementPaths: NonNullable = { summary: "List measurements", description: "Filter by type + date range. Response is paged via offset/limit; default limit 100.\n\n" + - "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0 with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.", + "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=` answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.", requestParams: { query: listMeasurementsSchema, }, @@ -1118,7 +1139,7 @@ export const measurementPaths: NonNullable = { tags: ["Measurements"], summary: "Reconstructed sleep night (hypnogram source, v1.11.5)", description: - "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.", + "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.", requestParams: { query: z.object({ date: z @@ -1126,7 +1147,7 @@ export const measurementPaths: NonNullable = { .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe( - "Wake-day key (YYYY-MM-DD). Omit for the most recent night.", + "Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.", ), }), }, diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 5db0eaf56..673347319 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -54,6 +54,7 @@ interface ListEnvelope { sampleCount?: number; sleepStage?: string | null; napCount?: number; + mainAsleepMinutes?: number | null; awakenings?: number; }>; meta: { total: number; groupBy?: string; windowFrom?: string }; @@ -231,7 +232,9 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { expect(firstBody.data.meta.windowFrom).toBeUndefined(); expect(firstBody.data.measurements).toHaveLength(25); expect(firstBody.data.measurements[0].dayKey).toBe(dayKeyOf(morning)); - expect(firstBody.data.measurements[0].value).toBe(420); + // The day's sleep counts the nap; the main night rides beside it. + expect(firstBody.data.measurements[0].value).toBe(450); + expect(firstBody.data.measurements[0].mainAsleepMinutes).toBe(420); expect(firstBody.data.measurements[0].napCount).toBe(1); // The stage read covered the page's nights, not the history. const read = spy.mock.calls @@ -256,7 +259,8 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { expect(body.data.meta.total).toBe(NIGHTS); for (const row of body.data.measurements) { seen.push(row.dayKey!); - expect(row.value).toBe(420); + expect(row.mainAsleepMinutes).toBe(420); + expect(row.value).toBe(row.napCount ? 450 : 420); } } expect(seen).toHaveLength(NIGHTS); diff --git a/tests/integration/sleep-night-sql-parity.test.ts b/tests/integration/sleep-night-sql-parity.test.ts index 129ec0697..b6a11b0d7 100644 --- a/tests/integration/sleep-night-sql-parity.test.ts +++ b/tests/integration/sleep-night-sql-parity.test.ts @@ -191,6 +191,7 @@ async function seed(timezone: string) { interface ListRow { dayKey: string; value: number; + mainAsleepMinutes: number | null; napCount: number; napAsleepMinutes: number; } @@ -245,9 +246,15 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { const jsNights = [...new Set(scorable.map((s) => s.night))].sort(); expect(page.nights.map((n) => n.night)).toEqual(jsNights); expect(page.total).toBe(jsNights.length); - // Every reconstructed session lies inside its night's SQL span. + // Every reconstructed session of a page night lies inside the night's + // SQL span, the in-bed-only ones included: the reconstruction adds + // their in-bed time to the night, so a chunked read must reach them. const span = new Map(page.nights.map((n) => [n.night, n])); - for (const s of scorable) { + const sessions = reconstructSleepSessions(rows, tz, null).filter((s) => + span.has(s.night), + ); + expect(sessions.length).toBeGreaterThan(scorable.length); + for (const s of sessions) { const n = span.get(s.night)!; expect(s.start.getTime()).toBeGreaterThanOrEqual( n.spanStart.getTime() - 1000, @@ -255,6 +262,34 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { expect(s.end.getTime()).toBeLessThanOrEqual(n.spanEnd.getTime() + 1000); } }, 240_000); + + it(`reads the same in-bed time a night at a time and in chunks (${tz})`, async () => { + const user = await seed(tz); + const { readSleepNights, readSleepRows } = + await import("@/lib/measurements/list/sleep-nights"); + const { reconstructSleepNights } = + await import("@/lib/analytics/sleep-night"); + const chunked = await readSleepNights(user.id, tz, null, { + maxNights: 10_000, + }); + const whole = new Map( + reconstructSleepNights( + await readSleepRows( + user.id, + new Date(BASE - 2 * 86_400_000), + new Date(BASE + (DAYS + 2) * 86_400_000), + ), + tz, + null, + ).map((n) => [n.night, n]), + ); + expect(chunked.length).toBeGreaterThan(DAYS - 2); + for (const night of chunked) { + const ref = whole.get(night.night)!; + expect(night.inBedMinutes).toBe(ref.inBedMinutes); + expect(night.asleepMinutes).toBe(ref.asleepMinutes); + } + }, 240_000); } it("gives every page night a row the night view answers for the same day", async () => { @@ -275,8 +310,20 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { for (const row of rows) { const view = await nightView(row.dayKey); expect(view.data.night).toBe(row.dayKey); - expect(view.data.main?.asleepMinutes ?? 0).toBe(row.value); + // The row's value is the day's sleep, main night plus naps; the main + // night alone rides beside it. + const napMinutes = view.data.naps.reduce( + (sum, n) => sum + n.asleepMinutes, + 0, + ); + expect(row.value).toBe( + Math.round((view.data.main?.asleepMinutes ?? 0) + napMinutes), + ); + expect(row.mainAsleepMinutes).toBe( + view.data.main ? Math.round(view.data.main.asleepMinutes) : null, + ); expect(view.data.naps).toHaveLength(row.napCount); + if (row.napCount > 0) expect(row.value).toBeGreaterThan(0); if (!view.data.main) { napOnly += 1; expect(row.napCount).toBeGreaterThan(0); @@ -286,6 +333,69 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => { expect(napOnly).toBe(DAYS / 10); }, 480_000); + it("states the same day total in the list and the series", async () => { + await seed("Europe/Berlin"); + const { GET } = await import("@/app/api/measurements/series/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=sleep&days=3650", + ), + ); + expect(res.status).toBe(200); + const series = ( + (await res.json()) as { + data: { points: Array<{ id: string; value: number }> }; + } + ).data.points; + const rows = (await list("type=SLEEP_DURATION&limit=1000&sortDir=asc")).data + .measurements; + const byDay = new Map(series.map((p) => [p.id.slice("sleep:".length), p])); + let napDays = 0; + for (const row of rows) { + const point = byDay.get(row.dayKey); + expect(point, row.dayKey).toBeDefined(); + // Hours to two places against whole minutes: within a minute. + expect(Math.abs(point!.value * 60 - row.value)).toBeLessThanOrEqual(1); + if (row.napCount > 0) napDays += 1; + } + expect(napDays).toBeGreaterThan(0); + }, 240_000); + + it("answers the latest main night, not a later day of naps alone", async () => { + const user = await seed("UTC"); + const prisma = getPrismaClient(); + // Two afternoon naps on the two days after the last fixture day. + const after = BASE + DAYS * 86_400_000; + await prisma.measurement.createMany({ + data: [0, 1].map((d) => ({ + userId: user.id, + type: "SLEEP_DURATION" as const, + value: 40, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(after + d * 86_400_000 + 14 * 3_600_000), + externalId: `late-nap-${d}`, + sleepStage: "CORE" as const, + })), + }); + const { GET } = await import("@/app/api/sleep/night/route"); + const res = await GET(new NextRequest("http://localhost/api/sleep/night")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { night: string; main: unknown; naps: unknown[] }; + }; + expect(body.data.main).not.toBeNull(); + // The answer is a fixture night, before the naps. + expect(body.data.night < new Date(after).toISOString().slice(0, 10)).toBe( + true, + ); + // The nap days stay reachable by date. + const napDay = new Date(after + 86_400_000).toISOString().slice(0, 10); + const nap = await nightView(napDay); + expect(nap.data.main).toBeNull(); + expect(nap.data.naps).toHaveLength(1); + }, 240_000); + it("reaches the oldest night from the series' All range", async () => { await seed("Europe/Berlin"); const { GET } = await import("@/app/api/measurements/series/route");