mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user