fix(measurements): file sleep sessions by span, not by night label

The SQL page labels a sleep session by the last row of any writer; the
reconstruction labels it by its winning writer. A watch stopping before
midnight beside a phone whose in-bed span ran past it gave two days,
and matching the two by label dropped the night from the list while
the total still counted it. Each reconstructed session is now filed
under the page night whose span holds it and takes that night's label,
which the drill-down also asks by.

A sleep drill-down scans 36 hours either side of its day instead of the
whole history. Both day branches and the sleep list resolve the zone
through the same stored-zone resolver, so a drill-down always cuts the
days its list row summed. The list description states the real page
bounds: a day-sum page takes `limit` as asked, a sleep page caps at
1000 nights.

Refs #1245
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 19:49:11 +02:00
1 parent d953789ecc
commit bf84c0c11e
5 files changed
+122 -31

No files matched your search

@@ -88,7 +88,10 @@ async function seedSession(username: string) {
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
// An hour, not a minute: walking every page of a long history can
// outlast a minute on a loaded runner, and an expired session reads as
// a 401 halfway through.
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
@@ -207,7 +210,7 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => {
).json()) as ListEnvelope;
expect(past.data.measurements).toEqual([]);
expect(past.data.meta.total).toBe(expected.size);
});
}, 240_000);
it("starts at the oldest day ascending and counts only the filtered window", async () => {
const user = await seedSession("day-paging-window-user");
@@ -235,5 +238,5 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => {
}
expect(windowed.data.meta.total).toBe(inWindow);
expect(windowed.data.measurements).toHaveLength(PAGE);
});
}, 240_000);
});
@@ -66,7 +66,10 @@ async function seedSession(username: string) {
data: { username, email: `${username}@example.test`, role: "USER" },
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
// An hour, not a minute: walking every page of a long history can
// outlast a minute on a loaded runner, and an expired session reads as
// a 401 halfway through.
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
@@ -287,5 +290,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => {
).json()) as ListEnvelope;
expect(drill.data.measurements).toHaveLength(1);
expect(drill.data.measurements[0].sleepStage).toBe("CORE");
}, 240_000);
// Review finding on the SQL paging: the page labels a session by the last
// row of ANY writer, the reconstruction by its winning writer. A watch that
// stops before midnight beside a phone whose in-bed span runs past it gave
// two different days, and matching by label dropped the night from both.
it("keeps a night whose losing writer runs past midnight", async () => {
const prisma = getPrismaClient();
const user = await seedSession("sleep-midnight-user");
await prisma.user.update({
where: { id: user.id },
data: { timezone: "UTC" },
});
const base = {
userId: user.id,
type: "SLEEP_DURATION" as const,
unit: "minutes",
source: "APPLE_HEALTH" as const,
};
await prisma.measurement.createMany({
data: [
// The watch wins the device ladder and ends at 23:50.
{
...base,
value: 420,
measuredAt: new Date("2026-03-10T23:50:00.000Z"),
externalId: "uuid-watch-core",
sleepStage: "CORE" as const,
deviceType: "watch",
},
// The phone's in-bed span ends at 00:20 the next day.
{
...base,
value: 450,
measuredAt: new Date("2026-03-11T00:20:00.000Z"),
externalId: "uuid-phone-inbed",
sleepStage: "IN_BED" as const,
deviceType: "phone",
},
],
});
const list = (await (
await callList("type=SLEEP_DURATION")
).json()) as ListEnvelope;
expect(list.data.meta.total).toBe(1);
// The night is listed, not lost between two labels.
expect(list.data.measurements).toHaveLength(1);
const [night] = list.data.measurements;
expect(night.value).toBe(420);
// Its drill-down, asked by the label the list gave it, finds it too.
const drill = (await (
await callList(`type=SLEEP_DURATION&dayKey=${night.dayKey}`)
).json()) as ListEnvelope;
expect(drill.data.measurements.map((m) => m.sleepStage)).toEqual(["CORE"]);
});
});