mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
commit
67c04d06fd
7 files changed
+166
-13
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user