feat(apple-health): ingest RMSSD heart-rate variability from iOS 27

iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
(Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`,
available from 27.0). The live batch and the export.zip import now store it
as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in
milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The
accepted range is 1 to 300 ms.

RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a
day holding it from Apple Health and a strap resolves to one source
instead of a name tiebreak.

SDNN and RMSSD are different statistics and are kept apart once one
account holds both. The HRV page charts each on its own, titled with its
measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section
carries one timeline per measure under `byMeasure` (same section key, so
every reader still finds it) rather than one daily series averaging the
two; the budget pass condenses and sheds each measure's timeline like a
glucose context.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 09:37:04 +02:00
1 parent 2731c46399
commit 370270bbc1
14 files changed
+618 -58

No files matched your search

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* #1110 — since iOS 27 one Apple Watch writes both HRV measures, SDNN
* (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). The page used to chart
* RMSSD only when there was no SDNN at all, so a watch writing both showed
* SDNN and hid RMSSD. With both present each now gets its own chart, titled
* with its measure; they are never drawn as one line.
*
* The chart is `next/dynamic`; the stub echoes the props that matter.
*/
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = (props: { types?: string[]; title?: string }) => (
<div
data-slot="chart-stub"
data-types={(props.types ?? []).join(",")}
data-title={props.title ?? ""}
/>
);
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("next/navigation", () => ({ usePathname: () => "/insights/hrv" }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import InsightsHrvPage from "@/app/insights/hrv/page";
function render() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<InsightsHrvPage />
</I18nProvider>
</QueryClientProvider>,
);
}
function charts(html: string): Array<{ types: string; title: string }> {
return [...html.matchAll(/data-types="([^"]*)" data-title="([^"]*)"/g)].map(
(m) => ({ types: m[1]!, title: m[2]! }),
);
}
beforeEach(() => analyticsMock.mockReset());
describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => {
it("charts each measure on its own when both have readings", () => {
analyticsMock.mockReturnValue({
data: {
summaries: {
HEART_RATE_VARIABILITY: { count: 30 },
HRV_RMSSD: { count: 28 },
},
},
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(2);
expect(found[0]).toMatchObject({ types: "HEART_RATE_VARIABILITY" });
expect(found[0]!.title).toMatch(/SDNN$/);
expect(found[1]).toMatchObject({ types: "HRV_RMSSD" });
expect(found[1]!.title).toMatch(/RMSSD$/);
});
it("keeps one unlabelled SDNN chart when there is no RMSSD", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HEART_RATE_VARIABILITY: { count: 30 } } },
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(1);
expect(found[0]!.types).toBe("HEART_RATE_VARIABILITY");
expect(found[0]!.title).not.toMatch(/SDNN|RMSSD/);
});
it("charts RMSSD alone, labelled, for a ring or strap user", () => {
analyticsMock.mockReturnValue({
data: { summaries: { HRV_RMSSD: { count: 28 } } },
isEmpty: false,
});
const found = charts(render());
expect(found).toHaveLength(1);
expect(found[0]!.types).toBe("HRV_RMSSD");
expect(found[0]!.title).toMatch(/RMSSD$/);
});
});
@@ -73,6 +73,16 @@ export interface HealthKitMetricPageProps {
* `fallbackMeasurementType` is set.
*/
fallbackMeasureLabel?: string;
/**
* v1.42 (#1110) — short label naming the PRIMARY measure (e.g. "SDNN").
* Since iOS 27 one account can hold both HRV measures from the same
* watch. They are different statistics on different scales, so when
* both have rows the page charts each on its own, each titled with its
* measure, instead of hiding the second behind the first.
*/
primaryMeasureLabel?: string;
/** Line colour of the fallback measure's own chart; defaults to `color`. */
fallbackColor?: `var(--${string})`;
/** The InsightMetric key used by `useInsightsAnalytics()`. */
insightMetric: InsightMetric;
/** The chart-overlay slot id. */
@@ -215,6 +225,8 @@ export function HealthKitMetricPage({
measurementType,
fallbackMeasurementType,
fallbackMeasureLabel,
primaryMeasureLabel,
fallbackColor,
insightMetric,
chartKey,
i18nPrefix,
@@ -269,6 +281,11 @@ export function HealthKitMetricPage({
const effectiveType = usingFallback
? (fallbackMeasurementType as string)
: measurementType;
// v1.42 (#1110) — both measures present: the primary keeps the strip, the
// coach read and the value list, and the fallback measure gets a chart of
// its own beneath it. Never one line, never one hidden.
const bothMeasures =
!!fallbackMeasurementType && primaryCount > 0 && fallbackCount > 0;
// v1.32.26 — resolve the display unit + scale from the user's preference for
// a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved,
@@ -421,7 +438,11 @@ export function HealthKitMetricPage({
summary={summary}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
seriesLabel={title}
seriesLabel={
bothMeasures && primaryMeasureLabel
? `${title} · ${primaryMeasureLabel}`
: title
}
icon={statIcon}
windowStats={statsByType?.[effectiveType] ?? null}
medianLabel={statMedianLabel}
@@ -455,7 +476,9 @@ export function HealthKitMetricPage({
title={
usingFallback && fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
: bothMeasures && primaryMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${primaryMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[color]}
@@ -469,6 +492,27 @@ export function HealthKitMetricPage({
onVisibleStats={onVisibleStats}
showDataTable
/>
{bothMeasures ? (
<HealthChartDynamic
chartKey={chartKey}
types={[fallbackMeasurementType as string]}
title={
fallbackMeasureLabel
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
: t(`${i18nPrefix}.chartTitle`)
}
titleIcon={statIcon}
colors={[fallbackColor ?? color]}
unit={resolvedUnit}
yAxisUnit={resolvedYAxisUnit}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={resolvedScale}
valueOffset={resolvedOffset}
onVisibleStats={onVisibleStats}
showDataTable
/>
) : null}
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}