mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user