From 8ebc6fe5464d0a80bccee30108fb436cf2a0a598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 14:30:55 +0200 Subject: [PATCH] fix(integrations): give Oura and WHOOP a 48 h silence window Their rings and straps upload only when the phone app is opened, so a day without opening it is ordinary and must not page the operator. Wearables whose cloud receives data on its own keep 24 h, Apple Health keeps 48 h, and scales, cuffs and Strava never alert on silence. --- .../__tests__/watchdog-classify.test.ts | 63 ++++++++++++++++--- src/lib/integrations/watchdog-classify.ts | 27 +++++--- .../integration/integration-watchdog.test.ts | 9 +-- 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/lib/integrations/__tests__/watchdog-classify.test.ts b/src/lib/integrations/__tests__/watchdog-classify.test.ts index 0e2992e46..9e9be7b9a 100644 --- a/src/lib/integrations/__tests__/watchdog-classify.test.ts +++ b/src/lib/integrations/__tests__/watchdog-classify.test.ts @@ -47,7 +47,8 @@ const openRow = (overrides: Partial = {}): WatchRow => ({ }); describe("classifySilence", () => { - const oura = SOURCE_PROFILES.oura; + // A wearable whose cloud receives data on its own: the 24 h window. + const wearable = SOURCE_PROFILES.fitbit; const withings = SOURCE_PROFILES.withings; it("never flags an on-demand source for silence, however long and however regular", () => { @@ -67,7 +68,7 @@ describe("classifySilence", () => { it("is clear while a continuous source delivers inside its window", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(20), deliveredDays: null, currentSilentSince: null, @@ -79,7 +80,7 @@ describe("classifySilence", () => { it("asks for the history before flagging (the cheap hourly pass reads it lazily)", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(30), deliveredDays: null, currentSilentSince: null, @@ -91,7 +92,7 @@ describe("classifySilence", () => { it("flags a silent source that delivered on most of the previous 14 days", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(30), deliveredDays: NORMAL_DELIVERY_MIN_DAYS, currentSilentSince: null, @@ -103,7 +104,7 @@ describe("classifySilence", () => { it("does not flag a source its owner wears only now and then", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(30), deliveredDays: NORMAL_DELIVERY_MIN_DAYS - 1, currentSilentSince: null, @@ -115,7 +116,7 @@ describe("classifySilence", () => { it("keeps a standing flag on the hourly pass without re-reading history", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(50), deliveredDays: null, currentSilentSince: ago(50), @@ -127,7 +128,7 @@ describe("classifySilence", () => { it("drops a standing flag on the daily re-check once the history no longer says it delivers", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(24 * 6), deliveredDays: 8, currentSilentSince: ago(24 * 6), @@ -136,7 +137,55 @@ describe("classifySilence", () => { ).toEqual({ kind: "not_expected" }); }); + it("waits 48 h for Oura and WHOOP, which upload only when their phone app is opened", () => { + for (const key of ["oura", "whoop"] as const) { + const profile = SOURCE_PROFILES[key]; + expect(profile.kind).toBe("continuous"); + // A day and a half without opening the app is ordinary. + expect( + classifySilence({ + profile, + lastDataAt: ago(36), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + // Past two days it is not. + expect( + classifySilence({ + profile, + lastDataAt: ago(49), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(49) }); + } + }); + + it("keeps the 24 h window for wearables whose cloud receives data on its own", () => { + for (const key of [ + "fitbit", + "google-health", + "polar", + "nightscout", + ] as const) { + expect(SOURCE_PROFILES[key]).toEqual({ + kind: "continuous", + silenceWindowMs: 24 * HOUR, + }); + } + }); + + it("never alerts on silence for scales, cuffs and workout logs", () => { + for (const key of ["withings", "strava"] as const) { + expect(SOURCE_PROFILES[key].kind).toBe("on_demand"); + } + }); + it("gives Apple Health a wider window than a wearable", () => { + expect(SOURCE_PROFILES["apple-health"].silenceWindowMs).toBe(48 * HOUR); const apple = SOURCE_PROFILES["apple-health"]; expect( classifySilence({ diff --git a/src/lib/integrations/watchdog-classify.ts b/src/lib/integrations/watchdog-classify.ts index cc1951e36..0e0ab77f8 100644 --- a/src/lib/integrations/watchdog-classify.ts +++ b/src/lib/integrations/watchdog-classify.ts @@ -50,20 +50,29 @@ export const INCIDENT_AFTER_MS = DAY_MS; /** * Per connection. Withings is mostly scales and cuffs, Strava is workouts: - * both are on-demand. Everything worn or attached around the clock is - * continuous. + * both are on-demand and never alert on silence, only on errors. Everything + * worn or attached around the clock is continuous. * - * Apple Health gets 48 h instead of 24 h. iOS defers background delivery when - * the phone is low on storage, in low-power mode or simply not unlocked, and a - * day of that is ordinary; the history gate already limits the check to - * phones that deliver almost daily, and the longer window keeps an ordinary - * quiet day from paging anyone. + * The window is how long a continuous source may stay quiet before silence + * counts, and it follows how each source actually delivers: + * + * - 24 h for wearables and CGMs whose cloud receives data on its own + * (Fitbit, Google Health, Polar, Nightscout). + * - 48 h for Oura and WHOOP. Their rings and straps upload only when the + * phone app is opened, so a day without opening it is ordinary and must + * not page anyone. + * - 48 h for Apple Health. iOS defers background delivery when the phone is + * low on storage, in low-power mode or simply not unlocked, and a day of + * that is ordinary too. + * + * The history gate already limits the check to sources that deliver almost + * daily; the window only decides how long a gap is ordinary. */ export const SOURCE_PROFILES: Record = { withings: { kind: "on_demand", silenceWindowMs: DAY_MS }, strava: { kind: "on_demand", silenceWindowMs: DAY_MS }, - whoop: { kind: "continuous", silenceWindowMs: DAY_MS }, - oura: { kind: "continuous", silenceWindowMs: DAY_MS }, + whoop: { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, + oura: { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, fitbit: { kind: "continuous", silenceWindowMs: DAY_MS }, "google-health": { kind: "continuous", silenceWindowMs: DAY_MS }, polar: { kind: "continuous", silenceWindowMs: DAY_MS }, diff --git a/tests/integration/integration-watchdog.test.ts b/tests/integration/integration-watchdog.test.ts index 81c5ac97d..44de37b40 100644 --- a/tests/integration/integration-watchdog.test.ts +++ b/tests/integration/integration-watchdog.test.ts @@ -96,12 +96,13 @@ beforeEach(async () => { describe("integration watchdog", () => { it("flags a normally-daily wearable gone silent, alerts once, then sends one recovery note", async () => { - // Fourteen consecutive days of data before the window, newest 30 h ago. + // Thirteen consecutive days of data before the window, newest 54 h ago: + // past Oura's 48 h window (it uploads only when its phone app is opened). await seedDailyReadings( USER, "OURA", - Array.from({ length: 13 }, (_, i) => i + 2), - 30, + Array.from({ length: 13 }, (_, i) => i + 3), + 54, ); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); @@ -170,7 +171,7 @@ describe("integration watchdog", () => { }); it("leaves a wearable alone when its owner wears it only now and then", async () => { - await seedDailyReadings(USER, "OURA", [3, 7, 11], 30); + await seedDailyReadings(USER, "OURA", [3, 7, 11], 54); await recordSyncSuccess(USER, "oura"); const summary = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); expect(summary.silencesFlagged).toBe(0);