Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions PulseLoop/Health/HealthKitTypeMappings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ enum HealthKitTypeMappings {
let isPlausible: (Double) -> Bool
}

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

/// A paired blood-pressure reading, keyed by the instant both halves share so a re-export
/// upserts rather than duplicating.
static func bloodPressureSyncID(timestamp: Date) -> String {
"pl-bp-\(Int(timestamp.timeIntervalSince1970 * 1000))"
}

/// Plausibility guards for the two halves of a blood-pressure reading, mirroring
/// `RingEventBridge`'s persistence gates so a value that reached the store can still be rejected
/// here if it is nonsense as a *pair* (systolic at or below diastolic).
static func isPlausibleBloodPressure(systolic: Double, diastolic: Double) -> Bool {
(60...250).contains(systolic) && (30...160).contains(diastolic) && systolic > diastolic
}

// MARK: - Workouts

/// Maps a PulseLoop activity type onto the closest `HKWorkoutActivityType`.
Expand Down
96 changes: 95 additions & 1 deletion PulseLoop/Health/HealthSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ final class HealthSyncService {
private var quantityWriteTypes: [HKQuantityType] {
var identifiers: [HKQuantityTypeIdentifier] = [
.heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature,
.stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling
.stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling,
// Ring-dependent: only some families produce these, but the share set is fixed at
// authorization time and can't be re-prompted per device, so all four are requested up
// front. A ring that never reports one simply never writes it.
.respiratoryRate, .vo2Max, .bloodGlucose,
.bloodPressureSystolic, .bloodPressureDiastolic
]
// Dietary types join the share set only once the nutrition feature is enabled, so users
// who never opted in never see dietary rows on the Health authorization sheet. Enabling
Expand Down Expand Up @@ -116,6 +121,8 @@ final class HealthSyncService {

do { try await exportVitals(context: context, state: &state, counts: &counts, now: now, device: device) }
catch { log.error("Vitals export failed: \(error.localizedDescription)") }
do { try await exportBloodPressure(context: context, state: &state, counts: &counts, now: now, device: device) }
catch { log.error("Blood-pressure export failed: \(error.localizedDescription)") }
do { try await exportActivity(context: context, state: &state, counts: &counts, now: now, device: device) }
catch { log.error("Activity export failed: \(error.localizedDescription)") }
do { try await exportSleep(context: context, state: &state, counts: &counts, now: now, device: device) }
Expand Down Expand Up @@ -172,6 +179,10 @@ final class HealthSyncService {
if prefs.syncSpO2 { kinds.append(.spo2) }
if prefs.syncHRV { kinds.append(.hrv) }
if prefs.syncTemperature { kinds.append(.temperature) }
if prefs.syncRespiratoryRate { kinds.append(.respiratoryRate) }
if prefs.syncVO2Max { kinds.append(.vo2max) }
if prefs.syncBloodSugar { kinds.append(.bloodSugar) }
// Blood pressure is deliberately absent: it exports as a correlation, not a quantity.
return kinds
}

Expand Down Expand Up @@ -235,6 +246,87 @@ final class HealthSyncService {
)
}

// MARK: - Blood-pressure pass

/// Blood pressure is the one vital that can't ride the quantity path: Health only recognises a
/// reading when systolic and diastolic are saved together inside an `HKCorrelation`. Saved
/// separately they are stored but never surface in the Health app, which looks exactly like a
/// silent failure.
///
/// Rows for the two halves are written from one packet at one instant (`bloodPressureEvents`), so
/// the shared timestamp is the pairing key. A half without its partner is skipped rather than
/// guessed at.
///
/// The watermark reuses the `bloodPressureSystolic` slot in `measurementWatermarks`: that kind is
/// never exported as a quantity, so the slot is free, and it inherits the reset/backfill handling
/// `resetWatermarks` already applies to every `MeasurementKind`.
private func exportBloodPressure(context: ModelContext, state: inout AppleHealthSyncState,
counts: inout SyncCounts, now: Date, device: HKDevice?) async throws {
guard prefsStore.prefs.syncBloodPressure,
let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure),
canShare(systolicType), canShare(diastolicType) else { return }

let watermarkKey = MeasurementKind.bloodPressureSystolic.rawValue
let systolicRaw = MeasurementKind.bloodPressureSystolic.rawValue
let diastolicRaw = MeasurementKind.bloodPressureDiastolic.rawValue
let mockRaw = MeasurementSource.mock.rawValue
let watermark = state.measurementWatermarks[watermarkKey] ?? .distantPast

let systolicDescriptor = FetchDescriptor<Measurement>(
predicate: #Predicate { $0.kindRaw == systolicRaw && $0.sourceRaw != mockRaw && $0.createdAt > watermark },
sortBy: [SortDescriptor(\.createdAt, order: .forward)]
)
let systolicRows = (try? context.fetch(systolicDescriptor)) ?? []
guard !systolicRows.isEmpty else { return }

// Index the diastolic halves across the same instant span. Bounded by the batch's own range
// rather than the watermark, so a partner row that was persisted in a different pass — and
// therefore carries a different `createdAt` — is still found.
guard let spanStart = systolicRows.map(\.timestamp).min(),
let spanEnd = systolicRows.map(\.timestamp).max() else { return }
let diastolicDescriptor = FetchDescriptor<Measurement>(
predicate: #Predicate {
$0.kindRaw == diastolicRaw && $0.sourceRaw != mockRaw
&& $0.timestamp >= spanStart && $0.timestamp <= spanEnd
}
)
let diastolicByInstant = Dictionary(
((try? context.fetch(diastolicDescriptor)) ?? []).map { ($0.timestamp, $0.value) },
uniquingKeysWith: { first, _ in first }
)

for chunk in systolicRows.chunked(into: 1000) {
let correlations: [HKCorrelation] = chunk.compactMap { row in
guard row.timestamp <= now, let diastolic = diastolicByInstant[row.timestamp],
HealthKitTypeMappings.isPlausibleBloodPressure(systolic: row.value, diastolic: diastolic)
else { return nil }

let unit = HKUnit.millimeterOfMercury()
let metadata = HealthKitTypeMappings.metadata(
syncID: HealthKitTypeMappings.bloodPressureSyncID(timestamp: row.timestamp), version: 1
)
let objects: Set<HKSample> = [
HKQuantitySample(type: systolicType, quantity: HKQuantity(unit: unit, doubleValue: row.value),
start: row.timestamp, end: row.timestamp, device: device, metadata: nil),
HKQuantitySample(type: diastolicType, quantity: HKQuantity(unit: unit, doubleValue: diastolic),
start: row.timestamp, end: row.timestamp, device: device, metadata: nil),
]
return HKCorrelation(type: correlationType, start: row.timestamp, end: row.timestamp,
objects: objects, device: device, metadata: metadata)
}
if !correlations.isEmpty {
try await save(correlations)
counts.bloodPressure += correlations.count
}
if let maxCreated = chunk.map(\.createdAt).max() {
state.measurementWatermarks[watermarkKey] = maxCreated
prefsStore.syncState = state
}
}
}

