mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
3b1cdd7a1f
commit
c1921d1dd1
3 files changed
+536
-158
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user