fix(withings): read the sleep stages night by night

Sleep v2 Get answers only the first 24 hours after its startdate. The
sync asked for the whole 30-day window, so it got the night a month
back and nothing since: every night's stages arrived when the sliding
window reached it, weeks late.

The summary now runs first and lists the nights; each night's stages
are read over its own span (at most 24 hours). Every run re-reads the
nights that ended, or that Withings changed, in the last 48 hours, and
fills older nights that hold no stage row yet, eight per run with a
rotation so a night without stages cannot hold up the rest; a full
sync reads them all. Without the night list the last 24 hours are read
as one slice and nothing is swept, since a slice can cut a night.

A summary failure used to be a warning beside a green leg. It is now
the sleep leg's failure, recorded after the stages land.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:59:43 +02:00
1 parent 3b1cdd7a1f
commit c1921d1dd1
3 files changed
+536 -158

No files matched your search

+53 -10
View File
@@ -51,26 +51,70 @@ afterEach(() => {
vi.restoreAllMocks();
});
function stubWithingsSleep(series: unknown[]) {
type Segment = {
startdate: number;
enddate: number;
state: number;
id: number;
};
/**
* Answer Sleep v2 the way Withings does: `getsummary` lists the nights,
* `get` returns the segments inside the span asked for, at most its first
* 24 hours.
*/
function stubWithingsSleep(series: Segment[]) {
const nights = new Map<
number,
{ id: number; startdate: number; enddate: number }
>();
for (const seg of series) {
const night = nights.get(seg.id);
nights.set(seg.id, {
id: seg.id,
startdate: Math.min(night?.startdate ?? seg.startdate, seg.startdate),
enddate: Math.max(night?.enddate ?? seg.enddate, seg.enddate),
});
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { series },
}),
})),
vi.fn(async (_url: string, init: { body: string }) => {
const params = new URLSearchParams(init.body);
if (params.get("action") === "getsummary") {
return {
status: 200,
json: async () => ({
status: 0,
body: { series: [...nights.values()] },
}),
};
}
const from = Number(params.get("startdate"));
const to = Math.min(Number(params.get("enddate")), from + 24 * 3600);
return {
status: 200,
json: async () => ({
status: 0,
body: {
series: series.filter(
(seg) => seg.startdate >= from && seg.enddate <= to,
),
},
}),
};
}),
);
}
/** Last night: it started ten hours ago, so a run reads it again for a re-score. */
const base = Math.floor(Date.now() / 1000) - 10 * 3600;
describe("syncUserSleep — integration", () => {
it("persists one Measurement row per stage segment for a single night", async () => {
// Sleep session #99: 4 segments — CORE 60m, DEEP 30m, REM 30m, AWAKE 10m.
// All segments share the same parent night but each carries a
// distinct sleepStage, so the W17b/c composite (Migration 0055)
// keeps every row.
const base = 1715000000; // 2024-05-06 13:33:20 UTC (arbitrary)
stubWithingsSleep([
{ startdate: base, enddate: base + 3600, state: 1, id: 99 },
{ startdate: base + 3600, enddate: base + 5400, state: 2, id: 99 },
@@ -106,7 +150,6 @@ describe("syncUserSleep — integration", () => {
});
it("re-syncing the same night updates rows in place (no duplicates)", async () => {
const base = 1715000000;
// First sync: DEEP 30 minutes.
stubWithingsSleep([
{ startdate: base, enddate: base + 1800, state: 2, id: 100 },