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

+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.",
),
}),
},