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);