Skip to content

Commit 8d8d8b6

Browse files
authored
Merge pull request #122 from ak710/feat/healthkit-export-completion
Export the four ring metrics that stopped short of Apple Health
2 parents dfd5751 + bd399a5 commit 8d8d8b6

7 files changed

Lines changed: 327 additions & 17 deletions

File tree

‎PulseLoop/Health/HealthKitTypeMappings.swift‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ enum HealthKitTypeMappings {
2020
let isPlausible: (Double) -> Bool
2121
}
2222

23-
/// Ported from PR #16. Stress / fatigue / blood-pressure / blood-sugar map to `nil`:
24-
/// - stress & fatigue have no native HealthKit equivalent.
25-
/// - blood pressure needs `HKCorrelation` pairing (an unpaired systolic/diastolic sample never
26-
/// surfaces as a reading in Health) plus new share-authorization types — a documented follow-up.
27-
/// - blood sugar likewise needs its own share type. Follow-up.
23+
/// Ported from PR #16. Two kinds still map to `nil`, for two different reasons:
24+
///
25+
/// - **Stress and fatigue have no HealthKit type at all.** Not a follow-up — there is nothing to
26+
/// map them onto. `HKStateOfMind` (iOS 17) is a self-reported mood log, not a device-derived
27+
/// score, and writing a ring's 0–100 wellness number into it would misrepresent both.
28+
/// - **Blood pressure is a correlation, not a quantity.** An unpaired systolic or diastolic
29+
/// sample never surfaces as a reading in Health, so it needs `HKCorrelation` pairing and gets
30+
/// its own export pass in `HealthSyncService` rather than a `QuantityMapping`.
2831
static func quantityMapping(for kind: MeasurementKind) -> QuantityMapping? {
2932
switch kind {
3033
case .heartRate:
@@ -45,17 +48,43 @@ enum HealthKitTypeMappings {
4548
guard let type = HKQuantityType.quantityType(forIdentifier: .bodyTemperature) else { return nil }
4649
return QuantityMapping(type: type, unit: .degreeCelsius(),
4750
convert: { $0 }, isPlausible: { $0 > 25 && $0 < 45 })
51+
case .respiratoryRate:
52+
guard let type = HKQuantityType.quantityType(forIdentifier: .respiratoryRate) else { return nil }
53+
return QuantityMapping(type: type, unit: HKUnit.count().unitDivided(by: .minute()),
54+
convert: { $0 }, isPlausible: { $0 >= 4 && $0 <= 60 })
55+
case .vo2max:
56+
guard let type = HKQuantityType.quantityType(forIdentifier: .vo2Max) else { return nil }
57+
// mL/(kg·min) — HealthKit spells the same unit as a compound.
58+
let unit = HKUnit.literUnit(with: .milli)
59+
.unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute()))
60+
return QuantityMapping(type: type, unit: unit,
61+
convert: { $0 }, isPlausible: { $0 >= 10 && $0 <= 90 })
62+
case .bloodSugar:
63+
guard let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose) else { return nil }
64+
// Stored canonically in mg/dL; HealthKit's mass/volume unit spells that as mg/dL too.
65+
let unit = HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci))
66+
return QuantityMapping(type: type, unit: unit,
67+
convert: { $0 }, isPlausible: { $0 >= 40 && $0 <= 600 })
4868
case .stress, .fatigue:
49-
return nil // No native HealthKit equivalent.
50-
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodSugar:
51-
return nil // BP needs HKCorrelation pairing + new share types; blood sugar needs its own. Follow-up.
52-
case .respiratoryRate, .vo2max:
53-
// HealthKit has both (`respiratoryRate`, `vo2Max`), but exporting them needs new share
54-
// types plus their own per-type toggles to keep the sync opt-in per metric. Follow-up.
55-
return nil
69+
return nil // No HealthKit type exists for either — see the note above.
70+
case .bloodPressureSystolic, .bloodPressureDiastolic:
71+
return nil // Exported as an HKCorrelation instead; see `bloodPressureSyncID`.
5672
}
5773
}
5874

