mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
3004f55875
commit
63bbf0931c
20 files changed
+339
-94
No files matched your search
+64
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -845,7 +845,7 @@
|
||||
"nextPage": "다음 쪽",
|
||||
"dailyTotalCaption": "({count}개 샘플)",
|
||||
"openMetricInsights": "{type} 인사이트 열기",
|
||||
"sleepNapCaption": "+낮잠 {count}회",
|
||||
"sleepNapCaption": "낮잠 {count}회 포함",
|
||||
"sleepAwakeningsCaption": "깬 횟수 {count}회",
|
||||
"expandDay": "이 날의 샘플 보기",
|
||||
"collapseDay": "이 날의 샘플 숨기기",
|
||||
|
||||
+1
-1
@@ -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
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user