mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
/**
|
|
* The sleep list pages its nights in SQL (`sleepNightPage`) and fills them
|
|
* from the JS reconstruction (`reconstructSleepSessions`). The two cut the
|
|
* same stage rows into sessions and name each session's night by the same
|
|
* rule, or the list drops, doubles or mislabels nights. This suite runs both
|
|
* over one fixture set of awkward shapes and holds them to the same nights,
|
|
* then holds the list, the night view and the series to one answer per day.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
type Stage = "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP" | null;
|
|
|
|
interface Seg {
|
|
/** Segment end, minutes after the fixture day's UTC midnight. */
|
|
end: number;
|
|
minutes: number;
|
|
stage: Stage;
|
|
source?: "APPLE_HEALTH" | "WHOOP" | "WITHINGS";
|
|
deviceType?: string | null;
|
|
}
|
|
|
|
const H = 60;
|
|
|
|
/** A run of contiguous segments of `step` minutes cycling through stages. */
|
|
function run(
|
|
start: number,
|
|
end: number,
|
|
step: number,
|
|
stages: Stage[],
|
|
extra: Partial<Seg> = {},
|
|
): Seg[] {
|
|
const out: Seg[] = [];
|
|
let i = 0;
|
|
for (let t = start + step; t <= end; t += step) {
|
|
out.push({
|
|
end: t,
|
|
minutes: step,
|
|
stage: stages[i % stages.length],
|
|
...extra,
|
|
});
|
|
i += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const NIGHT: Stage[] = ["CORE", "DEEP", "CORE", "REM", "AWAKE"];
|
|
|
|
/**
|
|
* Ten shapes, one per day modulo ten, minutes relative to the day's UTC
|
|
* midnight (negative = the evening before).
|
|
*/
|
|
function shape(i: number): Seg[] {
|
|
switch (i % 10) {
|
|
case 0:
|
|
// A dense watch night in five-minute segments.
|
|
return run(-1 * H, 7 * H, 5, NIGHT, { deviceType: "watch" });
|
|
case 1:
|
|
// The watch stops before midnight, the phone's in-bed runs past it.
|
|
return [
|
|
...run(-6 * H, -10, 10, NIGHT, { deviceType: "watch" }),
|
|
{ end: 20, minutes: 6 * H + 20, stage: "IN_BED", deviceType: "phone" },
|
|
];
|
|
case 2:
|
|
// An afternoon nap and no night.
|
|
return run(13 * H, 14 * H, 15, ["CORE", "REM"]);
|
|
case 3:
|
|
// A night and a nap.
|
|
return [
|
|
...run(-2 * H, 6 * H, 30, NIGHT),
|
|
...run(14 * H, 14 * H + 40, 20, ["CORE"]),
|
|
];
|
|
case 4:
|
|
// A split night, three and a half hours apart: two sessions.
|
|
return [
|
|
...run(-2 * H, 1 * H, 30, NIGHT),
|
|
...run(4 * H + 30, 7 * H, 30, NIGHT),
|
|
];
|
|
case 5:
|
|
// Exactly the session gap apart: one session.
|
|
return [
|
|
...run(-2 * H, 1 * H, 30, NIGHT),
|
|
...run(4 * H, 7 * H, 30, NIGHT),
|
|
];
|
|
case 6:
|
|
// One long stage block.
|
|
return [
|
|
{ end: 3 * H, minutes: 4 * H, stage: "CORE" },
|
|
{ end: 5 * H, minutes: 2 * H, stage: "REM" },
|
|
];
|
|
case 7:
|
|
// A bare aggregate with its granular twin, and a zero-minute granular
|
|
// row from a second writer beside a bare night.
|
|
return [
|
|
{ end: 6 * H, minutes: 7 * H, stage: "ASLEEP" },
|
|
...run(-1 * H, 6 * H, 60, ["CORE", "DEEP", "REM"]),
|
|
{ end: 6 * H, minutes: 0, stage: "REM", source: "WITHINGS" },
|
|
{ end: 6 * H, minutes: 6 * H, stage: "ASLEEP", source: "WITHINGS" },
|
|
];
|
|
case 8:
|
|
// An in-bed-only evening and a stage-less night.
|
|
return [
|
|
{ end: -8 * H, minutes: 40, stage: "IN_BED" },
|
|
{ end: -8 * H + 30, minutes: 20, stage: "AWAKE" },
|
|
{ end: 6 * H, minutes: 6 * H, stage: null },
|
|
];
|
|
default:
|
|
// A summary-shaped writer (every stage on one instant) beside a
|
|
// partial timed night.
|
|
return [
|
|
{ end: 7 * H, minutes: 200, stage: "CORE", source: "WHOOP" },
|
|
{ end: 7 * H, minutes: 90, stage: "DEEP", source: "WHOOP" },
|
|
{ end: 7 * H, minutes: 80, stage: "REM", source: "WHOOP" },
|
|
...run(2 * H, 7 * H, 30, NIGHT),
|
|
];
|
|
}
|
|
}
|
|
|
|
const BASE = Date.UTC(2024, 2, 1); // three years back, across two DST turns
|
|
const DAYS = 120;
|
|
|
|
async function seed(timezone: string) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: `parity-${timezone.replace("/", "-")}`.toLowerCase(),
|
|
email: `parity-${timezone.length}@example.test`,
|
|
role: "USER",
|
|
timezone,
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
const rows = [];
|
|
for (let d = 0; d < DAYS; d++) {
|
|
const midnight = BASE + d * 86_400_000;
|
|
for (const [k, seg] of shape(d).entries()) {
|
|
rows.push({
|
|
userId: user.id,
|
|
type: "SLEEP_DURATION" as const,
|
|
value: seg.minutes,
|
|
unit: "minutes",
|
|
source: seg.source ?? ("APPLE_HEALTH" as const),
|
|
deviceType: seg.deviceType ?? null,
|
|
measuredAt: new Date(midnight + seg.end * 60_000),
|
|
externalId: `p-${d}-${k}`,
|
|
sleepStage: seg.stage,
|
|
});
|
|
}
|
|
}
|
|
await prisma.measurement.createMany({ data: rows });
|
|
return user;
|
|
}
|
|
|
|
interface ListRow {
|
|
dayKey: string;
|
|
value: number;
|
|
mainAsleepMinutes: number | null;
|
|
napCount: number;
|
|
napAsleepMinutes: number;
|
|
}
|
|
|
|
async function list(query: string) {
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const res = await GET(
|
|
new NextRequest(`http://localhost/api/measurements?${query}`),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()) as {
|
|
data: { measurements: ListRow[]; meta: { total: number } };
|
|
};
|
|
}
|
|
|
|
async function nightView(date: string) {
|
|
const { GET } = await import("@/app/api/sleep/night/route");
|
|
const res = await GET(
|
|
new NextRequest(`http://localhost/api/sleep/night?date=${date}`),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()) as {
|
|
data: {
|
|
night: string | null;
|
|
main: { asleepMinutes: number } | null;
|
|
naps: Array<{ asleepMinutes: number }>;
|
|
};
|
|
};
|
|
}
|
|
|
|
describe("sleep nights: SQL page and JS reconstruction agree", () => {
|
|
for (const tz of ["UTC", "Europe/Berlin"]) {
|
|
it(`names the same nights over every shape (${tz})`, async () => {
|
|
const user = await seed(tz);
|
|
const { sleepNightPage, readSleepRows } =
|
|
await import("@/lib/measurements/list/sleep-nights");
|
|
const { reconstructSleepSessions } =
|
|
await import("@/lib/analytics/sleep-night");
|
|
const page = await sleepNightPage(user.id, tz, {
|
|
sortDir: "asc",
|
|
limit: 10_000,
|
|
offset: 0,
|
|
});
|
|
const rows = await readSleepRows(
|
|
user.id,
|
|
new Date(BASE - 2 * 86_400_000),
|
|
new Date(BASE + (DAYS + 2) * 86_400_000),
|
|
);
|
|
const scorable = reconstructSleepSessions(rows, tz, null).filter(
|
|
(s) => s.asleepMinutes > 0,
|
|
);
|
|
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 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]));
|
|
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,
|
|
);
|
|
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 () => {
|
|
await seed("Europe/Berlin");
|
|
const first = await list("type=SLEEP_DURATION&limit=7&sortDir=asc");
|
|
const total = first.data.meta.total;
|
|
const rows: ListRow[] = [];
|
|
for (let offset = 0; offset < total; offset += 7) {
|
|
const page = await list(
|
|
`type=SLEEP_DURATION&limit=7&offset=${offset}&sortDir=asc`,
|
|
);
|
|
// No short page before the last one.
|
|
expect(page.data.measurements).toHaveLength(Math.min(7, total - offset));
|
|
rows.push(...page.data.measurements);
|
|
}
|
|
expect(rows).toHaveLength(total);
|
|
let napOnly = 0;
|
|
for (const row of rows) {
|
|
const view = await nightView(row.dayKey);
|
|
expect(view.data.night).toBe(row.dayKey);
|
|
// 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);
|
|
}
|
|
}
|
|
// The afternoon-nap days have no main night and still have their row.
|
|
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");
|
|
const res = await GET(
|
|
new NextRequest(
|
|
"http://localhost/api/measurements/series?kind=sleep&days=3650",
|
|
),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { points: Array<{ id: string }> };
|
|
};
|
|
const oldest = (await list("type=SLEEP_DURATION&limit=1&sortDir=asc")).data
|
|
.measurements[0].dayKey;
|
|
// Years old, past any one-year cut.
|
|
expect(body.data.points[0].id).toBe(`sleep:${oldest}`);
|
|
expect(oldest < "2024-03-03").toBe(true);
|
|
}, 240_000);
|
|
});
|