75+
/// A paired blood-pressure reading, keyed by the instant both halves share so a re-export
76+
/// upserts rather than duplicating.
77+
static func bloodPressureSyncID(timestamp: Date) -> String {
78+
"pl-bp-\(Int(timestamp.timeIntervalSince1970 * 1000))"
79+
}
80+
81+
/// Plausibility guards for the two halves of a blood-pressure reading, mirroring
82+
/// `RingEventBridge`'s persistence gates so a value that reached the store can still be rejected
83+
/// here if it is nonsense as a *pair* (systolic at or below diastolic).
84+
static func isPlausibleBloodPressure(systolic: Double, diastolic: Double) -> Bool {
85+
(60...250).contains(systolic) && (30...160).contains(diastolic) && systolic > diastolic
86+
}
87+
5988
// MARK: - Workouts
6089

6190
/// Maps a PulseLoop activity type onto the closest `HKWorkoutActivityType`.

‎PulseLoop/Health/HealthSyncService.swift‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ final class HealthSyncService {
6060
private var quantityWriteTypes: [HKQuantityType] {
6161
var identifiers: [HKQuantityTypeIdentifier] = [
6262
.heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature,
63-
.stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling
63+
.stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling,
64+
// Ring-dependent: only some families produce these, but the share set is fixed at
65+
// authorization time and can't be re-prompted per device, so all four are requested up
66+
// front. A ring that never reports one simply never writes it.
67+
.respiratoryRate, .vo2Max, .bloodGlucose,
68+
.bloodPressureSystolic, .bloodPressureDiastolic
6469
]
6570
// Dietary types join the share set only once the nutrition feature is enabled, so users
6671
// who never opted in never see dietary rows on the Health authorization sheet. Enabling
@@ -116,6 +121,8 @@ final class HealthSyncService {
116121

117122
do { try await exportVitals(context: context, state: &state, counts: &counts, now: now, device: device) }
118123
catch { log.error("Vitals export failed: \(error.localizedDescription)") }
124+
do { try await exportBloodPressure(context: context, state: &state, counts: &counts, now: now, device: device) }
125+
catch { log.error("Blood-pressure export failed: \(error.localizedDescription)") }
119126
do { try await exportActivity(context: context, state: &state, counts: &counts, now: now, device: device) }
120127
catch { log.error("Activity export failed: \(error.localizedDescription)") }
121128
do { try await exportSleep(context: context, state: &state, counts: &counts, now: now, device: device) }
@@ -172,6 +179,10 @@ final class HealthSyncService {
172179
if prefs.syncSpO2 { kinds.append(.spo2) }
173180
if prefs.syncHRV { kinds.append(.hrv) }
174181
if prefs.syncTemperature { kinds.append(.temperature) }
182+
if prefs.syncRespiratoryRate { kinds.append(.respiratoryRate) }
183+
if prefs.syncVO2Max { kinds.append(.vo2max) }
184+
if prefs.syncBloodSugar { kinds.append(.bloodSugar) }
185+
// Blood pressure is deliberately absent: it exports as a correlation, not a quantity.
175186
return kinds
176187
}
177188

@@ -235,6 +246,87 @@ final class HealthSyncService {
235246
)
236247
}
237248

249+
// MARK: - Blood-pressure pass
250+
251+
/// Blood pressure is the one vital that can't ride the quantity path: Health only recognises a
252+
/// reading when systolic and diastolic are saved together inside an `HKCorrelation`. Saved
253+
/// separately they are stored but never surface in the Health app, which looks exactly like a
254+
/// silent failure.
255+
///
256+
/// Rows for the two halves are written from one packet at one instant (`bloodPressureEvents`), so
257+
/// the shared timestamp is the pairing key. A half without its partner is skipped rather than
258+
/// guessed at.
259+
///
260+
/// The watermark reuses the `bloodPressureSystolic` slot in `measurementWatermarks`: that kind is
261+
/// never exported as a quantity, so the slot is free, and it inherits the reset/backfill handling
262+
/// `resetWatermarks` already applies to every `MeasurementKind`.
263+
private func exportBloodPressure(context: ModelContext, state: inout AppleHealthSyncState,
264+
counts: inout SyncCounts, now: Date, device: HKDevice?) async throws {
265+
guard prefsStore.prefs.syncBloodPressure,
266+
let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
267+
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
268+
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure),
269+
canShare(systolicType), canShare(diastolicType) else { return }
270+
271+
let watermarkKey = MeasurementKind.bloodPressureSystolic.rawValue
272+
let systolicRaw = MeasurementKind.bloodPressureSystolic.rawValue
273+
let diastolicRaw = MeasurementKind.bloodPressureDiastolic.rawValue
274+
let mockRaw = MeasurementSource.mock.rawValue
275+
let watermark = state.measurementWatermarks[watermarkKey] ?? .distantPast
276+
277+
let systolicDescriptor = FetchDescriptor<Measurement>(
278+
predicate: #Predicate { $0.kindRaw == systolicRaw && $0.sourceRaw != mockRaw && $0.createdAt > watermark },
279+
sortBy: [SortDescriptor(\.createdAt, order: .forward)]
280+
)
281+
let systolicRows = (try? context.fetch(systolicDescriptor)) ?? []
282+
guard !systolicRows.isEmpty else { return }
283+
284+
// Index the diastolic halves across the same instant span. Bounded by the batch's own range
285+
// rather than the watermark, so a partner row that was persisted in a different pass — and
286+
// therefore carries a different `createdAt` — is still found.
287+
guard let spanStart = systolicRows.map(\.timestamp).min(),
288+
let spanEnd = systolicRows.map(\.timestamp).max() else { return }
289+
let diastolicDescriptor = FetchDescriptor<Measurement>(
290+
predicate: #Predicate {
291+
$0.kindRaw == diastolicRaw && $0.sourceRaw != mockRaw
292+
&& $0.timestamp >= spanStart && $0.timestamp <= spanEnd
293+
}
294+
)
295+
let diastolicByInstant = Dictionary(
296+
((try? context.fetch(diastolicDescriptor)) ?? []).map { ($0.timestamp, $0.value) },
297+
uniquingKeysWith: { first, _ in first }
298+
)
299+
300+
for chunk in systolicRows.chunked(into: 1000) {
301+
let correlations: [HKCorrelation] = chunk.compactMap { row in
302+
guard row.timestamp <= now, let diastolic = diastolicByInstant[row.timestamp],
303+
HealthKitTypeMappings.isPlausibleBloodPressure(systolic: row.value, diastolic: diastolic)
304+
else { return nil }
305+
306+
let unit = HKUnit.millimeterOfMercury()
307+
let metadata = HealthKitTypeMappings.metadata(
308+
syncID: HealthKitTypeMappings.bloodPressureSyncID(timestamp: row.timestamp), version: 1
309+
)
310+
let objects: Set<HKSample> = [
311+
HKQuantitySample(type: systolicType, quantity: HKQuantity(unit: unit, doubleValue: row.value),
312+
start: row.timestamp, end: row.timestamp, device: device, metadata: nil),
313+
HKQuantitySample(type: diastolicType, quantity: HKQuantity(unit: unit, doubleValue: diastolic),
314+
start: row.timestamp, end: row.timestamp, device: device, metadata: nil),
315+
]
316+
return HKCorrelation(type: correlationType, start: row.timestamp, end: row.timestamp,
317+
objects: objects, device: device, metadata: metadata)
318+
}
319+
if !correlations.isEmpty {
320+
try await save(correlations)
321+
counts.bloodPressure += correlations.count
322+
}
323+
if let maxCreated = chunk.map(\.createdAt).max() {
324+
state.measurementWatermarks[watermarkKey] = maxCreated
325+
prefsStore.syncState = state
326+
}
327+
}
328+
}
329+
238330
// MARK: - Daily activity pass
239331

