fix(insights): surface ring and strap HRV and rank the new sources

Light up the HRV tab when a device writes RMSSD instead of SDNN, so an Oura,
Polar or WHOOP user finally sees the nightly HRV the app already stored — the
chart swaps to the RMSSD series with a measure label rather than silently
merging the two measures. Add Oura and Polar to the steps, active-energy and
distance source ladders below the phone aggregate, and lift the optical-HR
wearables above a hand-typed pulse so the canonical reading is the device's.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:23:20 +02:00
commit 67c04d06fd
7 files changed
+166 -13

No files matched your search

+7 -2
View File
@@ -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 (
<HealthKitMetricPage
measurementType="HEART_RATE_VARIABILITY"
fallbackMeasurementType="HRV_RMSSD"
fallbackMeasureLabel="RMSSD"
statusMetric="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
@@ -54,6 +54,24 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
export interface HealthKitMetricPageProps {
/** The MeasurementType that backs the chart. */
measurementType: string;
/**
* v1.17.0 — optional secondary MeasurementType the page falls back to
* when the primary type has no rows but the fallback does. Used by HRV:
* the primary `HEART_RATE_VARIABILITY` (SDNN, Apple / Fitbit) is empty
* for a ring / strap user whose nightly HRV is stored as `HRV_RMSSD`
* (Oura / Polar / WHOOP). When the swap is active the chart, stat strip,
* "all values" list, and diversity nudge key off the fallback type and
* `fallbackMeasureLabel` names the measure so the two are never silently
* merged. Omit it and every other page renders byte-identically.
*/
fallbackMeasurementType?: string;
/**
* v1.17.0 — short label naming the fallback measure (e.g. "RMSSD") shown
* beneath the chart title when the fallback series is active, so the user
* sees which measure their reading is. Required in practice whenever
* `fallbackMeasurementType` is set.
*/
fallbackMeasureLabel?: string;
/** The InsightMetric key used by `useInsightsAnalytics()`. */
insightMetric: InsightMetric;
/** The chart-overlay slot id. */
@@ -167,6 +185,8 @@ export interface HealthKitMetricPageProps {
export function HealthKitMetricPage({
measurementType,
fallbackMeasurementType,
fallbackMeasureLabel,
insightMetric,
chartKey,
i18nPrefix,
@@ -204,7 +224,22 @@ export function HealthKitMetricPage({
// stat strip reads it back for this page's single series.
const { statsByType, onVisibleStats } = useChartDomainStats();
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// v1.17.0 — fallback-type swap. When the primary type has no rows but the
// declared fallback does (HRV: SDNN empty, RMSSD present), key the chart,
// stat strip, and "all values" list off the fallback so a ring / strap
// user's stored HRV renders instead of an empty state. The swap only
// triggers once analytics has loaded and the primary is genuinely empty.
const primaryCount = analytics?.summaries?.[measurementType]?.count ?? 0;
const fallbackCount = fallbackMeasurementType
? (analytics?.summaries?.[fallbackMeasurementType]?.count ?? 0)
: 0;
const usingFallback =
primaryCount === 0 && fallbackCount > 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={
<MeasurementDiversityNudge
measurementType={measurementType}
measurementType={effectiveType}
metricLabel={title}
timeZone={user?.timezone ?? undefined}
/>
}
coachLaunch
showAllValuesType={measurementType}
showAllValuesType={effectiveType}
>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
title={t(`${i18nPrefix}.chartTitle`)}
types={[effectiveType]}
title={
usingFallback && fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[color]}
unit={unit}
@@ -191,4 +191,37 @@ describe("hasMetricData", () => {
}),
).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);
});
});
+14
View File
@@ -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;
}
+5 -1
View File
@@ -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"],
@@ -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", () => {
+19 -4
View File
@@ -217,9 +217,20 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// 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<MetricPriority> = {
// 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,