refactor(measurements): move the list modes out of the route and page every night

The 1500-line list route carried the day-grouped list, the sleep list
and its SQL night page, each with its own copy of the window-total
paging. They now live in src/lib/measurements/list/ with one paging
helper, and the route dispatches.

The sleep list gives every page night a row. A night whose
reconstruction held no main sleep (a day of naps alone) used to be
dropped while meta.total still counted it, which left short pages; its
row now carries 0 minutes and its naps. The SQL night cut splices the
asleep stages from ASLEEP_STAGES and folds rows into hour buckets
before the window functions, which cannot move a session boundary and
sorts a handful of rows per night instead of every stage. A drill-down
reads its night the way the night view does.

groupBy=day refuses valueMin / valueMax with 422: a value range filters
single readings, and the rows promise whole days.

An integration test runs the SQL page and reconstructSleepSessions over
one set of awkward shapes and holds them to the same nights, and the
list, the night view and the series to one answer per day. The unit
test of the day-grouped branch now pins only the branch; what the rows
hold is pinned against Postgres.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:22:53 +02:00
1 parent 6328117bd2
commit 3da72136ba
10 files changed
+1178 -841

No files matched your search

+3 -3
View File
@@ -465,7 +465,7 @@ const sleepNightResponse = z
.meta({
id: "SleepNightResource",
description:
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately. A session is a nap when it holds under three hours asleep centred between 10:00 and 20:00 local time; the main session is the longest session that is not a nap. A day whose only sleep is naps has `main: null` and its naps.",
});
// ── Sleep rhythm (sleep-debt + chronotype, v1.17.0) ──────────────────
@@ -804,7 +804,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. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights. 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 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.",
requestParams: {
query: listMeasurementsSchema,
},
@@ -1118,7 +1118,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. 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 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.",
requestParams: {
query: z.object({
date: z