// MARK: - Daily activity pass

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

struct SyncCounts {
var vitals = 0
var bloodPressure = 0
var sleepSegments = 0
var dailyTotals = 0
var workouts = 0
Expand All @@ -505,6 +598,7 @@ final class HealthSyncService {
var summary: String {
var parts: [String] = []
if vitals > 0 { parts.append("\(vitals) vitals") }
if bloodPressure > 0 { parts.append("\(bloodPressure) BP reading\(bloodPressure == 1 ? "" : "s")") }
if sleepSegments > 0 { parts.append("\(sleepSegments) sleep segment\(sleepSegments == 1 ? "" : "s")") }
if dailyTotals > 0 { parts.append("\(dailyTotals) daily total\(dailyTotals == 1 ? "" : "s")") }
if workouts > 0 { parts.append("\(workouts) workout\(workouts == 1 ? "" : "s")") }
Expand Down
10 changes: 10 additions & 0 deletions PulseLoop/Services/Repositories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ enum MetricsRepository {
return (try? context.fetch(descriptor)) ?? []
}

/// Whether the store holds any reading of a kind. `fetchLimit: 1` — an existence check, not a
/// count, so it stays cheap enough to call from a settings `body`.
@MainActor
static func hasAnyMeasurement(kind: MeasurementKind, context: ModelContext) -> Bool {
let raw = kind.rawValue
var descriptor = FetchDescriptor<Measurement>(predicate: #Predicate { $0.kindRaw == raw })
descriptor.fetchLimit = 1
return ((try? context.fetch(descriptor)) ?? []).isEmpty == false
}

/// Oldest measurement timestamp across all kinds (for the calibration "Day X of N" counter).
/// `fetchLimit: 1` ascending — one row, not the whole table.
@MainActor
Expand Down
12 changes: 12 additions & 0 deletions PulseLoop/Settings/AppleHealthPrefsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ struct AppleHealthPrefs: Codable, Equatable {
var syncTemperature = true
var syncSleep = true
var syncActivity = true
/// The four ring metrics that reach Health but only exist on some hardware. Default **on** like
/// the rest — the settings screen hides the row entirely on a ring that can't produce the metric,
/// so an enabled-but-unreachable toggle never writes anything.
var syncRespiratoryRate = true
var syncVO2Max = true
var syncBloodSugar = true
/// Exported as an `HKCorrelation` pairing systolic + diastolic, not as two loose quantities.
var syncBloodPressure = true
/// Whether finished workout sessions export as `HKWorkout`s (calories, distance, HR stats, GPS route).
var exportWorkouts = true
/// Whether logged meals export as dietary samples (energy + macros). Only effective when the
Expand All @@ -52,6 +60,10 @@ struct AppleHealthPrefs: Codable, Equatable {
syncTemperature = try c.decodeIfPresent(Bool.self, forKey: .syncTemperature) ?? d.syncTemperature
syncSleep = try c.decodeIfPresent(Bool.self, forKey: .syncSleep) ?? d.syncSleep
syncActivity = try c.decodeIfPresent(Bool.self, forKey: .syncActivity) ?? d.syncActivity
syncRespiratoryRate = try c.decodeIfPresent(Bool.self, forKey: .syncRespiratoryRate) ?? d.syncRespiratoryRate
syncVO2Max = try c.decodeIfPresent(Bool.self, forKey: .syncVO2Max) ?? d.syncVO2Max
syncBloodSugar = try c.decodeIfPresent(Bool.self, forKey: .syncBloodSugar) ?? d.syncBloodSugar
syncBloodPressure = try c.decodeIfPresent(Bool.self, forKey: .syncBloodPressure) ?? d.syncBloodPressure
exportWorkouts = try c.decodeIfPresent(Bool.self, forKey: .exportWorkouts) ?? d.exportWorkouts
syncNutrition = try c.decodeIfPresent(Bool.self, forKey: .syncNutrition) ?? d.syncNutrition
backfillChoice = try c.decodeIfPresent(HealthBackfillChoice.self, forKey: .backfillChoice) ?? d.backfillChoice
Expand Down
33 changes: 32 additions & 1 deletion PulseLoop/Views/Settings/AppleHealthSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
/// can clean up after turning sync off).
struct AppleHealthSettingsView: View {
@Environment(\.modelContext) private var modelContext
@Environment(RingBLEClient.self) private var ble
@State private var service = HealthSyncService.shared
@State private var store = AppleHealthPrefsStore.shared
/// First-enable backfill choice ("all history" vs "new only" vs cancel).
Expand All @@ -21,6 +22,12 @@ struct AppleHealthSettingsView: View {

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

/// What the connected ring can actually produce. Rows for metrics it can't are hidden outright
/// rather than shown-and-inert: a VO₂max toggle on a jring is a promise the hardware can't keep.
private var capabilities: Set<WearableCapability> {
MetricsService.activeCapabilities(context: modelContext, ble: ble)
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
Expand Down Expand Up @@ -96,17 +103,41 @@ struct AppleHealthSettingsView: View {
@ViewBuilder private var dataTypesGroup: some View {
SettingsGroup(
header: "Data types",
footer: "Stress, fatigue, and blood pressure don't have an Apple Health equivalent yet, so they aren't synced."
footer: "Stress and fatigue have no Apple Health equivalent — Health has no type for a device "
+ "wellness score — so they can't be synced. Rows appear only for metrics your ring can produce."
) {
FormToggleRow(title: "Heart rate", isOn: prefBinding(\.syncHeartRate))
FormToggleRow(title: "Blood oxygen", isOn: prefBinding(\.syncSpO2))
FormToggleRow(title: "Heart rate variability", isOn: prefBinding(\.syncHRV))
FormToggleRow(title: "Temperature", isOn: prefBinding(\.syncTemperature))
if shows(.respiratoryRate) {
FormToggleRow(title: "Respiratory rate", isOn: prefBinding(\.syncRespiratoryRate))
}
if shows(.vo2max) {
FormToggleRow(title: "Cardio fitness (VO₂max)", isOn: prefBinding(\.syncVO2Max))
}
if shows(.bloodSugar, capability: .bloodSugar) {
FormToggleRow(title: "Blood glucose", isOn: prefBinding(\.syncBloodSugar))
}
if shows(.bloodPressureSystolic, capability: .bloodPressure) {
FormToggleRow(title: "Blood pressure", isOn: prefBinding(\.syncBloodPressure))
}
FormToggleRow(title: "Sleep", isOn: prefBinding(\.syncSleep))
FormToggleRow(title: "Steps & activity", isOn: prefBinding(\.syncActivity))
}
}

/// Whether to offer a toggle for a ring-dependent metric.
///
/// Shown when the connected ring declares the capability **or** the store already holds a reading
/// of that kind. The second arm matters for two cases the capability alone misses: respiratory
/// rate and VO₂max have no `WearableCapability` of their own (they ride the YCBT history records),
/// and history from a previously-paired ring should stay exportable after switching hardware.
private func shows(_ kind: MeasurementKind, capability: WearableCapability? = nil) -> Bool {
if let capability, capabilities.contains(capability) { return true }
return MetricsRepository.hasAnyMeasurement(kind: kind, context: modelContext)
}

@ViewBuilder private var workoutsGroup: some View {
SettingsGroup(
header: "Workouts",
Expand Down
Loading