feat(withings): capture nightly sleep vitals from the summary endpoint

The per-segment sleep sync wrote only the stage timeline; the per-night
summary's vitals — average HR, respiratory rate, SDNN-based HRV, average
SpO2 and the sleep score — were dropped at the fetch. Add a getsummary
fetch that maps each vital to its existing MeasurementType (RESTING_HEART_RATE,
RESPIRATORY_RATE, HEART_RATE_VARIABILITY, OXYGEN_SATURATION, SLEEP_SCORE), so
no migration and no client-contract change: the rows flow to the sync delta
under the WITHINGS source like every other server-side fetch.

Each vital upserts on the stable (userId, type, source, externalId) composite
keyed by the night's session id, so a re-sync overwrites rather than
duplicates. A summary fetch failure is isolated so the stage segments already
written survive. Fractional SpO2 is normalised to percent.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:51:51 +02:00
1 parent fa37941add
commit fb4e3921e2
2 files changed
+360

No files matched your search

@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
withingsConnection: { findUnique: vi.fn() },
},
@@ -69,6 +70,7 @@ import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollup
import {
fetchWithingsSleep,
mapWithingsSleepState,
mapWithingsSleepSummary,
syncUserSleep,
} from "../sync-sleep";
@@ -107,6 +109,71 @@ afterEach(() => {
vi.unstubAllGlobals();
});
/**
* v1.18.10 P0 — fetch mock that returns the per-segment series on the first
* call (`action=get`) and the per-night summary series on the second
* (`action=getsummary`). Lets the sync exercise both the stage path and the
* nightly-vital path in one round-trip.
*/
function installSegmentThenSummaryFetch(
segments: FakeSegment[],
summaries: Array<{
id?: number;
startdate: number;
enddate: number;
data?: Record<string, number | null>;
}>,
) {
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;
}
describe("mapWithingsSleepSummary", () => {
it("maps the canonical nightly vitals to existing enums", () => {
const rows = mapWithingsSleepSummary({
hr_average: 58,
rr_average: 14,
sdnn_1: 65,
spo2_average: 96,
sleep_score: 82,
});
const byType = Object.fromEntries(rows.map((r) => [r.type, r.value]));
expect(byType).toEqual({
RESTING_HEART_RATE: 58,
RESPIRATORY_RATE: 14,
HEART_RATE_VARIABILITY: 65,
OXYGEN_SATURATION: 96,
SLEEP_SCORE: 82,
});
});
it("normalises a fractional SpO2 (0..1) to percent", () => {
const rows = mapWithingsSleepSummary({ spo2_average: 0.97 });
const spo2 = rows.find((r) => r.type === "OXYGEN_SATURATION");
expect(spo2?.value).toBeCloseTo(97, 5);
});
it("skips omitted / non-finite fields", () => {
const rows = mapWithingsSleepSummary({
hr_average: 60,
rr_average: null,
sdnn_1: undefined as unknown as number,
sleep_score: Number.NaN,
});
expect(rows.map((r) => r.type)).toEqual(["RESTING_HEART_RATE"]);
});
it("returns nothing for an absent data block", () => {
expect(mapWithingsSleepSummary(undefined)).toEqual([]);
});
});
describe("mapWithingsSleepState", () => {
it("maps state 0 (awake) → AWAKE", () => {
expect(mapWithingsSleepState(0)).toBe("AWAKE");
@@ -271,6 +338,98 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
it("upserts nightly vitals with stable per-vital externalIds (v1.18.10 P0)", async () => {
const nightEnd = Math.floor(Date.UTC(2026, 4, 13, 6) / 1000);
installSegmentThenSummaryFetch(
[],
[
{
id: 555,
startdate: nightEnd - 8 * 3600,
enddate: nightEnd,
data: {
hr_average: 57,
rr_average: 13,
sdnn_1: 70,
spo2_average: 95,
sleep_score: 88,
},
},
],
);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
// Five vitals upserted (no stage segments in this fixture).
expect(imported).toBe(5);
const calls = vi.mocked(prisma.measurement.upsert).mock.calls;
const byType = new Map(
calls.map((c) => {
const w = c[0] as {
where: {
userId_type_source_externalId: { type: string; externalId: string };
};
create: { value: number };
};
return [
w.where.userId_type_source_externalId.type,
{
externalId: w.where.userId_type_source_externalId.externalId,
value: w.create.value,
},
];
}),
);
expect(byType.get("RESTING_HEART_RATE")).toEqual({
externalId: "withings:sleep:user-1:555:hr",
value: 57,
});
expect(byType.get("SLEEP_SCORE")).toEqual({
externalId: "withings:sleep:user-1:555:score",
value: 88,
});
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,
},
],
},
},
};
}),
);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const imported = await syncUserSleep("user-1");
// The one stage segment is still written; the summary failure is swallowed.
expect(imported).toBe(1);
expect(prisma.measurement.create).toHaveBeenCalledTimes(1);
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
// ScanWatch reports CORE + DEEP only; a missing REM should NOT
// synthesise an all-zeros REM row. Withings simply omits the
+201
View File
@@ -61,6 +61,87 @@ import {
const WITHINGS_SLEEP_URL = "https://wbsapi.withings.net/v2/sleep";
/**
* v1.18.10 P0 — nightly sleep vitals from `action=getsummary`.
*
* The per-segment `action=get` series carries only the stage timeline; the
* per-night summary carries the physiological vitals (average HR, respiratory
* rate, SDNN-based HRV, average SpO2, the Withings sleep score). These were
* previously dropped at the fetch even though the canonical enums exist. Each
* vital maps to an EXISTING `MeasurementType` so there is no migration and no
* iOS-contract change — the rows simply flow to `/api/sync/changes` under the
* `WITHINGS` source like every other server-side fetch.
*
* Field set requested via `data_fields`. Withings nests the requested fields
* under each night's `data` object. Only the fields with a canonical enum are
* requested; snoring / breathing-disturbance intensity have no enum and are
* deliberately left out (no home to store them).
*/
const SLEEP_SUMMARY_DATA_FIELDS =
"hr_average,rr_average,sdnn_1,spo2_average,sleep_score";
/**
* One nightly summary row from `POST /v2/sleep?action=getsummary`. The vitals
* live under `data`; `id` is the per-night session id (stable across re-syncs,
* the same id the segment series carries) so the externalId reconciles.
*/
export interface WithingsSleepSummary {
id?: number;
startdate: number;
enddate: number;
data?: {
hr_average?: number | null;
rr_average?: number | null;
sdnn_1?: number | null;
spo2_average?: number | null;
sleep_score?: number | null;
};
}
/**
* One nightly vital extracted from a summary, ready to upsert. The
* `measuredAt` is the night's END (`enddate`) so the vital sorts under the
* same night as the END-stamped stage segments.
*/
interface SleepVitalRow {
type: MeasurementType;
value: number;
/** Stable per-vital externalId suffix; combined with the session id. */
fieldTag: string;
}
/**
* Map a nightly summary's `data` block to the canonical vital rows. Skips any
* field Withings omitted or returned as a non-finite value. `spo2_average`
* arrives as a fraction (0..1) on some firmware and a percent (0..100) on
* others — normalise the fraction form to percent so it shares the
* `OXYGEN_SATURATION` bucket with the meastype-54 spot reading.
*/
export function mapWithingsSleepSummary(
data: WithingsSleepSummary["data"] | undefined,
): SleepVitalRow[] {
if (!data) return [];
const rows: SleepVitalRow[] = [];
const push = (type: MeasurementType, value: unknown, fieldTag: string) => {
if (typeof value !== "number" || !Number.isFinite(value)) return;
rows.push({ type, value, fieldTag });
};
// Average HR over the sleep window → RESTING_HEART_RATE (one nightly sample,
// distinct from spot PULSE — same convention the HealthKit nightly RHR uses).
push("RESTING_HEART_RATE", data.hr_average, "hr");
push("RESPIRATORY_RATE", data.rr_average, "rr");
// sdnn_1 is the SDNN-based HRV estimator — the same estimator the
// HEART_RATE_VARIABILITY enum documents (kept distinct from WHOOP's RMSSD).
push("HEART_RATE_VARIABILITY", data.sdnn_1, "hrv");
let spo2 = data.spo2_average;
if (typeof spo2 === "number" && Number.isFinite(spo2) && spo2 <= 1) {
spo2 = spo2 * 100;
}
push("OXYGEN_SATURATION", spo2, "spo2");
push("SLEEP_SCORE", data.sleep_score, "score");
return rows;
}
/**
* Default backfill window for the first sleep sync of a connection.
* Matches every other Withings ingest path so a reconnect lights up
@@ -83,6 +164,16 @@ export interface WithingsSleepSegment {
id?: number;
}
/**
* Format a Date as `YYYY-MM-DD` in UTC for the summary endpoint's
* `startdateymd` / `enddateymd` params. The summary window is day-grained;
* UTC keeps it deterministic and matches the unix-second window the segment
* fetch derives from the same `start` / `now` Dates.
*/
function ymdUtc(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Map Withings `state` to the HealthLog `SleepStage` enum. Returns
* `null` for states we deliberately ignore (state 4 = synthetic
@@ -153,6 +244,56 @@ export async function fetchWithingsSleep(
return series;
}
/**
* v1.18.10 P0 — fetch the per-night sleep summaries (with vitals) for the
* given window. `action=getsummary` keys by `lastupdate`/`startdateymd`; we
* use the same unix-second window the segment fetch uses, expressed as the
* `YYYY-MM-DD` form the summary endpoint expects. Returns the raw summaries
* (no DB write) so the caller composes the upsert step.
*/
export async function fetchWithingsSleepSummary(
accessToken: string,
startymd: string,
endymd: string,
): Promise<WithingsSleepSummary[]> {
const params = new URLSearchParams({
action: "getsummary",
startdateymd: startymd,
enddateymd: endymd,
data_fields: SLEEP_SUMMARY_DATA_FIELDS,
});
const pageStart = performance.now();
const res = await safeFetch(WITHINGS_SLEEP_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: "fetchWithingsSleepSummary",
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "sleep",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
const series: WithingsSleepSummary[] = json.body?.series ?? [];
return series;
}
/**
* Sync sleep data for a single user. Walks the trailing 30-day window
* (or whatever `SLEEP_BACKFILL_DAYS` is set to), writing one
@@ -319,6 +460,66 @@ export async function syncUserSleep(
segmentIndex++;
}
// v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
// / avg SpO2 / sleep score) from the per-night summary. A summary-fetch
// failure must not lose the stage segments already written, so it runs in
// its own try/catch and only annotates on error. Each vital upserts on the
// stable `(userId, type, source, externalId)` composite so a re-sync
// overwrites the night's value rather than inserting a duplicate.
try {
const startYmd = ymdUtc(start);
const endYmd = ymdUtc(now);
const summaries = await fetchWithingsSleepSummary(
tokenInfo.accessToken,
startYmd,
endYmd,
);
for (const summary of summaries) {
const measuredAt = new Date(summary.enddate * 1000);
const sessionId = summary.id ?? "no-id";
const vitals = mapWithingsSleepSummary(summary.data);
for (const vital of vitals) {
const externalId = `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`;
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: vital.type,
source: "WITHINGS",
externalId,
},
},
create: {
userId,
type: vital.type,
value: vital.value,
unit: getUnitForType(vital.type),
measuredAt,
source: "WITHINGS",
externalId,
},
update: {
value: vital.value,
measuredAt,
syncVersion: { increment: 1 },
},
});
touched.push({ type: vital.type, measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(
`withings sleep: failed to upsert vital (${vital.type}, ${measuredAt.toISOString()}): ${err}`,
);
}
}
}
} catch (err) {
getEvent()?.addWarning(
`withings sleep: summary fetch failed for ${userId}: ${err}`,
);
}
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. Sleep segments collapse heavily —
// ~10 stage rows per night land in one DAY recompute.