From 2ebe2dd867261d5483fa704311e9c76d9a7702d7 Mon Sep 17 00:00:00 2001 From: Abhijay <9abhijay@gmail.com> Date: Tue, 4 Aug 2026 19:42:11 +0530 Subject: [PATCH] Add .appleExerciseTime to healthkit so it sync with Fitness excercise rings --- PulseLoop/Health/HealthKitTypeMappings.swift | 2 +- PulseLoop/Health/HealthSyncService.swift | 26 +++++++++++++++----- PulseLoopTests/HealthSyncTests.swift | 20 +++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index 3221567..e61b715 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -106,7 +106,7 @@ enum HealthKitTypeMappings { "pl-m-\(kindRaw)-\(Int(timestamp.timeIntervalSince1970 * 1000))" } - /// A daily-activity aggregate. `metric` is one of "steps" / "energy" / "dist"; `dayEpoch` is the + /// A daily-activity aggregate. `metric` is one of "steps" / "energy" / "dist" / "exmin"; `dayEpoch` is the /// unix start-of-day, so each day's total replaces the prior export rather than duplicating. static func activitySyncID(metric: String, dayEpoch: Int) -> String { "pl-act-\(metric)-\(dayEpoch)" diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index 37f4583..d45aedc 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -5,7 +5,7 @@ import os /// Exports everything the ring captures into Apple Health, one direction only (PulseLoop → Health): /// • Vitals — heart rate, blood oxygen (SpO₂), HRV, skin temperature -/// • Daily activity — steps, active energy, walking/running distance +/// • Daily activity — steps, active energy, walking/running distance, exercise minutes /// • Sleep — per-stage segments (deep / core / REM / awake) /// • Workouts — type, energy, distance, and the recorded GPS route (see `+Workouts`) /// @@ -60,7 +60,7 @@ final class HealthSyncService { private var quantityWriteTypes: [HKQuantityType] { [ .heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature, - .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling + .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling, .appleExerciseTime ].compactMap { HKQuantityType.quantityType(forIdentifier: $0) } } @@ -251,9 +251,9 @@ final class HealthSyncService { } } - /// One day-spanning sample per enabled type. Native workout kcal/distance are netted out of the day + /// One day-spanning sample per enabled type. Native workout kcal/distance/excercise-minutes are netted out of the day /// total (only when workout export is on) so the Move ring doesn't double-count. - private func activitySamples(row: ActivityDaily, netting: (kcal: [Date: Double], meters: [Date: Double]), + private func activitySamples(row: ActivityDaily, netting: (kcal: [Date: Double], meters: [Date: Double], minutes: [Date: Double]), now: Date, device: HKDevice?) -> [HKQuantitySample] { let cal = Calendar.current let dayStart = cal.startOfDay(for: row.date) @@ -265,6 +265,7 @@ final class HealthSyncService { let version = Int(row.updatedAt.timeIntervalSince1970) let netKcal = prefsStore.prefs.exportWorkouts ? (netting.kcal[dayStart] ?? 0) : 0 let netMeters = prefsStore.prefs.exportWorkouts ? (netting.meters[dayStart] ?? 0) : 0 + let netMinutes = prefsStore.prefs.exportWorkouts ? (netting.minutes[dayStart] ?? 0) : 0 var out: [HKQuantitySample] = [] if let type = HKQuantityType.quantityType(forIdentifier: .stepCount), canShare(type), row.steps > 0 { @@ -280,6 +281,14 @@ final class HealthSyncService { version: version, device: device)) } } + if let type = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime), canShare(type) { + let leftover = Double(row.activeMinutes) - netMinutes + if leftover > 0 { + out.append(quantitySample(type, unit: .minute(), value: leftover, start: dayStart, end: dayEnd, + syncID: HealthKitTypeMappings.activitySyncID(metric: "exmin", dayEpoch: dayEpoch), + version: version, device: device)) + } + } if let type = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), canShare(type) { let leftover = row.distanceMeters - netMeters if leftover > 0 { @@ -299,9 +308,10 @@ final class HealthSyncService { /// maps to walking/running (a cycling ride exports to `.distanceCycling`, a separate HealthKit type /// that never contributes to the walking+running total, so netting it would silently under-count the /// day's real walking distance). - private func workoutNetting(context: ModelContext) -> (kcal: [Date: Double], meters: [Date: Double]) { + private func workoutNetting(context: ModelContext) -> (kcal: [Date: Double], meters: [Date: Double], minutes: [Date: Double]) { var kcal: [Date: Double] = [:] var meters: [Date: Double] = [:] + var minutes: [Date: Double] = [:] let cal = Calendar.current let walkRunID = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)?.identifier let sessions = ActivityRepository.sessions(context: context) @@ -313,8 +323,12 @@ final class HealthSyncService { HealthKitTypeMappings.distanceType(for: session.type)?.identifier == walkRunID { meters[day, default: 0] += m } + if let end = session.endedAt { + let workoutMinutes = Double(max(0, Int(end.timeIntervalSince(session.startedAt) - session.totalPauseSeconds)) / 60) + if workoutMinutes > 0 { minutes[day, default: 0] += workoutMinutes } + } } - return (kcal, meters) + return (kcal, meters, minutes) } private func quantitySample(_ type: HKQuantityType, unit: HKUnit, value: Double, start: Date, end: Date, diff --git a/PulseLoopTests/HealthSyncTests.swift b/PulseLoopTests/HealthSyncTests.swift index 10daa74..fdc25d0 100644 --- a/PulseLoopTests/HealthSyncTests.swift +++ b/PulseLoopTests/HealthSyncTests.swift @@ -158,6 +158,26 @@ final class HealthSyncTests: XCTestCase { ) } + func testExcerciseMinutesSyncIDsDeterminiticAndDistinct() { + let dayEpoch = Int(Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970) + XCTAssertEqual( + HealthKitTypeMappings.activitySyncID(metric: "exmin", dayEpoch: dayEpoch), + HealthKitTypeMappings.activitySyncID(metric: "exmin", dayEpoch: dayEpoch) + ) + XCTAssertNotEqual( + HealthKitTypeMappings.activitySyncID(metric: "exmin", dayEpoch: dayEpoch), + HealthKitTypeMappings.activitySyncID(metric: "steps", dayEpoch: dayEpoch) + ) + XCTAssertNotEqual( + HealthKitTypeMappings.activitySyncID(metric: "exmin", dayEpoch: dayEpoch), + HealthKitTypeMappings.activitySyncID(metric: "energy", dayEpoch: dayEpoch) + ) + XCTAssertNotEqual( + HealthKitTypeMappings.activitySyncID(metric: "exmin", dayEpoch: dayEpoch), + HealthKitTypeMappings.activitySyncID(metric: "dist", dayEpoch: dayEpoch) + ) + } + func testSleepAndWorkoutSyncIDsAreDeterministicPerID() { let blockID = UUID() XCTAssertEqual(HealthKitTypeMappings.sleepBlockSyncID(blockID: blockID), HealthKitTypeMappings.sleepBlockSyncID(blockID: blockID))