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

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