diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index 724600438..74f1c5c2d 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -10,13 +10,18 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * Heart-rate variability sub-page. Pairs with the iOS HKQuantityType * `heartRateVariabilitySDNN` ingest path; the user typically logs one * sample per night during sleep, so the chart is sparse but the - * weekly trend is the load-bearing signal. Stored as millisecond - * `HEART_RATE_VARIABILITY` rows by every existing ingest path. + * weekly trend is the load-bearing signal. Apple Health / Fitbit store + * SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP + * store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present + * and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a + * ring / strap user's stored HRV always surfaces. */ export default function InsightsHrvPage() { return ( 0 && !!fallbackMeasurementType; + const effectiveType = usingFallback + ? (fallbackMeasurementType as string) + : measurementType; + + const rawSummary = analytics?.summaries?.[effectiveType] ?? null; // The stat strip renders display-unit values. The summary holds stored // values, so when the page renders a scaled unit (e.g. WALKING_SPEED // stores m/s but displays km/h via `valueScale`), fold the same scale @@ -316,24 +351,28 @@ export function HealthKitMetricPage({ fractionDigits={statFractionDigits} seriesLabel={title} icon={statIcon} - windowStats={statsByType?.[measurementType] ?? null} + windowStats={statsByType?.[effectiveType] ?? null} medianLabel={statMedianLabel} /> } diversityNudge={ } coachLaunch - showAllValuesType={measurementType} + showAllValuesType={effectiveType} > { }), ).toBe(false); }); + + // v1.17.0 — HRV surfaces SDNN OR RMSSD. Oura / Polar / WHOOP store nightly + // HRV as `HRV_RMSSD`; the HRV tab must light up for a ring / strap user + // whose only HRV stream is RMSSD. + it("returns true for HEART_RATE_VARIABILITY when only HRV_RMSSD has data", () => { + expect( + hasMetricData("HEART_RATE_VARIABILITY", { + ...emptyInputs, + summaries: { HRV_RMSSD: fakeSummary(7) }, + }), + ).toBe(true); + }); + + it("returns true for HEART_RATE_VARIABILITY when only SDNN has data", () => { + expect( + hasMetricData("HEART_RATE_VARIABILITY", { + ...emptyInputs, + summaries: { HEART_RATE_VARIABILITY: fakeSummary(7) }, + }), + ).toBe(true); + }); + + it("returns false for HEART_RATE_VARIABILITY when neither HRV stream has data", () => { + expect( + hasMetricData("HEART_RATE_VARIABILITY", { + ...emptyInputs, + summaries: { + HEART_RATE_VARIABILITY: fakeSummary(0), + HRV_RMSSD: fakeSummary(0), + }, + }), + ).toBe(false); + }); }); diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 1899a9107..663bc7489 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -62,6 +62,11 @@ export type InsightMetric = | "ACTIVE_ENERGY" // v1.4.32 — wave-A HealthKit metrics promoted to first-class sub-pages. | "HEART_RATE_VARIABILITY" + // v1.17.0 — RMSSD-flavoured HRV. Oura / Polar / WHOOP write nightly HRV + // as `HRV_RMSSD`; Apple Health / Fitbit write SDNN as + // `HEART_RATE_VARIABILITY`. The `hrv` sub-page gates on either so a + // ring / strap user's stored HRV lights the tab, pill, and dashboard. + | "HRV_RMSSD" | "RESTING_HEART_RATE" | "OXYGEN_SATURATION" | "BODY_TEMPERATURE" @@ -149,5 +154,14 @@ export function hasMetricData( // mounts even at one weight reading; the gate matches. return (inputs.summaries?.WEIGHT?.count ?? 0) > 0; } + if (metric === "HEART_RATE_VARIABILITY") { + // v1.17.0 — surface SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit) + // OR RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP). Either stream lights + // the HRV tab, pill, and dashboard tile. + return ( + (inputs.summaries?.HEART_RATE_VARIABILITY?.count ?? 0) > 0 || + (inputs.summaries?.HRV_RMSSD?.count ?? 0) > 0 + ); + } return (inputs.summaries?.[metric]?.count ?? 0) > 0; } diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 103034569..840155c96 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -100,7 +100,11 @@ const SUB_PAGE_METRIC = { "breathing-disturbances": ["BREATHING_DISTURBANCES"], // ── cardiovascular ── "resting-pulse": ["RESTING_HEART_RATE"], - hrv: ["HEART_RATE_VARIABILITY"], + // v1.17.0 — HRV surfaces both flavours: SDNN (`HEART_RATE_VARIABILITY`, + // Apple / Fitbit) and RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP). A + // ring / strap user stores only RMSSD; listing both keeps the tab, + // pill, and dashboard tile lit whichever source the user has. + hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"], // v1.7.0 — Withings cardiovascular risk markers. "pulse-wave-velocity": ["PULSE_WAVE_VELOCITY"], "vascular-age": ["VASCULAR_AGE"], diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index b286cb052..068694913 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -247,6 +247,49 @@ describe("DEFAULT_SOURCE_PRIORITY", () => { expect(ladder.indexOf("WHOOP")).toBeLessThan(ladder.indexOf("OURA")); } }); + + it("appends Oura + Polar to the activity ladders below Apple Health (v1.17)", () => { + // v1.17.0 — Oura + Polar write ACTIVITY_STEPS + ACTIVE_ENERGY_BURNED; + // they belong on the activity ladders as legitimate sources, ranked + // below the phone-aggregated APPLE_HEALTH but above a MANUAL entry, so + // an Oura/Polar-only day is not dropped when another source coexists. + for (const key of [ + "steps", + "activeEnergy", + "walkingRunningDistance", + ] as const) { + const ladder = DEFAULT_SOURCE_PRIORITY[key]; + expect(ladder).toContain("OURA"); + expect(ladder).toContain("POLAR"); + // Below the phone-aggregated all-day Apple Health stream. + expect(ladder.indexOf("APPLE_HEALTH")).toBeLessThan( + ladder.indexOf("OURA"), + ); + expect(ladder.indexOf("APPLE_HEALTH")).toBeLessThan( + ladder.indexOf("POLAR"), + ); + // Above a hand-typed MANUAL entry — a real device beats nothing. + expect(ladder.indexOf("OURA")).toBeLessThan(ladder.indexOf("MANUAL")); + expect(ladder.indexOf("POLAR")).toBeLessThan(ladder.indexOf("MANUAL")); + // No existing source loses its rank: Apple Health still leads. + expect(ladder[0]).toBe("APPLE_HEALTH"); + } + }); + + it("ranks the pulse wearables above a MANUAL reading (v1.17)", () => { + // v1.17.0 — a copy-paste slip had Polar / Oura ranked BELOW a hand-typed + // MANUAL pulse. Continuous optical-HR wearables must sit above MANUAL, + // matching the device-source order on the rhr / hrv ladders. + const ladder = DEFAULT_SOURCE_PRIORITY.pulse; + for (const device of ["FITBIT", "OURA", "POLAR"] as const) { + expect(ladder.indexOf(device)).toBeLessThan(ladder.indexOf("MANUAL")); + } + // Device-source order matches the rhr / hrv ladders (FITBIT > OURA > POLAR). + expect(ladder.indexOf("FITBIT")).toBeLessThan(ladder.indexOf("OURA")); + expect(ladder.indexOf("OURA")).toBeLessThan(ladder.indexOf("POLAR")); + // Withings stays the primary point-measurement source. + expect(ladder[0]).toBe("WITHINGS"); + }); }); describe("DEFAULT_DEVICE_TYPE_PRIORITY", () => { diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 866472632..a6997a775 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -217,9 +217,20 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // v1.12.0 — Fitbit/Pixel rides below Apple Health for cumulative metrics: // HealthKit still aggregates the broadest device set, and a Fitbit-only // self-hoster has no Apple stream to compete with anyway. - steps: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"], - activeEnergy: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"], - walkingRunningDistance: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"], + // v1.17.0 — Oura + Polar both write `ACTIVITY_STEPS` + `ACTIVE_ENERGY_BURNED`; + // they rank below the phone-aggregated Apple Health (which sees all-day + // steps) but as legitimate sources above MANUAL, so an Oura/Polar-only day + // is not dropped when another source coexists. + steps: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "OURA", "POLAR", "MANUAL"], + activeEnergy: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "OURA", "POLAR", "MANUAL"], + walkingRunningDistance: [ + "APPLE_HEALTH", + "WITHINGS", + "FITBIT", + "OURA", + "POLAR", + "MANUAL", + ], flightsClimbed: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"], // v1.11.0 — WHOOP leads the recovery-input ladders (sleep / HRV / RHR): // a worn-all-night strap has higher-resolution overnight sampling than @@ -242,7 +253,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // A real scale beats a strap's body-measurement estimate for weight. weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP", "FITBIT"], bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"], - pulse: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT", "POLAR", "OURA"], + // v1.17.0 — continuous optical-HR wearables (Fitbit / Polar / Oura) rank + // above a hand-typed MANUAL pulse; the device-source order matches the + // rhr / hrv ladders (FITBIT > OURA > POLAR). Withings BPM cuff + the + // HealthKit relay stay the primary point-measurement sources. + pulse: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "OURA", "POLAR", "MANUAL"], bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], // Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second,