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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 14:30:55 +02:00
1 parent a4942e631f
commit 8ebc6fe546
3 files changed
+79 -20

No files matched your search

@@ -47,7 +47,8 @@ const openRow = (overrides: Partial<WatchRow> = {}): 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({
+18 -9
View File
@@ -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<ConnectionKey, SourceProfile> = {
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 },
@@ -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);