diff --git a/messages/de.json b/messages/de.json index 9a2c80392..90d17c3c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Ruheherzfrequenz", "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Sauerstoffsättigung", "BODY_TEMPERATURE": "Körpertemperatur", diff --git a/messages/en.json b/messages/en.json index 8d6ab2cd4..eba85e21c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Resting heart rate", "HEART_RATE_VARIABILITY": "Heart-rate variability", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Oxygen saturation", "BODY_TEMPERATURE": "Body temperature", diff --git a/messages/es.json b/messages/es.json index f86b42a52..f89ebd220 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo", "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca", + "HRV_RMSSD": "VFC (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturación de oxígeno", "BODY_TEMPERATURE": "Temperatura corporal", diff --git a/messages/fr.json b/messages/fr.json index 3b3a92f2f..eada370a5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Fréquence cardiaque au repos", "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque", + "HRV_RMSSD": "VFC (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturation en oxygène", "BODY_TEMPERATURE": "Température corporelle", diff --git a/messages/it.json b/messages/it.json index 85e7d2cf8..42929075e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Frequenza cardiaca a riposo", "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturazione di ossigeno", "BODY_TEMPERATURE": "Temperatura corporea", diff --git a/messages/ko.json b/messages/ko.json index cc848901c..ffd1b4c6f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "안정 시 심박수", "HEART_RATE_VARIABILITY": "심박변이도", + "HRV_RMSSD": "심박변이도 (RMSSD)", "RECOVERY_SCORE": "회복", "OXYGEN_SATURATION": "산소포화도", "BODY_TEMPERATURE": "체온", diff --git a/messages/nl.json b/messages/nl.json index 745094620..d42032ded 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Rusthartslag", "HEART_RATE_VARIABILITY": "Hartslagvariabiliteit", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Herstel", "OXYGEN_SATURATION": "Zuurstofverzadiging", "BODY_TEMPERATURE": "Lichaamstemperatuur", diff --git a/messages/pl.json b/messages/pl.json index d87da90db..6985f25f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10392,6 +10392,7 @@ "vital": { "RESTING_HEART_RATE": "Spoczynkowe tętno", "HEART_RATE_VARIABILITY": "Zmienność rytmu serca", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturacja tlenem", "BODY_TEMPERATURE": "Temperatura ciała", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 451ab270f..b091256da 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set([ "insights.derived.composite.READINESS.component.hrv", "labs.catalog.hs-crp", "measurements.typeHrvRmssd", + "illness.vital.HRV_RMSSD", "settings.sections.sources.metrics.hrvRmssd", "measurements.typeVo2Max", // "{hours} h {minutes} min": the SI symbols for hour and minute. diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 4c7e5cbd9..754026c7a 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -17,6 +17,7 @@ import { computeIllnessCorrelation, dayDiff, FUNCTIONAL_IMPACT_RETURN_KEY, + ILLNESS_SCAN_TYPES, isAdverseDeviation, MIN_BASELINE_DAYS, MIN_EPISODE_COVERAGE_DAYS, @@ -803,6 +804,12 @@ describe("isAdverseDeviation", () => { expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false); }); + it("reads a drop in the nightly RMSSD series as adverse, like SDNN", () => { + expect(ILLNESS_SCAN_TYPES).toContain("HRV_RMSSD"); + expect(isAdverseDeviation("HRV_RMSSD", "below")).toBe(true); + expect(isAdverseDeviation("HRV_RMSSD", "above")).toBe(false); + }); + it("treats neutral-direction vitals (WEIGHT) as never adverse", () => { expect(isAdverseDeviation("WEIGHT", "above")).toBe(false); expect(isAdverseDeviation("WEIGHT", "below")).toBe(false); diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 95f47e60b..2633ff279 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -118,6 +118,7 @@ export const ADVERSE_DIRECTION: Partial< BLOOD_PRESSURE_SYS: "up", BLOOD_PRESSURE_DIA: "up", HEART_RATE_VARIABILITY: "down", + HRV_RMSSD: "down", RECOVERY_SCORE: "down", OXYGEN_SATURATION: "down", }; @@ -130,6 +131,9 @@ export const ADVERSE_DIRECTION: Partial< export const ILLNESS_SCAN_TYPES: MeasurementType[] = [ "RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", + // RMSSD, scanned on its own baseline: a nightly strap or ring reading drops + // during illness the same way SDNN does. + "HRV_RMSSD", "RECOVERY_SCORE", "OXYGEN_SATURATION", "BODY_TEMPERATURE", diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 92d0d96b4..8f53be251 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -127,6 +127,21 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { expect(keys).toContain("CUSTOM_METRIC:abc"); }); + it("scans the nightly RMSSD series as an outcome owned by recovery", async () => { + const on = await assembleDiscoveryMatrix("u1", OPTS); + expect( + on.series.find((s) => s.key === "HRV_RMSSD" && s.role === "outcome"), + ).toBeDefined(); + const off = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { recovery: false }, + }); + const keys = keysOf(off.series); + expect(keys).not.toContain("HRV_RMSSD"); + // SDNN is a plain vital and stays. + expect(keys).toContain("HEART_RATE_VARIABILITY"); + }); + it("does not read the weather at all with the environment module off", async () => { const read = vi.mocked(fetchEnvironmentSeries); read.mockClear(); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index db16e66c0..c08f20f70 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -662,6 +662,12 @@ export const DISCOVERY_BEHAVIOURS = [ export const DISCOVERY_OUTCOMES = [ "SLEEP_DURATION", "HEART_RATE_VARIABILITY", + // The nightly RMSSD series (WHOOP, Oura, Polar, Fitbit, Google Health) as + // its own outcome. It is not merged into the SDNN channel: the estimators + // differ in scale, so one series of both would correlate the switch between + // devices rather than the body. Neither HRV channel is a behaviour, so the + // two never pair with each other. + "HRV_RMSSD", "RESTING_HEART_RATE", "WEIGHT", // v1.22 — blood pressure as an OUTCOME (it was behaviour-only). This makes diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 21f670926..a11e848a2 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -212,6 +212,8 @@ const STATIC_SURFACE_MODULE = { "correlation:SLEEP_DURATION": "sleep", "correlation:BLOOD_GLUCOSE": "glucose", "correlation:RESTING_HEART_RATE": "recovery", + // The RMSSD series is the recovery module's (see `summary:HRV_RMSSD`). + "correlation:HRV_RMSSD": "recovery", "correlation:MEDICATION_COMPLIANCE": "medications", // Symptom burden is read from the illness journal. "correlation:SYMPTOM_SEVERITY": "illness",