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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:28:40 +02:00
1 parent 3004f55875
commit 63bbf0931c
20 files changed
+339 -94

No files matched your search

+64 -9
View File
@@ -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:
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -845,7 +845,7 @@
"nextPage": "다음 쪽",
"dailyTotalCaption": "({count}개 샘플)",
"openMetricInsights": "{type} 인사이트 열기",
"sleepNapCaption": "+낮잠 {count}회",
"sleepNapCaption": "낮잠 {count}회 포함",
"sleepAwakeningsCaption": "깬 횟수 {count}회",
"expandDay": "이 날의 샘플 보기",
"collapseDay": "이 날의 샘플 숨기기",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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");
+7 -3
View File
@@ -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))
@@ -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();
+18
View File
@@ -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
+19 -14
View File
@@ -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: "",
},
@@ -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));
}
+12 -7
View File
@@ -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:<night>` 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(),
+45 -35
View File
@@ -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<string | null> {
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(
+24 -3
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
.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.",
),
}),
},
@@ -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);
@@ -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");