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
2 changes: 1 addition & 1 deletion PulseLoop/Health/HealthKitTypeMappings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
26 changes: 21 additions & 5 deletions PulseLoop/Health/HealthSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
///
Expand Down Expand Up @@ -60,6 +60,8 @@ final class HealthSyncService {
private var quantityWriteTypes: [HKQuantityType] {
var identifiers: [HKQuantityTypeIdentifier] = [
.heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature,
.stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling, .appleExerciseTime
].compactMap { HKQuantityType.quantityType(forIdentifier: $0) }
.stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling
]
// Dietary types join the share set only once the nutrition feature is enabled, so users
Expand Down Expand Up @@ -264,9 +266,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)
Expand All @@ -278,6 +280,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 {
Expand All @@ -293,6 +296,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 {
Expand All @@ -312,9 +323,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)
Expand All @@ -326,8 +338,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,
Expand Down
20 changes: 20 additions & 0 deletions PulseLoopTests/HealthSyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down