From 370270bbc1622f2fa81bdc8969a42c31ff07da01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:37:04 +0200 Subject: [PATCH] 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. --- docs/integrations/apple-health.md | 11 ++ src/app/insights/hrv/page.tsx | 6 +- ...ealthkit-metric-page-hrv-measures.test.tsx | 114 ++++++++++++ .../insights/healthkit-metric-page.tsx | 48 ++++- .../__tests__/hrv-measures-snapshot.test.ts | 90 +++++++++ src/lib/ai/coach/series-condense.ts | 11 +- .../snapshot-blocks/value-series-blocks.ts | 175 +++++++++++++----- src/lib/ai/coach/snapshot.ts | 19 +- src/lib/analytics/source-rank-sql.ts | 2 + .../__tests__/apple-health-mapping.test.ts | 34 +++- .../__tests__/cumulative-day-sum.test.ts | 8 + src/lib/measurements/apple-health-mapping.ts | 15 ++ src/lib/measurements/cumulative-day-sum.ts | 5 + tests/integration/hrv-rmssd-ingest.test.ts | 138 ++++++++++++++ 14 files changed, 618 insertions(+), 58 deletions(-) create mode 100644 src/components/insights/__tests__/healthkit-metric-page-hrv-measures.test.tsx create mode 100644 src/lib/ai/coach/__tests__/hrv-measures-snapshot.test.ts create mode 100644 tests/integration/hrv-rmssd-ingest.test.ts 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" }, + ]); + }); +});