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

+247 -56
View File
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
@@ -105,18 +106,71 @@ interface FakeSegment {
id?: number;
}
function installFetchMock(segments: FakeSegment[]) {
const fetchMock = vi.fn(async () => ({
status: 200,
json: async () => ({
status: 0,
body: { series: segments },
}),
}));
type FakeSummary = {
id?: number;
startdate: number;
enddate: number;
modified?: number;
data?: Record<string, number | null>;
};
/** The night list Withings would derive from these segments, one per session. */
function nightsOf(segments: FakeSegment[]): FakeSummary[] {
const bySession = new Map<string, FakeSummary>();
for (const seg of segments) {
const key = String(seg.id ?? "no-id");
const night = bySession.get(key);
bySession.set(
key,
night
? {
...night,
startdate: Math.min(night.startdate, seg.startdate),
enddate: Math.max(night.enddate, seg.enddate),
}
: { id: seg.id, startdate: seg.startdate, enddate: seg.enddate },
);
}
return [...bySession.values()];
}
/**
* Answer the two Sleep v2 actions the way Withings does: `getsummary` lists
* the nights, `get` returns the segments inside the requested span, and no
* more than its first 24 hours.
*/
function installSleepApi(segments: FakeSegment[], summaries: FakeSummary[]) {
const fetchMock = 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: summaries } }),
};
}
// Sleep v2 Get: "only the first 24h after startdate will be returned".
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: segments.filter(
(seg) => seg.startdate >= from && seg.enddate <= to,
),
},
}),
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function installFetchMock(segments: FakeSegment[]) {
return installSleepApi(segments, nightsOf(segments));
}
beforeEach(() => {
vi.resetAllMocks();
transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) =>
@@ -144,6 +198,8 @@ beforeEach(() => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
// No stage row stored yet: every listed night is one to read.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -158,21 +214,9 @@ afterEach(() => {
*/
function installSegmentThenSummaryFetch(
segments: FakeSegment[],
summaries: Array<{
id?: number;
startdate: number;
enddate: number;
data?: Record<string, number | null>;
}>,
summaries: FakeSummary[],
) {
let call = 0;
const fetchMock = vi.fn(async () => {
const body = call === 0 ? { series: segments } : { series: summaries };
call++;
return { status: 200, json: async () => ({ status: 0, body }) };
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
return installSleepApi(segments, summaries);
}
describe("mapWithingsSleepSummary", () => {
@@ -559,44 +603,50 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(byType.get("OXYGEN_SATURATION")?.value).toBe(95);
});
it("keeps stage segments when the summary fetch fails (v1.18.10 P0)", async () => {
let call = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
const isSummary = call > 0;
call++;
return {
status: 200,
json: async () =>
isSummary
? { status: 293 } // summary errors
: {
status: 0,
body: {
series: [
{
startdate: 1715000000,
enddate: 1715003600,
state: 2,
id: 1,
},
],
},
it("reads the last 24 hours and books the leg failed when the summary fails", async () => {
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
const params = new URLSearchParams(init.body);
return {
status: 200,
json: async () =>
params.get("action") === "getsummary"
? { status: 293 } // summary errors
: {
status: 0,
body: {
series: [
{
startdate: 1715000000,
enddate: 1715003600,
state: 2,
id: 1,
},
],
},
};
}),
);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
},
};
});
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
// The one stage segment is still written; the summary failure is swallowed.
// Without the night list one slice of the last 24 hours is read, so
// last night still lands; it is not swept, since a slice can cut a night.
expect(imported).toBe(1);
expect(reconcileMock).toHaveBeenCalledTimes(1);
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "sleep",
});
const stageCalls = fetchMock.mock.calls
.map(([, init]) => new URLSearchParams(init.body))
.filter((p) => p.get("action") === "get");
expect(stageCalls).toHaveLength(1);
expect(
Number(stageCalls[0]!.get("enddate")) -
Number(stageCalls[0]!.get("startdate")),
).toBe(24 * 3600);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
// The failure is the leg's, not a warning beside a green leg.
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({ integration: "withings", leg: "sleep" }),
);
});
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
@@ -848,3 +898,144 @@ describe("syncUserSleep — Withings API plan (status 277)", () => {
});
});
});
describe("syncUserSleep — one stage read per night (Sleep v2 Get answers 24 h)", () => {
const NOW = new Date("2026-10-21T09:00:00.000Z");
const nowUnix = Math.floor(NOW.getTime() / 1000);
const DAY = 24 * 3600;
/** Night `n` days back: 22:00 to 06:00, one CORE and one DEEP segment. */
function night(n: number, id: number) {
const end = nowUnix - 3 * 3600 - n * DAY; // 06:00 that morning
const startdate = end - 8 * 3600;
return {
summary: { id, startdate, enddate: end },
segments: [
{ startdate, enddate: startdate + 4 * 3600, state: 1, id },
{ startdate: startdate + 4 * 3600, enddate: end, state: 2, id },
],
};
}
function stageReads(fetchMock: ReturnType<typeof installSleepApi>) {
return fetchMock.mock.calls
.map(([, init]) => new URLSearchParams(init.body))
.filter((p) => p.get("action") === "get")
.map((p) => [Number(p.get("startdate")), Number(p.get("enddate"))]);
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("reads every listed night with its own span and lands all of them", async () => {
// Before, one read over the 30-day window answered only its first 24
// hours: last night's stages came weeks late.
const nights = [0, 3, 9].map((n, i) => night(n, 100 + i));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(6);
const bySpan = (a: number[], b: number[]) => a[0]! - b[0]!;
expect([...stageReads(fetchMock)].sort(bySpan)).toEqual(
nights.map((x) => [x.summary.startdate, x.summary.enddate]).sort(bySpan),
);
for (const [from, to] of stageReads(fetchMock)) {
expect(to - from).toBeLessThanOrEqual(DAY);
}
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
leg: "sleep",
});
});
it("re-reads the recent nights every run and skips older nights already stored", async () => {
const recent = night(0, 1);
const stored = night(5, 2);
const missing = night(6, 3);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ measuredAt: new Date(stored.summary.enddate * 1000) },
] as never);
// The recent night is stored too; it is read again for a re-score.
const fetchMock = installSleepApi(
[...recent.segments, ...stored.segments, ...missing.segments],
[recent.summary, stored.summary, missing.summary],
);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([
[recent.summary.startdate, recent.summary.enddate],
[missing.summary.startdate, missing.summary.enddate],
]);
});
it("re-reads an older stored night Withings changed in the last 48 hours", async () => {
const rescored = night(6, 4);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ measuredAt: new Date(rescored.summary.enddate * 1000) },
] as never);
const fetchMock = installSleepApi(rescored.segments, [
{ ...rescored.summary, modified: nowUnix - 3600 } as FakeSummary,
]);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([
[rescored.summary.startdate, rescored.summary.enddate],
]);
});
it("reads at most a run's budget of missing nights, rotating which", async () => {
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
await syncUserSleep("user-1");
const first = stageReads(fetchMock);
expect(first).toHaveLength(8);
fetchMock.mockClear();
vi.setSystemTime(new Date(NOW.getTime() + 3600_000));
await syncUserSleep("user-1");
const second = stageReads(fetchMock);
expect(second).toHaveLength(8);
expect(second).not.toEqual(first);
});
it("reads every night of the window on a full sync", async () => {
const nights = Array.from({ length: 12 }, (_, i) => night(i + 3, i + 1));
const fetchMock = installSleepApi(
nights.flatMap((x) => x.segments),
nights.map((x) => x.summary),
);
await syncUserSleep("user-1", { fullSync: true });
expect(stageReads(fetchMock)).toHaveLength(12);
});
it("caps a night longer than a day at the 24 hours one read can return", async () => {
const startdate = nowUnix - 30 * 3600;
const enddate = nowUnix - 2 * 3600; // 28 hours in bed
const fetchMock = installSleepApi(
[{ startdate, enddate: startdate + 3600, state: 1, id: 5 }],
[{ id: 5, startdate, enddate }],
);
await syncUserSleep("user-1");
expect(stageReads(fetchMock)).toEqual([[startdate, startdate + DAY]]);
// A capped read did not see the whole night, so the night is not swept.
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
});
+236 -92
View File
@@ -122,6 +122,8 @@ export interface WithingsSleepSummary {
id?: number;
startdate: number;
enddate: number;
/** When Withings last changed the night (unix seconds), e.g. a re-score. */
modified?: number;
data?: {
hr_average?: number | null;
rr_average?: number | null;
@@ -385,56 +387,145 @@ export async function syncUserSleep(
return 0;
}
void opts.fullSync;
const now = new Date();
const start = new Date(
now.getTime() - SLEEP_BACKFILL_DAYS * 24 * 60 * 60 * 1000,
);
const startUnix = Math.floor(start.getTime() / 1000);
const endUnix = Math.floor(now.getTime() / 1000);
let segments: WithingsSleepSegment[];
// The nights come first: `action=getsummary` lists every night of the
// window (and carries its vitals), and the stage timeline is then read per
// night. `action=get` answers only the first 24 hours after its
// `startdate` ("If your input startdate and enddate are separated by more
// than 24h, only the first 24h after startdate will be returned", Sleep v2
// Get); asked for the whole window it returned the night a month back and
// nothing since, so every night's stages arrived when the sliding window
// reached it, weeks late.
//
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and
// the respiration rate are Advanced Biomarkers, and one of them in
// `data_fields` has the whole summary refused with 277. Ask only for what
// the plan allows, remember what it refused, and re-probe weekly.
let summaries: WithingsSleepSummary[] = [];
let summaryFailure: unknown = null;
let nightsKnown = false;
try {
segments = await fetchWithingsSleep(
tokenInfo.accessToken,
startUnix,
endUnix,
);
} catch (err) {
// The stage timeline is a free-plan biomarker, so a plan refusal here is
// not expected. If Withings ever moves it, the leg reads nothing rather
// than failing: a 277 must not climb the strike ladder and park the
// measures, activity and ECG legs along with it.
if (isPlanGatedError(err)) {
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
return 0;
}
// v1.4.43 — typed-classification path (see sync-activity.ts
// for the full rationale). The thrown `WithingsApiError` carries
// the classification verdict; `recordWithingsSyncFailure` routes
// it through `classifyError`, which falls back to the legacy regex
// for un-prototyped errors (pg-boss JSON round-trip).
//
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
// this scope-gated endpoint always means scope-missing or
// token-revoked; force `reauth_required` so the 3-strike alert
// still pages on the unexpected case.
const withingsStatus =
err instanceof WithingsApiError ? err.withingsStatus : undefined;
if (withingsStatus === 403) {
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
leg: WITHINGS_LEG_SLEEP,
const planRow = await prisma.withingsConnection.findUnique({
where: { userId },
select: { apiPlanState: true },
});
const sleepPlan = parseWithingsApiPlanState(
planRow?.apiPlanState,
).sleepSummary;
const reprobed = isPlanReprobeDue(sleepPlan, now);
const skip = planSkipFor(sleepPlan, now);
const plan = await requestWithinPlan({
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
fetch: (fields) =>
fetchWithingsSleepSummary(
tokenInfo.accessToken,
ymdUtc(start),
ymdUtc(now),
fields,
),
});
summaries = plan.results;
nightsKnown = plan.acceptedAny;
if (
shouldPersistPlanLeg({
gated: plan.gated,
reprobed,
acceptedAny: plan.acceptedAny,
})
) {
await persistWithingsPlanLeg(userId, "sleepSummary", {
unavailable: [...skip, ...plan.unavailable],
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
});
}
} catch (err) {
// Booked as this leg's failure once the stages are written: the summary
// carries the vitals and names the nights, and a run without it is not
// a healthy one. It used to be a warning, and the leg read green.
summaryFailure = err;
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
// Without the night list (the summary failed, or the plan refused every
// field it was asked for) the last 24 hours are read as one slice, so last
// night still lands. Such a slice can cut a night, so it is never swept.
const windows: StageWindow[] = nightsKnown
? await stageWindowsToFetch(userId, summaries, now, {
all: opts.fullSync === true,
})
: [
{
startdate: endUnix - DAY_SECONDS,
enddate: endUnix,
wholeNight: false,
},
];
const segments: WithingsSleepSegment[] = [];
const wholeNightSessions = new Set<number>();
const seenSegments = new Set<string>();
for (const window of windows) {
let series: WithingsSleepSegment[];
try {
series = await fetchWithingsSleep(
tokenInfo.accessToken,
window.startdate,
window.enddate,
);
} catch (err) {
// The stage timeline is a free-plan biomarker, so a plan refusal here
// is not expected. If Withings ever moves it, the leg reads no stages
// rather than failing: a 277 must not climb the strike ladder and park
// the measures, activity and ECG legs along with it.
if (isPlanGatedError(err)) {
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
break;
}
// v1.4.43 — typed-classification path (see sync-activity.ts
// for the full rationale). The thrown `WithingsApiError` carries
// the classification verdict; `recordWithingsSyncFailure` routes
// it through `classifyError`, which falls back to the legacy regex
// for un-prototyped errors (pg-boss JSON round-trip).
//
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
// this scope-gated endpoint always means scope-missing or
// token-revoked; force `reauth_required` so the 3-strike alert
// still pages on the unexpected case.
const withingsStatus =
err instanceof WithingsApiError ? err.withingsStatus : undefined;
if (withingsStatus === 403) {
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
leg: WITHINGS_LEG_SLEEP,
});
throw err;
}
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
throw err;
}
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_SLEEP);
throw err;
for (const segment of series) {
// Two windows can return the same segment (a night longer than its
// 24-hour slice, a nap inside a night); it is written once.
const key = `${segment.id ?? "no-id"}:${segment.startdate}`;
if (seenSegments.has(key)) continue;
seenSegments.add(key);
segments.push(segment);
if (window.wholeNight && typeof segment.id === "number") {
wholeNightSessions.add(segment.id);
}
}
}
let imported = 0;
@@ -528,63 +619,19 @@ export async function syncUserSleep(
// running-index row for re-fetched sessions. Sessions absent from this fetch
// are never touched; a sweep failure never fails the sync (the session stays
// inside the rolling 30-day window, so the hourly cron retries it).
const sweeps: SleepSegmentSweep[] = [...freshBySession].map(
([sessionId, keepIds]) => ({
const sweeps: SleepSegmentSweep[] = [...freshBySession]
// Only a session read across its whole night is swept: a 24-hour slice
// can cut one, and sweeping it would retire the part outside the slice.
.filter(([sessionId]) => wholeNightSessions.has(sessionId))
.map(([sessionId, keepIds]) => ({
prefix: `withings:sleep:${userId}:${sessionId}:`,
keepIds,
}),
);
}));
await sweepStaleSleepSegments(userId, "WITHINGS", sweeps);
// v1.18.10 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
// isolated from the stage writes; database failures are hard sync failures.
//
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and the
// respiration rate are Advanced Biomarkers, and one of them in `data_fields`
// has the whole summary refused with 277. Ask only for what the plan allows,
// remember what it refused, and re-probe weekly.
let summaries: WithingsSleepSummary[] = [];
try {
const planRow = await prisma.withingsConnection.findUnique({
where: { userId },
select: { apiPlanState: true },
});
const sleepPlan = parseWithingsApiPlanState(
planRow?.apiPlanState,
).sleepSummary;
const reprobed = isPlanReprobeDue(sleepPlan, now);
const skip = planSkipFor(sleepPlan, now);
const plan = await requestWithinPlan({
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
fetch: (fields) =>
fetchWithingsSleepSummary(
tokenInfo.accessToken,
ymdUtc(start),
ymdUtc(now),
fields,
),
});
summaries = plan.results;
if (
shouldPersistPlanLeg({
gated: plan.gated,
reprobed,
acceptedAny: plan.acceptedAny,
})
) {
await persistWithingsPlanLeg(userId, "sleepSummary", {
unavailable: [...skip, ...plan.unavailable],
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
});
}
} catch (err) {
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
// / avg SpO2 / sleep score) from the per-night summary read above. Database
// failures are hard sync failures.
await prisma
.$transaction(
async (tx) => {
@@ -656,6 +703,103 @@ export async function syncUserSleep(
() => {},
);
if (summaryFailure !== null) {
await recordWithingsSyncFailure(userId, summaryFailure, WITHINGS_LEG_SLEEP);
return imported;
}
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
return imported;
}
const DAY_SECONDS = 24 * 60 * 60;
/** Nights that ended this recently are read again on every run (re-scores). */
const RECENT_NIGHT_SECONDS = 2 * DAY_SECONDS;
/**
* Stage reads per run beyond the recent nights. A new connection, or a
* window the sleep-timeline backfill just cleared, fills in over a few runs
* instead of firing thirty requests at once; a full sync reads every night.
*/
export const SLEEP_STAGE_NIGHTS_PER_RUN = 8;
/** One `action=get` read: a night's own span, at most 24 hours. */
interface StageWindow {
startdate: number;
enddate: number;
/** The read covers the whole night, so its session may be swept. */
wholeNight: boolean;
}
/**
* The nights whose stage timeline this run reads, from the summary's night
* list: every night that ended, or that Withings changed (`modified`), in the
* last 48 hours, since Withings re-scores a night after the fact; then nights
* that hold no Withings stage row yet,
* up to `SLEEP_STAGE_NIGHTS_PER_RUN`. The missing nights rotate by the hour,
* so a night that never carries stages (a tracker without them) cannot keep
* the ones behind it waiting.
*/
async function stageWindowsToFetch(
userId: string,
summaries: readonly WithingsSleepSummary[],
now: Date,
opts: { all: boolean },
): Promise<StageWindow[]> {
const nights = new Map<
string,
{ startdate: number; enddate: number; changed: number }
>();
for (const s of summaries) {
if (!Number.isFinite(s.startdate) || !Number.isFinite(s.enddate)) continue;
if (s.enddate <= s.startdate) continue;
const modified = Number.isFinite(s.modified) ? s.modified! : 0;
const key = `${s.startdate}:${s.enddate}`;
nights.set(key, {
startdate: s.startdate,
enddate: s.enddate,
changed: Math.max(s.enddate, modified, nights.get(key)?.changed ?? 0),
});
}
const all = [...nights.values()].sort((x, y) => y.enddate - x.enddate);
const toWindow = (n: {
startdate: number;
enddate: number;
}): StageWindow => ({
startdate: n.startdate,
enddate: Math.min(n.enddate, n.startdate + DAY_SECONDS),
wholeNight: n.enddate - n.startdate <= DAY_SECONDS,
});
if (opts.all || all.length === 0) return all.map(toWindow);
const nowUnix = Math.floor(now.getTime() / 1000);
// Recent: ended, or was changed by Withings, within the last 48 hours.
const recent = all.filter((n) => n.changed >= nowUnix - RECENT_NIGHT_SECONDS);
const older = all.filter((n) => n.changed < nowUnix - RECENT_NIGHT_SECONDS);
if (older.length === 0) return recent.map(toWindow);
// Segments are stamped at their END, so a night holds a stage row when one
// ends inside (startdate, enddate]. One read over the span of the older
// nights, on the `(user_id, type, measured_at)` index.
const first = Math.min(...older.map((n) => n.startdate));
const last = Math.max(...older.map((n) => n.enddate));
const stored = await prisma.measurement.findMany({
where: {
userId,
source: "WITHINGS",
type: SLEEP_TYPE,
deletedAt: null,
measuredAt: { gt: new Date(first * 1000), lte: new Date(last * 1000) },
},
select: { measuredAt: true },
});
const endsAt = stored.map((row) => row.measuredAt.getTime() / 1000);
const missing = older
.filter((n) => !endsAt.some((t) => t > n.startdate && t <= n.enddate))
.sort((x, y) => x.enddate - y.enddate);
const budget = Math.max(0, SLEEP_STAGE_NIGHTS_PER_RUN - recent.length);
if (missing.length <= budget) return [...recent, ...missing].map(toWindow);
const offset = Math.floor(nowUnix / 3600) % missing.length;
const rotated = [...missing.slice(offset), ...missing.slice(0, offset)];
return [...recent, ...rotated.slice(0, budget)].map(toWindow);
}
+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 },