mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(apple-health): ingest RMSSD heart-rate variability from iOS 27
iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD` (Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`, available from 27.0). The live batch and the export.zip import now store it as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The accepted range is 1 to 300 ms. RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a day holding it from Apple Health and a strap resolves to one source instead of a name tiebreak. SDNN and RMSSD are different statistics and are kept apart once one account holds both. The HRV page charts each on its own, titled with its measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section carries one timeline per measure under `byMeasure` (same section key, so every reader still finds it) rather than one daily series averaging the two; the budget pass condenses and sheds each measure's timeline like a glucose context.
This commit is contained in:
1 parent
2731c46399
commit
370270bbc1
14 files changed
+618
-58
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* #1110 — since iOS 27 one Apple Watch writes both HRV measures, SDNN
|
||||
* (`HEART_RATE_VARIABILITY`) and RMSSD (`HRV_RMSSD`). The page used to chart
|
||||
* RMSSD only when there was no SDNN at all, so a watch writing both showed
|
||||
* SDNN and hid RMSSD. With both present each now gets its own chart, titled
|
||||
* with its measure; they are never drawn as one line.
|
||||
*
|
||||
* The chart is `next/dynamic`; the stub echoes the props that matter.
|
||||
*/
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = (props: { types?: string[]; title?: string }) => (
|
||||
<div
|
||||
data-slot="chart-stub"
|
||||
data-types={(props.types ?? []).join(",")}
|
||||
data-title={props.title ?? ""}
|
||||
/>
|
||||
);
|
||||
Stub.displayName = "HealthChartStub";
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => "/insights/hrv" }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "UTC", dateOfBirth: null, gender: null },
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
|
||||
}));
|
||||
const analyticsMock = vi.fn();
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => analyticsMock(),
|
||||
}));
|
||||
|
||||
import InsightsHrvPage from "@/app/insights/hrv/page";
|
||||
|
||||
function render() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightsHrvPage />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function charts(html: string): Array<{ types: string; title: string }> {
|
||||
return [...html.matchAll(/data-types="([^"]*)" data-title="([^"]*)"/g)].map(
|
||||
(m) => ({ types: m[1]!, title: m[2]! }),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => analyticsMock.mockReset());
|
||||
|
||||
describe("/insights/hrv — SDNN and RMSSD kept apart (#1110)", () => {
|
||||
it("charts each measure on its own when both have readings", () => {
|
||||
analyticsMock.mockReturnValue({
|
||||
data: {
|
||||
summaries: {
|
||||
HEART_RATE_VARIABILITY: { count: 30 },
|
||||
HRV_RMSSD: { count: 28 },
|
||||
},
|
||||
},
|
||||
isEmpty: false,
|
||||
});
|
||||
const found = charts(render());
|
||||
expect(found).toHaveLength(2);
|
||||
expect(found[0]).toMatchObject({ types: "HEART_RATE_VARIABILITY" });
|
||||
expect(found[0]!.title).toMatch(/SDNN$/);
|
||||
expect(found[1]).toMatchObject({ types: "HRV_RMSSD" });
|
||||
expect(found[1]!.title).toMatch(/RMSSD$/);
|
||||
});
|
||||
|
||||
it("keeps one unlabelled SDNN chart when there is no RMSSD", () => {
|
||||
analyticsMock.mockReturnValue({
|
||||
data: { summaries: { HEART_RATE_VARIABILITY: { count: 30 } } },
|
||||
isEmpty: false,
|
||||
});
|
||||
const found = charts(render());
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.types).toBe("HEART_RATE_VARIABILITY");
|
||||
expect(found[0]!.title).not.toMatch(/SDNN|RMSSD/);
|
||||
});
|
||||
|
||||
it("charts RMSSD alone, labelled, for a ring or strap user", () => {
|
||||
analyticsMock.mockReturnValue({
|
||||
data: { summaries: { HRV_RMSSD: { count: 28 } } },
|
||||
isEmpty: false,
|
||||
});
|
||||
const found = charts(render());
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.types).toBe("HRV_RMSSD");
|
||||
expect(found[0]!.title).toMatch(/RMSSD$/);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,16 @@ export interface HealthKitMetricPageProps {
|
||||
* `fallbackMeasurementType` is set.
|
||||
*/
|
||||
fallbackMeasureLabel?: string;
|
||||
/**
|
||||
* v1.42 (#1110) — short label naming the PRIMARY measure (e.g. "SDNN").
|
||||
* Since iOS 27 one account can hold both HRV measures from the same
|
||||
* watch. They are different statistics on different scales, so when
|
||||
* both have rows the page charts each on its own, each titled with its
|
||||
* measure, instead of hiding the second behind the first.
|
||||
*/
|
||||
primaryMeasureLabel?: string;
|
||||
/** Line colour of the fallback measure's own chart; defaults to `color`. */
|
||||
fallbackColor?: `var(--${string})`;
|
||||
/** The InsightMetric key used by `useInsightsAnalytics()`. */
|
||||
insightMetric: InsightMetric;
|
||||
/** The chart-overlay slot id. */
|
||||
@@ -215,6 +225,8 @@ export function HealthKitMetricPage({
|
||||
measurementType,
|
||||
fallbackMeasurementType,
|
||||
fallbackMeasureLabel,
|
||||
primaryMeasureLabel,
|
||||
fallbackColor,
|
||||
insightMetric,
|
||||
chartKey,
|
||||
i18nPrefix,
|
||||
@@ -269,6 +281,11 @@ export function HealthKitMetricPage({
|
||||
const effectiveType = usingFallback
|
||||
? (fallbackMeasurementType as string)
|
||||
: measurementType;
|
||||
// v1.42 (#1110) — both measures present: the primary keeps the strip, the
|
||||
// coach read and the value list, and the fallback measure gets a chart of
|
||||
// its own beneath it. Never one line, never one hidden.
|
||||
const bothMeasures =
|
||||
!!fallbackMeasurementType && primaryCount > 0 && fallbackCount > 0;
|
||||
|
||||
// v1.32.26 — resolve the display unit + scale from the user's preference for
|
||||
// a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved,
|
||||
@@ -421,7 +438,11 @@ export function HealthKitMetricPage({
|
||||
summary={summary}
|
||||
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
|
||||
fractionDigits={resolvedFractionDigits}
|
||||
seriesLabel={title}
|
||||
seriesLabel={
|
||||
bothMeasures && primaryMeasureLabel
|
||||
? `${title} · ${primaryMeasureLabel}`
|
||||
: title
|
||||
}
|
||||
icon={statIcon}
|
||||
windowStats={statsByType?.[effectiveType] ?? null}
|
||||
medianLabel={statMedianLabel}
|
||||
@@ -455,7 +476,9 @@ export function HealthKitMetricPage({
|
||||
title={
|
||||
usingFallback && fallbackMeasureLabel
|
||||
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
|
||||
: t(`${i18nPrefix}.chartTitle`)
|
||||
: bothMeasures && primaryMeasureLabel
|
||||
? `${t(`${i18nPrefix}.chartTitle`)} · ${primaryMeasureLabel}`
|
||||
: t(`${i18nPrefix}.chartTitle`)
|
||||
}
|
||||
titleIcon={statIcon}
|
||||
colors={[color]}
|
||||
@@ -469,6 +492,27 @@ export function HealthKitMetricPage({
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
/>
|
||||
{bothMeasures ? (
|
||||
<HealthChartDynamic
|
||||
chartKey={chartKey}
|
||||
types={[fallbackMeasurementType as string]}
|
||||
title={
|
||||
fallbackMeasureLabel
|
||||
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
|
||||
: t(`${i18nPrefix}.chartTitle`)
|
||||
}
|
||||
titleIcon={statIcon}
|
||||
colors={[fallbackColor ?? color]}
|
||||
unit={resolvedUnit}
|
||||
yAxisUnit={resolvedYAxisUnit}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={resolvedScale}
|
||||
valueOffset={resolvedOffset}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
/>
|
||||
) : null}
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user