240332
private func exportActivity(context: ModelContext, state: inout AppleHealthSyncState,
@@ -497,6 +589,7 @@ final class HealthSyncService {
497589

498590
struct SyncCounts {
499591
var vitals = 0
592+
var bloodPressure = 0
500593
var sleepSegments = 0
501594
var dailyTotals = 0
502595
var workouts = 0
@@ -505,6 +598,7 @@ final class HealthSyncService {
505598
var summary: String {
506599
var parts: [String] = []
507600
if vitals > 0 { parts.append("\(vitals) vitals") }
601+
if bloodPressure > 0 { parts.append("\(bloodPressure) BP reading\(bloodPressure == 1 ? "" : "s")") }
508602
if sleepSegments > 0 { parts.append("\(sleepSegments) sleep segment\(sleepSegments == 1 ? "" : "s")") }
509603
if dailyTotals > 0 { parts.append("\(dailyTotals) daily total\(dailyTotals == 1 ? "" : "s")") }
510604
if workouts > 0 { parts.append("\(workouts) workout\(workouts == 1 ? "" : "s")") }

‎PulseLoop/Services/Repositories.swift‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ enum MetricsRepository {
104104
return (try? context.fetch(descriptor)) ?? []
105105
}
106106

107+
/// Whether the store holds any reading of a kind. `fetchLimit: 1` — an existence check, not a
108+
/// count, so it stays cheap enough to call from a settings `body`.
109+
@MainActor
110+
static func hasAnyMeasurement(kind: MeasurementKind, context: ModelContext) -> Bool {
111+
let raw = kind.rawValue
112+
var descriptor = FetchDescriptor<Measurement>(predicate: #Predicate { $0.kindRaw == raw })
113+
descriptor.fetchLimit = 1
114+
return ((try? context.fetch(descriptor)) ?? []).isEmpty == false
115+
}
116+
107117
/// Oldest measurement timestamp across all kinds (for the calibration "Day X of N" counter).
108118
/// `fetchLimit: 1` ascending — one row, not the whole table.
109119
@MainActor

‎PulseLoop/Settings/AppleHealthPrefsStore.swift‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ struct AppleHealthPrefs: Codable, Equatable {
2828
var syncTemperature = true
2929
var syncSleep = true
3030
var syncActivity = true
31+
/// The four ring metrics that reach Health but only exist on some hardware. Default **on** like
32+
/// the rest — the settings screen hides the row entirely on a ring that can't produce the metric,
33+
/// so an enabled-but-unreachable toggle never writes anything.
34+
var syncRespiratoryRate = true
35+
var syncVO2Max = true
36+
var syncBloodSugar = true
37+
/// Exported as an `HKCorrelation` pairing systolic + diastolic, not as two loose quantities.
38+
var syncBloodPressure = true
3139
/// Whether finished workout sessions export as `HKWorkout`s (calories, distance, HR stats, GPS route).
3240
var exportWorkouts = true
3341
/// Whether logged meals export as dietary samples (energy + macros). Only effective when the
@@ -52,6 +60,10 @@ struct AppleHealthPrefs: Codable, Equatable {
5260
syncTemperature = try c.decodeIfPresent(Bool.self, forKey: .syncTemperature) ?? d.syncTemperature
5361
syncSleep = try c.decodeIfPresent(Bool.self, forKey: .syncSleep) ?? d.syncSleep
5462
syncActivity = try c.decodeIfPresent(Bool.self, forKey: .syncActivity) ?? d.syncActivity
63+
syncRespiratoryRate = try c.decodeIfPresent(Bool.self, forKey: .syncRespiratoryRate) ?? d.syncRespiratoryRate
64+
syncVO2Max = try c.decodeIfPresent(Bool.self, forKey: .syncVO2Max) ?? d.syncVO2Max
65+
syncBloodSugar = try c.decodeIfPresent(Bool.self, forKey: .syncBloodSugar) ?? d.syncBloodSugar
66+
syncBloodPressure = try c.decodeIfPresent(Bool.self, forKey: .syncBloodPressure) ?? d.syncBloodPressure
5567
exportWorkouts = try c.decodeIfPresent(Bool.self, forKey: .exportWorkouts) ?? d.exportWorkouts
5668
syncNutrition = try c.decodeIfPresent(Bool.self, forKey: .syncNutrition) ?? d.syncNutrition
5769
backfillChoice = try c.decodeIfPresent(HealthBackfillChoice.self, forKey: .backfillChoice) ?? d.backfillChoice

‎PulseLoop/Views/Settings/AppleHealthSettingsView.swift‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import UIKit
1010
/// can clean up after turning sync off).
1111
struct AppleHealthSettingsView: View {
1212
@Environment(\.modelContext) private var modelContext
13+
@Environment(RingBLEClient.self) private var ble
1314
@State private var service = HealthSyncService.shared
1415
@State private var store = AppleHealthPrefsStore.shared
1516
/// First-enable backfill choice ("all history" vs "new only" vs cancel).
@@ -21,6 +22,12 @@ struct AppleHealthSettingsView: View {
2122

2223
private var masterOn: Bool { store.prefs.masterEnabled }
2324

25+
/// What the connected ring can actually produce. Rows for metrics it can't are hidden outright
26+
/// rather than shown-and-inert: a VO₂max toggle on a jring is a promise the hardware can't keep.
27+
private var capabilities: Set<WearableCapability> {
28+
MetricsService.activeCapabilities(context: modelContext, ble: ble)
29+
}
30+
2431
var body: some View {
2532
ScrollView {
2633
VStack(alignment: .leading, spacing: 22) {
@@ -96,17 +103,41 @@ struct AppleHealthSettingsView: View {
96103
@ViewBuilder private var dataTypesGroup: some View {
97104
SettingsGroup(
98105
header: "Data types",
99-
footer: "Stress, fatigue, and blood pressure don't have an Apple Health equivalent yet, so they aren't synced."
106+
footer: "Stress and fatigue have no Apple Health equivalent — Health has no type for a device "
107+
+ "wellness score — so they can't be synced. Rows appear only for metrics your ring can produce."
100108
) {
101109
FormToggleRow(title: "Heart rate", isOn: prefBinding(\.syncHeartRate))
102110
FormToggleRow(title: "Blood oxygen", isOn: prefBinding(\.syncSpO2))
103111
FormToggleRow(title: "Heart rate variability", isOn: prefBinding(\.syncHRV))
104112
FormToggleRow(title: "Temperature", isOn: prefBinding(\.syncTemperature))
113+
if shows(.respiratoryRate) {
114+
FormToggleRow(title: "Respiratory rate", isOn: prefBinding(\.syncRespiratoryRate))
115+
}
116+
if shows(.vo2max) {
117+
FormToggleRow(title: "Cardio fitness (VO₂max)", isOn: prefBinding(\.syncVO2Max))
118+
}
119+
if shows(.bloodSugar, capability: .bloodSugar) {
120+
FormToggleRow(title: "Blood glucose", isOn: prefBinding(\.syncBloodSugar))
121+
}
122+
if shows(.bloodPressureSystolic, capability: .bloodPressure) {
123+
FormToggleRow(title: "Blood pressure", isOn: prefBinding(\.syncBloodPressure))
124+
}
105125
FormToggleRow(title: "Sleep", isOn: prefBinding(\.syncSleep))
106126
FormToggleRow(title: "Steps & activity", isOn: prefBinding(\.syncActivity))
107127
}
108128
}
109129

130+
/// Whether to offer a toggle for a ring-dependent metric.
131+
///
132+
/// Shown when the connected ring declares the capability **or** the store already holds a reading
133+
/// of that kind. The second arm matters for two cases the capability alone misses: respiratory
134+
/// rate and VO₂max have no `WearableCapability` of their own (they ride the YCBT history records),
135+
/// and history from a previously-paired ring should stay exportable after switching hardware.
136+
private func shows(_ kind: MeasurementKind, capability: WearableCapability? = nil) -> Bool {
137+
if let capability, capabilities.contains(capability) { return true }
138+
return MetricsRepository.hasAnyMeasurement(kind: kind, context: modelContext)
139+
}
140+
110141
@ViewBuilder private var workoutsGroup: some View {
111142
SettingsGroup(
112143
header: "Workouts",

0 commit comments

Comments
 (0)