mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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,138 @@
|
||||
/**
|
||||
* RMSSD HRV from HealthKit (#1110), against real Postgres: iOS / watchOS 27
|
||||
* added `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`. Both ingest
|
||||
* paths, the live batch and the `export.zip` import, read the one mapping;
|
||||
* each must store it as `HRV_RMSSD`, apart from SDNN, and keep a reading
|
||||
* above the old 200 ms ceiling instead of skipping it for good.
|
||||
*/
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
|
||||
|
||||
const USER_ID = "user-hrv-rmssd-ingest";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "hrv-rmssd",
|
||||
email: "hrv-rmssd@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
describe("RMSSD HRV ingest (#1110)", () => {
|
||||
it("stores a batch entry as HRV_RMSSD, a 250 ms reading included", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
const at = "2026-10-01T03:00:00.000Z";
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilityRMSSD",
|
||||
value: 250,
|
||||
unit: "ms",
|
||||
startDate: at,
|
||||
endDate: at,
|
||||
externalId: "uuid-rmssd-1",
|
||||
},
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
|
||||
value: 48,
|
||||
unit: "ms",
|
||||
startDate: at,
|
||||
endDate: at,
|
||||
externalId: "uuid-sdnn-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.entries.map((e) => e.status)).toEqual([
|
||||
"inserted",
|
||||
"inserted",
|
||||
]);
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: USER_ID },
|
||||
orderBy: { type: "asc" },
|
||||
select: { type: true, value: true, unit: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ type: "HEART_RATE_VARIABILITY", value: 48, unit: "ms" },
|
||||
{ type: "HRV_RMSSD", value: 250, unit: "ms" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports an RMSSD record from export.zip as HRV_RMSSD", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "healthlog-rmssd-"));
|
||||
const xmlPath = join(dir, "export.xml");
|
||||
writeFileSync(
|
||||
xmlPath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<HealthData locale="en_US">
|
||||
<Record type="HKQuantityTypeIdentifierHeartRateVariabilityRMSSD" unit="ms"
|
||||
startDate="2026-10-01 05:00:00 +0200"
|
||||
endDate="2026-10-01 05:00:00 +0200"
|
||||
value="71.5" sourceName="Watch" sourceVersion="27.0"/>
|
||||
</HealthData>`,
|
||||
);
|
||||
await streamParseExportXml({
|
||||
xmlPath,
|
||||
userId: USER_ID,
|
||||
userTimezone: "UTC",
|
||||
prisma: getPrismaClient(),
|
||||
});
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: USER_ID },
|
||||
select: { type: true, value: true, source: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ type: "HRV_RMSSD", value: 71.5, source: "APPLE_HEALTH" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user