mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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