diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index 70c985e69..91bad4170 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -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; + }>, +) { + 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 diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 27b643baf..31940c073 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -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 { + 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.