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

@@ -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))