From a45325af52d48c2b9621213db1d6a78607f53c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:08 +0200 Subject: [PATCH] feat(healthkit): thread the device-event verdict through batch ingest Accept the optional HKCategoryValue codepoint on each batch entry and persist the resolved rhythm_classification on the stored row. The wire field is additive and ignored for non-event identifiers, so every existing caller is byte-for-byte unchanged. Regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 33 +++++++++++++++++++++++++ src/app/api/measurements/batch/route.ts | 10 ++++++++ src/lib/openapi/routes.ts | 9 +++++++ 3 files changed, 52 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d25cba861..7fdd80ece 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -383,6 +383,11 @@ paths: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT - in: query name: from schema: @@ -523,6 +528,11 @@ paths: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT value: type: number measuredAt: @@ -2090,6 +2100,11 @@ paths: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -2544,6 +2559,14 @@ components: type: integer minimum: 0 maximum: 20 + categoryValue: + description: v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, + walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, + which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it + never re-classifies. Ignored for non-event identifiers. + type: integer + minimum: 0 + maximum: 20 externalId: type: string minLength: 1 @@ -3758,6 +3781,11 @@ components: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT value: type: number unit: @@ -4055,6 +4083,11 @@ components: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT value: type: number unit: diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index f2c2713ba..152435efa 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -90,6 +90,12 @@ const batchEntrySchema = z.object({ startDate: z.iso.datetime({ offset: true }), endDate: z.iso.datetime({ offset: true }), sleepStage: z.number().int().min(0).max(20).optional(), + // v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` + // codepoint for an EVENT-class category sample (irregular-rhythm, + // high/low-HR, walking-steadiness, breathing-disturbance). Carries the + // device's own verdict / severity; resolved to a `rhythmClassification` + // by `mapAppleHealthEntry`. Ignored for every non-event identifier. + categoryValue: z.number().int().min(0).max(20).optional(), externalId: z.string().min(1).max(120), externalSourceVersion: z.string().min(1).max(120).optional(), // v1.8.6 W6 — optional per-entry source tag. Defaults to @@ -215,6 +221,7 @@ async function postBatch(request: NextRequest): Promise { startDate: entry.startDate, endDate: entry.endDate, sleepStage: entry.sleepStage, + categoryValue: entry.categoryValue, }); if (!mapped) { @@ -254,6 +261,9 @@ async function postBatch(request: NextRequest): Promise { externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, sleepStage: mapped.sleepStage ?? null, + // v1.10.0 — categorical events (WX-B). The device's own verdict / + // severity for an EVENT row; null for every continuous reading. + rhythmClassification: mapped.rhythmClassification ?? null, // v1.4.25 W8c — pass the iOS-supplied device-type through to // the row. Stays null for pre-W8c iOS builds; the canonical // picker treats null as `unknown` so legacy ingest keeps diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 32d23dd53..49574c5d2 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -378,6 +378,15 @@ const batchEntrySchema = z .describe( "HKCategoryValueSleepAnalysis codepoint; only for sleep samples.", ), + categoryValue: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe( + "v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers.", + ), externalId: z .string() .min(1)