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

+11
View File
@@ -120,6 +120,14 @@ The HK quantity types that map to HealthLog metric types live in
mapped set (for example workout routes) is counted as unsupported and
not stored.
Heart-rate variability comes in two measures, and they stay two types.
`HeartRateVariabilitySDNN` becomes `HEART_RATE_VARIABILITY`;
`HeartRateVariabilityRMSSD`, which iOS and watchOS 27 added, becomes
`HRV_RMSSD`, the same type WHOOP, Oura and Polar write. RMSSD readings
up to 300 ms are accepted. When both arrive, the HRV page charts each on
its own and the Coach reads them apart, because one is not a stand-in for
the other.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
@@ -284,6 +292,9 @@ Concrete consequences:
source tag — the lowest rank — is reserved for legacy CSV/JSON
imports that pre-date the Apple Health passthrough.
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Sources (`/settings/sources`).
## Failure modes
+5 -1
View File
@@ -15,7 +15,9 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
* 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.
* ring / strap user's stored HRV always surfaces. Since iOS 27 an Apple
* Watch writes both (#1110); with rows of both the page charts each on its
* own, labelled, because the two are not one measure.
*/
export default function InsightsHrvPage() {
return (
@@ -23,6 +25,8 @@ export default function InsightsHrvPage() {
measurementType="HEART_RATE_VARIABILITY"
fallbackMeasurementType="HRV_RMSSD"
fallbackMeasureLabel="RMSSD"
primaryMeasureLabel="SDNN"
fallbackColor="var(--chart-2)"
statusMetric="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
@@ -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}
@@ -0,0 +1,90 @@
/**
* #1110 — since iOS 27 one Apple Watch writes both HRV measures: SDNN
* (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). They are different
* statistics on different scales. The Coach's HRV block used to fold every
* row of both into one daily series, so a day with both averaged an SDNN
* value with an RMSSD value. With both present the block now carries one
* timeline per measure under `byMeasure`, under the same section key, and
* the budget pass condenses each as its own series.
*/
import { describe, expect, it } from "vitest";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { buildValueSeriesBlocks } from "@/lib/ai/coach/snapshot-blocks/value-series-blocks";
import {
condenseSeriesBlock,
isCondensable,
type DailySeries,
} from "@/lib/ai/coach/series-condense";
import type { CoachProvenance } from "@/lib/ai/coach/types";
const NOW = new Date("2026-10-08T12:00:00.000Z");
const day = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
function build(rows: Array<{ type: string; value: number; measuredAt: Date }>) {
const snapshot: Record<string, unknown> = {};
const registered = new Map<string, () => DailySeries>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
buildValueSeriesBlocks({
sources: new Set(["hrv"]),
measurementRows: rows,
additiveCutoff: () => day(400),
recentCutoff: day(14),
userTz: "UTC",
snapshot,
metrics: new Set(),
counts,
registerBlock: (key, _source, daily) => {
if (daily) registered.set(key, daily);
},
groundingValues: new Map(),
units: DEFAULT_UNIT_PREFERENCES,
});
return { snapshot, registered, counts };
}
const both = [
{ type: "HEART_RATE_VARIABILITY", value: 40, measuredAt: day(1) },
{ type: "HRV_RMSSD", value: 70, measuredAt: day(1) },
{ type: "HEART_RATE_VARIABILITY", value: 44, measuredAt: day(2) },
{ type: "HRV_RMSSD", value: 76, measuredAt: day(2) },
{ type: "HEART_RATE_VARIABILITY", value: 42, measuredAt: day(30) },
];
describe("Coach HRV block — SDNN and RMSSD kept apart (#1110)", () => {
it("carries one timeline per measure when both are present", () => {
const { snapshot, counts } = build(both);
const block = snapshot.heartRateVariability as {
unit?: string;
timeline?: unknown;
byMeasure: Record<string, { recent: Array<{ value: number }> }>;
};
expect(block.timeline).toBeUndefined();
expect(block.unit).toBe("ms");
expect(Object.keys(block.byMeasure)).toEqual(["SDNN", "RMSSD"]);
const sdnn = block.byMeasure.SDNN!.recent.map((r) => r.value);
const rmssd = block.byMeasure.RMSSD!.recent.map((r) => r.value);
// No day mixes the two: SDNN days read 40s, RMSSD days 70s.
expect(sdnn.every((v) => v < 50)).toBe(true);
expect(rmssd.every((v) => v > 60)).toBe(true);
expect(counts.hrv).toBe(5);
});
it("registers each measure as its own series, and condenses each", () => {
const { snapshot, registered } = build(both);
const daily = registered.get("heartRateVariability")!();
expect(Object.keys(daily).sort()).toEqual(["RMSSD", "SDNN"]);
const block = snapshot.heartRateVariability;
expect(isCondensable("heartRateVariability", block)).toBe(true);
condenseSeriesBlock(block, 1, "heartRateVariability", daily);
const summary = (block as { summary?: Record<string, unknown> }).summary;
expect(Object.keys(summary ?? {}).sort()).toEqual(["RMSSD", "SDNN"]);
});
it("keeps the single-timeline shape when only one measure is present", () => {
const { snapshot } = build(both.filter((row) => row.type === "HRV_RMSSD"));
const block = snapshot.heartRateVariability as Record<string, unknown>;
expect(block.byMeasure).toBeUndefined();
expect(block.timeline).toBeDefined();
});
});
+10 -1
View File
@@ -238,8 +238,17 @@ function tracksOf(kind: SeriesBlockKind, block: Rec): Track[] {
]
: [];
switch (kind) {
case "value":
case "value": {
// A metric with several measures (HRV: SDNN and RMSSD, #1110) keeps
// one timeline per measure, each condensed as its own series.
const byMeasure = block.byMeasure;
if (isRecord(byMeasure)) {
return Object.keys(byMeasure).flatMap((measure) =>
single(byMeasure[measure], "value", measure),
);
}
return single(block.timeline, "value", null);
}
case "sleep":
return single(block.timeline, "minutes", null);
case "compliance":
@@ -28,6 +28,15 @@ interface ValueBlock {
source: CoachScopeSource;
snapshotKey: string;
type: string | readonly string[];
/**
* For a block whose types are different statistics of one thing (HRV:
* SDNN and RMSSD). With rows of one measure the block is built as before;
* with rows of several, the block carries one timeline per measure under
* `byMeasure`, named by measure, instead of one daily series averaging the
* two together (#1110: since iOS 27 one watch writes both). Same section
* key, so every reader that asks for the section still finds it.
*/
measures?: readonly { type: string; label: string }[];
}
const VALUE_BLOCKS: readonly ValueBlock[] = [
@@ -36,6 +45,10 @@ const VALUE_BLOCKS: readonly ValueBlock[] = [
source: "hrv",
snapshotKey: "heartRateVariability",
type: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
measures: [
{ type: "HEART_RATE_VARIABILITY", label: "SDNN" },
{ type: "HRV_RMSSD", label: "RMSSD" },
],
},
{
metric: "resting_hr",
@@ -271,14 +284,13 @@ export function buildValueSeriesBlocks(
const wanted = Array.isArray(block.type) ? new Set(block.type) : null;
const blockCutoff = ctx.additiveCutoff(block.source);
const rows = ctx.measurementRows
.filter((row) =>
wanted ? wanted.has(row.type) : row.type === block.type,
)
.map((row) => ({ measuredAt: row.measuredAt, value: row.value }))
.filter((row) => row.measuredAt >= blockCutoff);
const inWindow = ctx.measurementRows.filter(
(row) =>
(wanted ? wanted.has(row.type) : row.type === block.type) &&
row.measuredAt >= blockCutoff,
);
if (rows.length === 0) {
if (inWindow.length === 0) {
const cluster = sourceCluster(block.source);
if (cluster) {
annotate({
@@ -289,43 +301,120 @@ export function buildValueSeriesBlocks(
continue;
}
const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz);
// Aggregated on the canonical value (the grounding below reads that),
// stated in the reader's unit. The two HRV estimators share one unit.
const transform = getReadingTransform(
typeof block.type === "string" ? block.type : block.type[0],
ctx.units,
const present = block.measures?.filter((measure) =>
inWindow.some((row) => row.type === measure.type),
);
ctx.snapshot[block.snapshotKey] = {
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
timeline: timelineInUnit(
{
recent: recentRows,
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
),
};
ctx.metrics.add(block.metric);
ctx.counts[block.metric] = rows.length;
ctx.registerBlock(block.snapshotKey, block.source, () => ({
value: dailyPoints(rows, ctx.userTz, undefined, transform),
}));
const referenceMetric =
typeof block.type === "string"
? TYPE_TO_REFERENCE_METRIC[block.type]
: undefined;
if (referenceMetric && recentRows.length > 0) {
const values = recentRows.slice(-14);
const mean =
values.reduce((sum, row) => sum + row.value, 0) / values.length;
ctx.groundingValues.set(referenceMetric, mean);
if (present && present.length > 1) {
writeMeasuresSection(ctx, block, present, inWindow);
} else {
writeValueSection(
ctx,
block,
typeof block.type === "string" ? block.type : block.type[0]!,
inWindow,
);
}
ctx.metrics.add(block.metric);
ctx.counts[block.metric] = inWindow.length;
}
}
type ValueRows = ValueSeriesBlocksContext["measurementRows"];
function toPoints(rows: ValueRows) {
return rows.map((row) => ({ measuredAt: row.measuredAt, value: row.value }));
}
function valueTimeline(
ctx: Readonly<ValueSeriesBlocksContext>,
rows: ReturnType<typeof toPoints>,
transform: ReturnType<typeof getReadingTransform>,
) {
return timelineInUnit(
{
recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz),
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
);
}
/**
* Several measures of one metric, each its own timeline under `byMeasure`,
* and each its own series should the budget pass condense the block.
*/
function writeMeasuresSection(
ctx: Readonly<ValueSeriesBlocksContext>,
block: ValueBlock,
measures: readonly { type: string; label: string }[],
inWindow: ValueRows,
): void {
// The measures of one block share a unit (HRV: milliseconds).
const transform = getReadingTransform(measures[0]!.type, ctx.units);
const byMeasure: Record<string, unknown> = {};
const pointsByMeasure = new Map<string, ReturnType<typeof toPoints>>();
for (const measure of measures) {
const rows = toPoints(inWindow.filter((row) => row.type === measure.type));
pointsByMeasure.set(measure.label, rows);
byMeasure[measure.label] = valueTimeline(ctx, rows, transform);
}
ctx.snapshot[block.snapshotKey] = {
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
byMeasure,
};
ctx.registerBlock(block.snapshotKey, block.source, () =>
Object.fromEntries(
[...pointsByMeasure].map(([label, rows]) => [
label,
dailyPoints(rows, ctx.userTz, undefined, transform),
]),
),
);
}
function writeValueSection(
ctx: Readonly<ValueSeriesBlocksContext>,
block: ValueBlock,
unitType: string,
sourceRows: ValueRows,
): void {
const rows = toPoints(sourceRows);
const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz);
// Aggregated on the canonical value (the grounding below reads that),
// stated in the reader's unit.
const transform = getReadingTransform(unitType, ctx.units);
ctx.snapshot[block.snapshotKey] = {
...(UNLABELLED_UNITS.has(transform.displayUnit)
? {}
: { unit: transform.displayUnit }),
timeline: timelineInUnit(
{
recent: recentRows,
weekly: bucketWeekly(
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
ctx.userTz,
),
},
transform,
),
};
ctx.registerBlock(block.snapshotKey, block.source, () => ({
value: dailyPoints(rows, ctx.userTz, undefined, transform),
}));
const referenceMetric =
typeof block.type === "string"
? TYPE_TO_REFERENCE_METRIC[block.type]
: undefined;
if (referenceMetric && recentRows.length > 0) {
const values = recentRows.slice(-14);
const mean =
values.reduce((sum, row) => sum + row.value, 0) / values.length;
ctx.groundingValues.set(referenceMetric, mean);
}
}
+11 -8
View File
@@ -1223,10 +1223,12 @@ export function degradeToBudget(
delete timeline.coarse;
changed = true;
}
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
const c = asRecord(byContext[ctx]);
// Glucose keeps a timeline per context, HRV one per measure (#1110).
for (const nested of [block.byContext, block.byMeasure]) {
const byKey = asRecord(nested);
if (!byKey) continue;
for (const ctx of Object.keys(byKey)) {
const c = asRecord(byKey[ctx]);
if (c && "recent" in c) {
delete c.recent;
changed = true;
@@ -1258,10 +1260,11 @@ export function degradeToBudget(
delete block.timeline;
}
}
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
const c = asRecord(byContext[ctx]);
for (const nested of [block.byContext, block.byMeasure]) {
const byKey = asRecord(nested);
if (!byKey) continue;
for (const ctx of Object.keys(byKey)) {
const c = asRecord(byKey[ctx]);
if (c && "weekly" in c) {
delete c.weekly;
changed = true;
+2
View File
@@ -34,6 +34,8 @@ import {
export const RANKED_TYPES: readonly MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
// v1.42 (#1110) — the `hrvRmssd` ladder.
"HRV_RMSSD",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
@@ -45,9 +45,8 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// v1.11.0 — WHOOP-native score classes. These ingest server-side from the
// WHOOP API (source = WHOOP), never from HealthKit; Apple ships no
// identifier for day/workout strain, the WHOOP sleep-quality indices, sleep
// need, RMSSD HRV (Apple ships only the SDNN variant), or kJ energy. They
// have no HK mapping by design.
"HRV_RMSSD",
// need, or kJ energy. They have no HK mapping by design. (RMSSD HRV left
// this list in v1.42: iOS 27 added an identifier for it, #1110.)
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
@@ -124,6 +123,8 @@ describe("APPLE_HEALTH_TYPE_MAP", () => {
[
"HKCategoryTypeIdentifierSleepAnalysis",
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
// v1.42 (#1110) — the RMSSD variant, same consent as SDNN.
"HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
"HKQuantityTypeIdentifierVO2Max",
// v1.10.0 — overnight wrist temperature + the sleep-breathing
// index ride behind an explicit Health-share consent, same as
@@ -1081,3 +1082,30 @@ describe("percent-shaped HealthKit types take a fraction or an already-scaled pe
expect(mapping.convertToDbUnit(1.02)).toBeCloseTo(102);
});
});
describe("RMSSD HRV from HealthKit (#1110, iOS 27)", () => {
it("maps the RMSSD identifier onto HRV_RMSSD in milliseconds, never onto SDNN", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
value: 64,
unit: "ms",
startDate: "2026-10-01T03:00:00.000Z",
endDate: "2026-10-01T03:00:00.000Z",
});
expect(out).toMatchObject({ type: "HRV_RMSSD", value: 64, unit: "ms" });
expect(hkIdentifierForType("HRV_RMSSD")).toBe(
"HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
);
expect(hkIdentifierForType("HEART_RATE_VARIABILITY")).toBe(
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
);
});
it("accepts a trained person's 250 ms and refuses 301 ms", () => {
// RMSSD runs higher than SDNN; an out-of-range value is a terminal skip,
// so a 200 ms ceiling would have lost real readings for good.
expect(validateMeasurementRange("HRV_RMSSD", 250)).toBeNull();
expect(validateMeasurementRange("HRV_RMSSD", 300)).toBeNull();
expect(validateMeasurementRange("HRV_RMSSD", 301)).not.toBeNull();
});
});
@@ -176,4 +176,12 @@ describe("metricKeyForType — source-priority ladder coverage (v1.18.10 I-5)",
expect(metricKeyForType("STRESS_SCORE")).toBe("stress");
expect(RANKED_TYPES).toContain("STRESS_SCORE");
});
it("ranks RMSSD HRV on its own ladder, never on SDNN's (#1110)", () => {
// Since iOS 27 Apple Health writes RMSSD next to WHOOP / Oura / Polar,
// so a day can hold it from two sources.
expect(metricKeyForType("HRV_RMSSD")).toBe("hrvRmssd");
expect(metricKeyForType("HEART_RATE_VARIABILITY")).toBe("hrv");
expect(RANKED_TYPES).toContain("HRV_RMSSD");
});
});
@@ -337,6 +337,21 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
aggregation: "mean",
isPrivacySensitive: true,
},
// v1.42 (#1110) — RMSSD HRV, new in iOS / watchOS 27.0. Apple documents it
// as `HKQuantityTypeIdentifier.heartRateVariabilityRMSSD`, symbol
// `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
// (developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/
// heartratevariabilityrmssd). It lands on `HRV_RMSSD` beside WHOOP, Oura
// and Polar, never on the SDNN type: the two are different statistics.
HKQuantityTypeIdentifierHeartRateVariabilityRMSSD: {
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
measurementType: "HRV_RMSSD",
hkUnit: "ms",
dbUnit: "ms",
convertToDbUnit: (v) => v,
aggregation: "mean",
isPrivacySensitive: true,
},
// ── Activity (cumulative) ───────────────────────────────────
HKQuantityTypeIdentifierStepCount: {
@@ -151,6 +151,11 @@ export function metricKeyForType(
return "restingHeartRate";
case "HEART_RATE_VARIABILITY":
return "hrv";
// v1.42 (#1110) — RMSSD has its own ladder: WHOOP, Oura, Polar and,
// since iOS 27, Apple Health all write it, so one day can hold it from
// two sources.
case "HRV_RMSSD":
return "hrvRmssd";
case "RESPIRATORY_RATE":
return "respiratoryRate";
case "OXYGEN_SATURATION":
+138
View File
@@ -0,0 +1,138 @@
/**
* RMSSD HRV from HealthKit (#1110), against real Postgres: iOS / watchOS 27
* added `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`. Both ingest
* paths, the live batch and the `export.zip` import, read the one mapping;
* each must store it as `HRV_RMSSD`, apart from SDNN, and keep a reading
* above the old 200 ms ceiling instead of skipping it for good.
*/
import { mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
const USER_ID = "user-hrv-rmssd-ingest";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "hrv-rmssd",
email: "hrv-rmssd@example.test",
timezone: "UTC",
},
});
const session = await getPrismaClient().session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
describe("RMSSD HRV ingest (#1110)", () => {
it("stores a batch entry as HRV_RMSSD, a 250 ms reading included", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const at = "2026-10-01T03:00:00.000Z";
const res = await POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
value: 250,
unit: "ms",
startDate: at,
endDate: at,
externalId: "uuid-rmssd-1",
},
{
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
value: 48,
unit: "ms",
startDate: at,
endDate: at,
externalId: "uuid-sdnn-1",
},
],
}),
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { entries: Array<{ status: string }> };
};
expect(body.data.entries.map((e) => e.status)).toEqual([
"inserted",
"inserted",
]);
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER_ID },
orderBy: { type: "asc" },
select: { type: true, value: true, unit: true },
});
expect(rows).toEqual([
{ type: "HEART_RATE_VARIABILITY", value: 48, unit: "ms" },
{ type: "HRV_RMSSD", value: 250, unit: "ms" },
]);
});
it("imports an RMSSD record from export.zip as HRV_RMSSD", async () => {
const dir = mkdtempSync(join(tmpdir(), "healthlog-rmssd-"));
const xmlPath = join(dir, "export.xml");
writeFileSync(
xmlPath,
`<?xml version="1.0" encoding="UTF-8"?>
<HealthData locale="en_US">
<Record type="HKQuantityTypeIdentifierHeartRateVariabilityRMSSD" unit="ms"
startDate="2026-10-01 05:00:00 +0200"
endDate="2026-10-01 05:00:00 +0200"
value="71.5" sourceName="Watch" sourceVersion="27.0"/>
</HealthData>`,
);
await streamParseExportXml({
xmlPath,
userId: USER_ID,
userTimezone: "UTC",
prisma: getPrismaClient(),
});
const rows = await getPrismaClient().measurement.findMany({
where: { userId: USER_ID },
select: { type: true, value: true, source: true },
});
expect(rows).toEqual([
{ type: "HRV_RMSSD", value: 71.5, source: "APPLE_HEALTH" },
]);
});
});