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" }, + ]); + }); +});