fix(sleep): count a re-exported sleep period once

A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.

Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.

stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 17:09:03 +02:00
1 parent 81ba711946
commit a3b0af577f
3 files changed
+554 -27

No files matched your search

@@ -201,4 +201,31 @@ describe("GET /api/sleep/night", () => {
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // never 500 on the reconstruction edge
});
it("cannot serve an in-bed headline that contradicts its stage legend", async () => {
// A device wrote the same night twice: an envelope 16:06 -> 02:09:59 and
// a corrected one 16:06 -> 02:18:59. Raw-summing them served a 613-minute
// headline next to a 1217-minute legend on the same screen.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-07-25T02:09:59.000Z", "IN_BED", 603 + 59 / 60),
stage("2026-07-25T02:18:59.000Z", "IN_BED", 612 + 59 / 60),
stage("2026-07-24T21:55:42.000Z", "CORE", 319.7),
stage("2026-07-24T23:30:36.000Z", "DEEP", 94.9),
stage("2026-07-24T23:37:31.200Z", "AWAKE", 6.92),
stage("2026-07-25T01:53:25.200Z", "REM", 135.9),
] as never);
const res = await GET(req("date=2026-07-25"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.main.inBedMinutes).toBe(613);
expect(body.data.main.stages.IN_BED).toBe(613);
// One in-bed bar on the hypnogram, not the night drawn twice over.
expect(
body.data.main.segments.filter(
(seg: { stage: string }) => seg.stage === "IN_BED",
),
).toHaveLength(1);
expect(body.data.main.stages.CORE).toBe(320);
expect(body.data.main.asleepMinutes).toBe(551);
});
});