diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md
index 456dd96c3..dc7a3d615 100644
--- a/docs/integrations/apple-health.md
+++ b/docs/integrations/apple-health.md
@@ -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
diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx
index fca82fbe7..0ab1e33da 100644
--- a/src/app/insights/hrv/page.tsx
+++ b/src/app/insights/hrv/page.tsx
@@ -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"
diff --git a/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx
new file mode 100644
index 000000000..2f4273ac4
--- /dev/null
+++ b/src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx
@@ -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 }) => (
+
+ );
+ 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(
+
+
+
+
+ ,
+ );
+}
+
+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$/);
+ });
+});
diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx
index 321fddb30..5acc7c3bb 100644
--- a/src/components/insights/healthkit-metric-page.tsx
+++ b/src/components/insights/healthkit-metric-page.tsx
@@ -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 ? (
+
+ ) : null}
{targetSummarySlug ? (
) : null}
diff --git a/src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts b/src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts
new file mode 100644
index 000000000..3b31a2424
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts
@@ -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 = {};
+ const registered = new Map DailySeries>();
+ const counts: NonNullable = {};
+ 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 }>;
+ };
+ 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 }).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;
+ expect(block.byMeasure).toBeUndefined();
+ expect(block.timeline).toBeDefined();
+ });
+});
diff --git a/src/lib/ai/coach/series-condense.ts b/src/lib/ai/coach/series-condense.ts
index e068e9019..428aa2f8a 100644
--- a/src/lib/ai/coach/series-condense.ts
+++ b/src/lib/ai/coach/series-condense.ts
@@ -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":
diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts
index 948f636e1..f3ea65239 100644
--- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts
+++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts
@@ -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,
+ rows: ReturnType,
+ transform: ReturnType,
+) {
+ 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,
+ 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 = {};
+ const pointsByMeasure = new Map>();
+ 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,
+ 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);
}
}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 3ab1ff58a..2dd46bbc6 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -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;
diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts
index a0f02cec4..c53485983 100644
--- a/src/lib/analytics/source-rank-sql.ts
+++ b/src/lib/analytics/source-rank-sql.ts
@@ -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",
diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts
index 61892652c..48cac12e4 100644
--- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts
+++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts
@@ -45,9 +45,8 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([
// 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();
+ });
+});
diff --git a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts
index eb993ce5d..e9c5fb8b9 100644
--- a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts
+++ b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts
@@ -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");
+ });
});
diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts
index b368c805a..60824d9c8 100644
--- a/src/lib/measurements/apple-health-mapping.ts
+++ b/src/lib/measurements/apple-health-mapping.ts
@@ -337,6 +337,21 @@ export const APPLE_HEALTH_TYPE_MAP: Record = {
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: {
diff --git a/src/lib/measurements/cumulative-day-sum.ts b/src/lib/measurements/cumulative-day-sum.ts
index c4c55b1d1..239aefcd8 100644
--- a/src/lib/measurements/cumulative-day-sum.ts
+++ b/src/lib/measurements/cumulative-day-sum.ts
@@ -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":
diff --git a/tests/integration/hrv-rmssd-ingest.test.ts b/tests/integration/hrv-rmssd-ingest.test.ts
new file mode 100644
index 000000000..47a932972
--- /dev/null
+++ b/tests/integration/hrv-rmssd-ingest.test.ts
@@ -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,
+ `
+
+
+`,
+ );
+ 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" },
+ ]);
+ });
+});