@interval-health/capacitor-health 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,11 @@ enum HealthDataType: String, CaseIterable {
34
34
  case heartRate
35
35
  case weight
36
36
  case sleep
37
+ case mobility
38
+ case activity
39
+ case heart
40
+ case body
41
+ case workout
37
42
 
38
43
  func sampleType() throws -> HKSampleType {
39
44
  if self == .sleep {
@@ -43,6 +48,43 @@ enum HealthDataType: String, CaseIterable {
43
48
  return type
44
49
  }
45
50
 
51
+ if self == .mobility {
52
+ // Mobility uses multiple types, return walkingSpeed as representative
53
+ guard let type = HKObjectType.quantityType(forIdentifier: .walkingSpeed) else {
54
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
55
+ }
56
+ return type
57
+ }
58
+
59
+ if self == .activity {
60
+ // Activity uses multiple types, return stepCount as representative
61
+ guard let type = HKObjectType.quantityType(forIdentifier: .stepCount) else {
62
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
63
+ }
64
+ return type
65
+ }
66
+
67
+ if self == .heart {
68
+ // Heart uses multiple types, return heartRate as representative
69
+ guard let type = HKObjectType.quantityType(forIdentifier: .heartRate) else {
70
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
71
+ }
72
+ return type
73
+ }
74
+
75
+ if self == .body {
76
+ // Body uses multiple types, return bodyMass as representative
77
+ guard let type = HKObjectType.quantityType(forIdentifier: .bodyMass) else {
78
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
79
+ }
80
+ return type
81
+ }
82
+
83
+ if self == .workout {
84
+ // Workout uses HKWorkoutType
85
+ return HKObjectType.workoutType()
86
+ }
87
+
46
88
  let identifier: HKQuantityTypeIdentifier
47
89
  switch self {
48
90
  case .steps:
@@ -57,6 +99,16 @@ enum HealthDataType: String, CaseIterable {
57
99
  identifier = .bodyMass
58
100
  case .sleep:
59
101
  fatalError("Sleep should have been handled above")
102
+ case .mobility:
103
+ fatalError("Mobility should have been handled above")
104
+ case .activity:
105
+ fatalError("Activity should have been handled above")
106
+ case .heart:
107
+ fatalError("Heart should have been handled above")
108
+ case .body:
109
+ fatalError("Body should have been handled above")
110
+ case .workout:
111
+ fatalError("Workout should have been handled above")
60
112
  }
61
113
 
62
114
  guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
@@ -79,6 +131,16 @@ enum HealthDataType: String, CaseIterable {
79
131
  return HKUnit.gramUnit(with: .kilo)
80
132
  case .sleep:
81
133
  return HKUnit.minute() // Sleep duration in minutes
134
+ case .mobility:
135
+ return HKUnit.meter() // Placeholder, mobility has multiple units
136
+ case .activity:
137
+ return HKUnit.count() // Placeholder, activity has multiple units
138
+ case .heart:
139
+ return HKUnit.count().unitDivided(by: HKUnit.minute()) // Placeholder, heart has multiple units
140
+ case .body:
141
+ return HKUnit.gramUnit(with: .kilo) // Placeholder, body has multiple units
142
+ case .workout:
143
+ return HKUnit.minute() // Workout duration in minutes
82
144
  }
83
145
  }
84
146
 
@@ -96,6 +158,16 @@ enum HealthDataType: String, CaseIterable {
96
158
  return "kilogram"
97
159
  case .sleep:
98
160
  return "minute"
161
+ case .mobility:
162
+ return "mixed" // Mobility has multiple units
163
+ case .activity:
164
+ return "mixed" // Activity has multiple units
165
+ case .heart:
166
+ return "mixed" // Heart has multiple units
167
+ case .body:
168
+ return "mixed" // Body has multiple units
169
+ case .workout:
170
+ return "minute" // Workout duration in minutes
99
171
  }
100
172
  }
101
173
 
@@ -200,8 +272,7 @@ final class Health {
200
272
 
201
273
  func readSamples(dataTypeIdentifier: String, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) throws {
202
274
  let dataType = try parseDataType(identifier: dataTypeIdentifier)
203
- let sampleType = try dataType.sampleType()
204
-
275
+
205
276
  let startDate = try parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))
206
277
  let endDate = try parseDate(endDateString, defaultValue: Date())
207
278
 
@@ -209,6 +280,77 @@ final class Health {
209
280
  throw HealthManagerError.invalidDateRange
210
281
  }
211
282
 
283
+ // Handle body data (multiple quantity types)
284
+ // Skip the initial query and go directly to processing to avoid authorization issues
285
+ if dataType == .body {
286
+ processBodyData(startDate: startDate, endDate: endDate) { result in
287
+ switch result {
288
+ case .success(let bodyData):
289
+ completion(.success(bodyData))
290
+ case .failure(let error):
291
+ completion(.failure(error))
292
+ }
293
+ }
294
+ return
295
+ }
296
+
297
+ // Handle heart data (multiple quantity types)
298
+ // Skip the initial query and go directly to processing to avoid authorization issues
299
+ if dataType == .heart {
300
+ processHeartData(startDate: startDate, endDate: endDate) { result in
301
+ switch result {
302
+ case .success(let heartData):
303
+ completion(.success(heartData))
304
+ case .failure(let error):
305
+ completion(.failure(error))
306
+ }
307
+ }
308
+ return
309
+ }
310
+
311
+ // Handle activity data (multiple quantity and category types)
312
+ // Skip the initial query and go directly to processing to avoid authorization issues
313
+ if dataType == .activity {
314
+ processActivityData(startDate: startDate, endDate: endDate) { result in
315
+ switch result {
316
+ case .success(let activityData):
317
+ completion(.success(activityData))
318
+ case .failure(let error):
319
+ completion(.failure(error))
320
+ }
321
+ }
322
+ return
323
+ }
324
+
325
+ // Handle mobility data (multiple quantity types)
326
+ // Skip the initial query and go directly to processing to avoid authorization issues
327
+ if dataType == .mobility {
328
+ processMobilityData(startDate: startDate, endDate: endDate) { result in
329
+ switch result {
330
+ case .success(let mobilityData):
331
+ completion(.success(mobilityData))
332
+ case .failure(let error):
333
+ completion(.failure(error))
334
+ }
335
+ }
336
+ return
337
+ }
338
+
339
+ // Handle workout data
340
+ if dataType == .workout {
341
+ processWorkoutData(startDate: startDate, endDate: endDate, limit: limit, ascending: ascending) { result in
342
+ switch result {
343
+ case .success(let workoutData):
344
+ completion(.success(workoutData))
345
+ case .failure(let error):
346
+ completion(.failure(error))
347
+ }
348
+ }
349
+ return
350
+ }
351
+
352
+ // For all other data types, use the standard query approach
353
+ let sampleType = try dataType.sampleType()
212
354
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
213
355
  let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
214
356
  let queryLimit = limit ?? 100
@@ -225,7 +367,7 @@ final class Health {
225
367
  completion(.success([]))
226
368
  return
227
369
  }
228
-
370
+
229
371
  // Handle sleep data (category samples)
230
372
  if dataType == .sleep {
231
373
  guard let categorySamples = samples as? [HKCategorySample] else {
@@ -233,27 +375,9 @@ final class Health {
233
375
  return
234
376
  }
235
377
 
236
- let results = categorySamples.map { sample -> [String: Any] in
237
- let duration = sample.endDate.timeIntervalSince(sample.startDate) / 60.0 // in minutes
238
- let sleepValue = self.sleepValueString(for: sample.value)
239
-
240
- var payload: [String: Any] = [
241
- "dataType": dataType.rawValue,
242
- "value": duration,
243
- "unit": dataType.unitIdentifier,
244
- "sleepState": sleepValue,
245
- "startDate": self.isoFormatter.string(from: sample.startDate),
246
- "endDate": self.isoFormatter.string(from: sample.endDate)
247
- ]
248
-
249
- let source = sample.sourceRevision.source
250
- payload["sourceName"] = source.name
251
- payload["sourceId"] = source.bundleIdentifier
252
-
253
- return payload
254
- }
255
-
256
- completion(.success(results))
378
+ // Process sleep data similar to client-side parser
379
+ let processedSleepData = self.processSleepSamples(categorySamples)
380
+ completion(.success(processedSleepData))
257
381
  return
258
382
  }
259
383
 
@@ -363,18 +487,110 @@ final class Health {
363
487
  var denied: [HealthDataType] = []
364
488
 
365
489
  for type in types {
366
- guard let sampleType = try? type.sampleType() else {
367
- denied.append(type)
368
- continue
490
+ // Special handling for activity - check all activity types
491
+ // Consider authorized if at least one activity type is authorized
492
+ if type == .activity {
493
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
494
+ .stepCount,
495
+ .distanceWalkingRunning,
496
+ .flightsClimbed,
497
+ .activeEnergyBurned,
498
+ .appleExerciseTime
499
+ ]
500
+
501
+ var hasAnyAuthorized = false
502
+ for identifier in activityIdentifiers {
503
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
504
+ let status = healthStore.authorizationStatus(for: quantityType)
505
+ if status == .sharingAuthorized {
506
+ hasAnyAuthorized = true
507
+ break
508
+ }
509
+ }
510
+ }
511
+
512
+ // Also check stand hour (category type)
513
+ if !hasAnyAuthorized {
514
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
515
+ let status = healthStore.authorizationStatus(for: standHourType)
516
+ if status == .sharingAuthorized {
517
+ hasAnyAuthorized = true
518
+ }
519
+ }
520
+ }
521
+
522
+ if hasAnyAuthorized {
523
+ authorized.append(type)
524
+ } else {
525
+ denied.append(type)
526
+ }
527
+ }
528
+ // Special handling for body - check body composition types
529
+ // Consider authorized if at least one body type is authorized
530
+ else if type == .body {
531
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
532
+ .bodyMass,
533
+ .bodyFatPercentage
534
+ ]
535
+
536
+ var hasAnyAuthorized = false
537
+ for identifier in bodyIdentifiers {
538
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
539
+ let status = healthStore.authorizationStatus(for: quantityType)
540
+ if status == .sharingAuthorized {
541
+ hasAnyAuthorized = true
542
+ break
543
+ }
544
+ }
545
+ }
546
+
547
+ if hasAnyAuthorized {
548
+ authorized.append(type)
549
+ } else {
550
+ denied.append(type)
551
+ }
369
552
  }
553
+ // Special handling for mobility - check all 6 types
554
+ else if type == .mobility {
555
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
556
+ .walkingSpeed,
557
+ .walkingStepLength,
558
+ .walkingAsymmetryPercentage,
559
+ .walkingDoubleSupportPercentage,
560
+ .stairAscentSpeed,
561
+ .sixMinuteWalkTestDistance
562
+ ]
563
+
564
+ var allAuthorized = true
565
+ for identifier in mobilityIdentifiers {
566
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
567
+ let status = healthStore.authorizationStatus(for: quantityType)
568
+ if status != .sharingAuthorized {
569
+ allAuthorized = false
570
+ break
571
+ }
572
+ }
573
+ }
574
+
575
+ if allAuthorized {
576
+ authorized.append(type)
577
+ } else {
578
+ denied.append(type)
579
+ }
580
+ } else {
581
+ guard let sampleType = try? type.sampleType() else {
582
+ denied.append(type)
583
+ continue
584
+ }
370
585
 
371
- switch healthStore.authorizationStatus(for: sampleType) {
372
- case .sharingAuthorized:
373
- authorized.append(type)
374
- case .sharingDenied, .notDetermined:
375
- denied.append(type)
376
- @unknown default:
377
- denied.append(type)
586
+ switch healthStore.authorizationStatus(for: sampleType) {
587
+ case .sharingAuthorized:
588
+ authorized.append(type)
589
+ case .sharingDenied, .notDetermined:
590
+ denied.append(type)
591
+ @unknown default:
592
+ denied.append(type)
593
+ }
378
594
  }
379
595
  }
380
596
 
@@ -394,28 +610,174 @@ final class Health {
394
610
  var denied: [HealthDataType] = []
395
611
 
396
612
  for type in types {
397
- guard let objectType = try? type.sampleType() else {
398
- denied.append(type)
399
- continue
613
+ // Special handling for activity - check all activity types
614
+ if type == .activity {
615
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
616
+ .stepCount,
617
+ .distanceWalkingRunning,
618
+ .flightsClimbed,
619
+ .activeEnergyBurned,
620
+ .appleExerciseTime
621
+ ]
622
+
623
+ // Check all 5 quantity types + 1 category type
624
+ var activityAuthorizedCount = 0
625
+ let totalActivityTypes = activityIdentifiers.count + 1 // +1 for stand hour
626
+
627
+ // Create a nested group for activity checks
628
+ let activityGroup = DispatchGroup()
629
+
630
+ for identifier in activityIdentifiers {
631
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
632
+ activityGroup.enter()
633
+ let readSet = Set<HKObjectType>([quantityType])
634
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
635
+ defer { activityGroup.leave() }
636
+
637
+ if error == nil && status == .unnecessary {
638
+ lock.lock()
639
+ activityAuthorizedCount += 1
640
+ lock.unlock()
641
+ }
642
+ }
643
+ }
644
+ }
645
+
646
+ // Check stand hour (category type)
647
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
648
+ activityGroup.enter()
649
+ let readSet = Set<HKObjectType>([standHourType])
650
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
651
+ defer { activityGroup.leave() }
652
+
653
+ if error == nil && status == .unnecessary {
654
+ lock.lock()
655
+ activityAuthorizedCount += 1
656
+ lock.unlock()
657
+ }
658
+ }
659
+ }
660
+
661
+ // Wait for all activity checks, then determine if activity is authorized
662
+ // Consider authorized if at least one activity type is authorized
663
+ activityGroup.notify(queue: .main) {
664
+ lock.lock()
665
+ if activityAuthorizedCount > 0 {
666
+ authorized.append(type)
667
+ } else {
668
+ denied.append(type)
669
+ }
670
+ lock.unlock()
671
+ }
400
672
  }
401
-
402
- group.enter()
403
- let readSet = Set<HKObjectType>([objectType])
404
- healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
405
- defer { group.leave() }
406
-
407
- if error != nil {
408
- lock.lock(); denied.append(type); lock.unlock()
409
- return
673
+ // Special handling for body - check body composition types
674
+ else if type == .body {
675
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
676
+ .bodyMass,
677
+ .bodyFatPercentage
678
+ ]
679
+
680
+ // Check all body types
681
+ var bodyAuthorizedCount = 0
682
+
683
+ // Create a nested group for body checks
684
+ let bodyGroup = DispatchGroup()
685
+
686
+ for identifier in bodyIdentifiers {
687
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
688
+ bodyGroup.enter()
689
+ let readSet = Set<HKObjectType>([quantityType])
690
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
691
+ defer { bodyGroup.leave() }
692
+
693
+ if error == nil && status == .unnecessary {
694
+ lock.lock()
695
+ bodyAuthorizedCount += 1
696
+ lock.unlock()
697
+ }
698
+ }
699
+ }
700
+ }
701
+
702
+ // Wait for all body checks, then determine if body is authorized
703
+ // Consider authorized if at least one body type is authorized
704
+ bodyGroup.notify(queue: .main) {
705
+ lock.lock()
706
+ if bodyAuthorizedCount > 0 {
707
+ authorized.append(type)
708
+ } else {
709
+ denied.append(type)
710
+ }
711
+ lock.unlock()
712
+ }
713
+ }
714
+ // Special handling for mobility - check all 6 types
715
+ else if type == .mobility {
716
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
717
+ .walkingSpeed,
718
+ .walkingStepLength,
719
+ .walkingAsymmetryPercentage,
720
+ .walkingDoubleSupportPercentage,
721
+ .stairAscentSpeed,
722
+ .sixMinuteWalkTestDistance
723
+ ]
724
+
725
+ // Check all 6 mobility types
726
+ var mobilityAuthorizedCount = 0
727
+
728
+ // Create a nested group for mobility checks
729
+ let mobilityGroup = DispatchGroup()
730
+
731
+ for identifier in mobilityIdentifiers {
732
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
733
+ mobilityGroup.enter()
734
+ let readSet = Set<HKObjectType>([quantityType])
735
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
736
+ defer { mobilityGroup.leave() }
737
+
738
+ if error == nil && status == .unnecessary {
739
+ lock.lock()
740
+ mobilityAuthorizedCount += 1
741
+ lock.unlock()
742
+ }
743
+ }
744
+ }
745
+ }
746
+
747
+ // Wait for all mobility checks, then determine if mobility is authorized
748
+ mobilityGroup.notify(queue: .main) {
749
+ lock.lock()
750
+ if mobilityAuthorizedCount == mobilityIdentifiers.count {
751
+ authorized.append(type)
752
+ } else {
753
+ denied.append(type)
754
+ }
755
+ lock.unlock()
756
+ }
757
+ } else {
758
+ guard let objectType = try? type.sampleType() else {
759
+ denied.append(type)
760
+ continue
410
761
  }
411
762
 
412
- switch status {
413
- case .unnecessary:
414
- lock.lock(); authorized.append(type); lock.unlock()
415
- case .shouldRequest, .unknown:
416
- lock.lock(); denied.append(type); lock.unlock()
417
- @unknown default:
418
- lock.lock(); denied.append(type); lock.unlock()
763
+ group.enter()
764
+ let readSet = Set<HKObjectType>([objectType])
765
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
766
+ defer { group.leave() }
767
+
768
+ if error != nil {
769
+ lock.lock(); denied.append(type); lock.unlock()
770
+ return
771
+ }
772
+
773
+ switch status {
774
+ case .unnecessary:
775
+ lock.lock(); authorized.append(type); lock.unlock()
776
+ case .shouldRequest, .unknown:
777
+ lock.lock(); denied.append(type); lock.unlock()
778
+ @unknown default:
779
+ lock.lock(); denied.append(type); lock.unlock()
780
+ }
419
781
  }
420
782
  }
421
783
  }
@@ -478,6 +840,225 @@ final class Health {
478
840
  }
479
841
  }
480
842
  }
843
+
844
+ private func processSleepSamples(_ samples: [HKCategorySample]) -> [[String: Any]] {
845
+ // Filter for detailed stage data only (Deep, REM, Core, Awake)
846
+ let detailedSamples = samples.filter { sample in
847
+ if #available(iOS 16.0, *) {
848
+ return sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
849
+ sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
850
+ sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
851
+ sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
852
+ } else {
853
+ // For older iOS, just process what's available
854
+ return true
855
+ }
856
+ }
857
+
858
+ // Sort by start date
859
+ let sortedSamples = detailedSamples.sorted { $0.startDate < $1.startDate }
860
+
861
+ // Collect ALL stage segments (raw data) - matching TypeScript structure
862
+ struct SleepSegment {
863
+ let start: Date
864
+ let end: Date
865
+ let stage: String
866
+ }
867
+
868
+ var allSegments: [SleepSegment] = []
869
+
870
+ for sample in sortedSamples {
871
+ let stageValue = getHealthKitStageConstantName(for: sample.value)
872
+ allSegments.append(SleepSegment(
873
+ start: sample.startDate,
874
+ end: sample.endDate,
875
+ stage: stageValue
876
+ ))
877
+ }
878
+
879
+ // Group segments by SLEEP SESSION (not by day!)
880
+ // Sessions are separated by 30+ minute gaps
881
+ struct SleepSession {
882
+ let start: Date
883
+ let end: Date
884
+ let segments: [SleepSegment]
885
+ }
886
+
887
+ var sessions: [SleepSession] = []
888
+ var currentSession: [SleepSegment] = []
889
+
890
+ for segment in allSegments {
891
+ if currentSession.isEmpty {
892
+ currentSession.append(segment)
893
+ } else {
894
+ let lastSegment = currentSession.last!
895
+ let gapMinutes = segment.start.timeIntervalSince(lastSegment.end) / 60.0
896
+
897
+ if gapMinutes < 30 {
898
+ // Same session
899
+ currentSession.append(segment)
900
+ } else {
901
+ // New session (gap > 30 min)
902
+ if !currentSession.isEmpty {
903
+ sessions.append(SleepSession(
904
+ start: currentSession.first!.start,
905
+ end: currentSession.last!.end,
906
+ segments: currentSession
907
+ ))
908
+ }
909
+ currentSession = [segment]
910
+ }
911
+ }
912
+ }
913
+
914
+ // Don't forget last session
915
+ if !currentSession.isEmpty {
916
+ sessions.append(SleepSession(
917
+ start: currentSession.first!.start,
918
+ end: currentSession.last!.end,
919
+ segments: currentSession
920
+ ))
921
+ }
922
+
923
+ // Now attribute each session to the day you WOKE UP (end date)
924
+ struct DayData {
925
+ var sessions: [SleepSession]
926
+ var deepMinutes: Double
927
+ var remMinutes: Double
928
+ var coreMinutes: Double
929
+ var awakeMinutes: Double
930
+ }
931
+
932
+ var sleepByDate: [String: DayData] = [:]
933
+
934
+ for session in sessions {
935
+ // Wake-up date (local)
936
+ let calendar = Calendar.current
937
+ let wakeDate = calendar.dateComponents([.year, .month, .day], from: session.end)
938
+ let dateString = String(format: "%04d-%02d-%02d", wakeDate.year!, wakeDate.month!, wakeDate.day!)
939
+
940
+ // Initialize day data if needed
941
+ if sleepByDate[dateString] == nil {
942
+ sleepByDate[dateString] = DayData(
943
+ sessions: [],
944
+ deepMinutes: 0,
945
+ remMinutes: 0,
946
+ coreMinutes: 0,
947
+ awakeMinutes: 0
948
+ )
949
+ }
950
+
951
+ sleepByDate[dateString]!.sessions.append(session)
952
+
953
+ // Calculate minutes per stage for THIS session
954
+ for segment in session.segments {
955
+ let minutes = segment.end.timeIntervalSince(segment.start) / 60.0
956
+
957
+ if segment.stage == "HKCategoryValueSleepAnalysisAsleepDeep" {
958
+ sleepByDate[dateString]!.deepMinutes += minutes
959
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAsleepREM" {
960
+ sleepByDate[dateString]!.remMinutes += minutes
961
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAsleepCore" {
962
+ sleepByDate[dateString]!.coreMinutes += minutes
963
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAwake" {
964
+ sleepByDate[dateString]!.awakeMinutes += minutes
965
+ }
966
+ }
967
+ }
968
+
969
+ // Convert to final format
970
+ var sleepData: [[String: Any]] = []
971
+
972
+ for (date, data) in sleepByDate {
973
+ let deepHours = data.deepMinutes / 60.0
974
+ let remHours = data.remMinutes / 60.0
975
+ let coreHours = data.coreMinutes / 60.0
976
+ let awakeHours = data.awakeMinutes / 60.0
977
+ let totalSleepHours = deepHours + remHours + coreHours
978
+
979
+ // Calculate time in bed from merged sessions (first start to last end)
980
+ var timeInBed = 0.0
981
+ if !data.sessions.isEmpty {
982
+ let bedtime = data.sessions.first!.start
983
+ let wakeTime = data.sessions.last!.end
984
+ timeInBed = wakeTime.timeIntervalSince(bedtime) / 3600.0
985
+ }
986
+
987
+ // Calculate efficiency
988
+ let efficiency = timeInBed > 0 ? Int(round((totalSleepHours / timeInBed) * 100)) : 0
989
+
990
+ // Map sessions to output format with segments matching TypeScript structure
991
+ let mergedSessions: [[String: Any]] = data.sessions.map { session in
992
+ let segments: [[String: Any]] = session.segments.map { segment in
993
+ return [
994
+ "start": isoFormatter.string(from: segment.start),
995
+ "end": isoFormatter.string(from: segment.end),
996
+ "stage": segment.stage
997
+ ]
998
+ }
999
+
1000
+ return [
1001
+ "start": isoFormatter.string(from: session.start),
1002
+ "end": isoFormatter.string(from: session.end),
1003
+ "segments": segments
1004
+ ]
1005
+ }
1006
+
1007
+ sleepData.append([
1008
+ "date": date,
1009
+ "totalSleepHours": round(totalSleepHours * 10) / 10,
1010
+ "sleepSessions": data.sessions.count,
1011
+ "deepSleep": round(deepHours * 10) / 10,
1012
+ "remSleep": round(remHours * 10) / 10,
1013
+ "coreSleep": round(coreHours * 10) / 10,
1014
+ "awakeTime": round(awakeHours * 10) / 10,
1015
+ "timeInBed": round(timeInBed * 10) / 10,
1016
+ "efficiency": efficiency,
1017
+ "mergedSessions": mergedSessions
1018
+ ])
1019
+ }
1020
+
1021
+ // Sort by date
1022
+ sleepData.sort { (a, b) -> Bool in
1023
+ let dateA = a["date"] as! String
1024
+ let dateB = b["date"] as! String
1025
+ return dateA < dateB
1026
+ }
1027
+
1028
+ return sleepData
1029
+ }
1030
+
1031
+ private func getHealthKitStageConstantName(for value: Int) -> String {
1032
+ if #available(iOS 16.0, *) {
1033
+ switch value {
1034
+ case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
1035
+ return "HKCategoryValueSleepAnalysisAsleepDeep"
1036
+ case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
1037
+ return "HKCategoryValueSleepAnalysisAsleepREM"
1038
+ case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
1039
+ return "HKCategoryValueSleepAnalysisAsleepCore"
1040
+ case HKCategoryValueSleepAnalysis.awake.rawValue:
1041
+ return "HKCategoryValueSleepAnalysisAwake"
1042
+ case HKCategoryValueSleepAnalysis.inBed.rawValue:
1043
+ return "HKCategoryValueSleepAnalysisInBed"
1044
+ case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
1045
+ return "HKCategoryValueSleepAnalysisAsleepUnspecified"
1046
+ default:
1047
+ return "HKCategoryValueSleepAnalysisUnknown"
1048
+ }
1049
+ } else {
1050
+ switch value {
1051
+ case HKCategoryValueSleepAnalysis.inBed.rawValue:
1052
+ return "HKCategoryValueSleepAnalysisInBed"
1053
+ case HKCategoryValueSleepAnalysis.asleep.rawValue:
1054
+ return "HKCategoryValueSleepAnalysisAsleep"
1055
+ case HKCategoryValueSleepAnalysis.awake.rawValue:
1056
+ return "HKCategoryValueSleepAnalysisAwake"
1057
+ default:
1058
+ return "HKCategoryValueSleepAnalysisUnknown"
1059
+ }
1060
+ }
1061
+ }
481
1062
 
482
1063
  private func unit(for identifier: String?, dataType: HealthDataType) -> HKUnit {
483
1064
  guard let identifier = identifier else {
@@ -505,8 +1086,77 @@ final class Health {
505
1086
  private func objectTypes(for dataTypes: [HealthDataType]) throws -> Set<HKObjectType> {
506
1087
  var set = Set<HKObjectType>()
507
1088
  for dataType in dataTypes {
508
- let type = try dataType.sampleType()
509
- set.insert(type)
1089
+ // Special handling for activity - expand into all activity-related HealthKit types
1090
+ if dataType == .activity {
1091
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
1092
+ .stepCount,
1093
+ .distanceWalkingRunning,
1094
+ .flightsClimbed,
1095
+ .activeEnergyBurned,
1096
+ .appleExerciseTime
1097
+ ]
1098
+
1099
+ for identifier in activityIdentifiers {
1100
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1101
+ set.insert(quantityType)
1102
+ }
1103
+ }
1104
+
1105
+ // Add category type for stand hours
1106
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1107
+ set.insert(standHourType)
1108
+ }
1109
+ }
1110
+ // Special handling for heart - expand into all 6 HealthKit types
1111
+ else if dataType == .heart {
1112
+ let heartIdentifiers: [HKQuantityTypeIdentifier] = [
1113
+ .heartRate,
1114
+ .restingHeartRate,
1115
+ .vo2Max,
1116
+ .heartRateVariabilitySDNN,
1117
+ .oxygenSaturation,
1118
+ .respiratoryRate
1119
+ ]
1120
+
1121
+ for identifier in heartIdentifiers {
1122
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1123
+ set.insert(quantityType)
1124
+ }
1125
+ }
1126
+ }
1127
+ // Special handling for body - expand into body composition types
1128
+ else if dataType == .body {
1129
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
1130
+ .bodyMass,
1131
+ .bodyFatPercentage
1132
+ ]
1133
+
1134
+ for identifier in bodyIdentifiers {
1135
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1136
+ set.insert(quantityType)
1137
+ }
1138
+ }
1139
+ }
1140
+ // Special handling for mobility - expand into all 6 HealthKit types
1141
+ else if dataType == .mobility {
1142
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
1143
+ .walkingSpeed,
1144
+ .walkingStepLength,
1145
+ .walkingAsymmetryPercentage,
1146
+ .walkingDoubleSupportPercentage,
1147
+ .stairAscentSpeed,
1148
+ .sixMinuteWalkTestDistance
1149
+ ]
1150
+
1151
+ for identifier in mobilityIdentifiers {
1152
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1153
+ set.insert(quantityType)
1154
+ }
1155
+ }
1156
+ } else {
1157
+ let type = try dataType.sampleType()
1158
+ set.insert(type)
1159
+ }
510
1160
  }
511
1161
  return set
512
1162
  }
@@ -514,9 +1164,1223 @@ final class Health {
514
1164
  private func sampleTypes(for dataTypes: [HealthDataType]) throws -> Set<HKSampleType> {
515
1165
  var set = Set<HKSampleType>()
516
1166
  for dataType in dataTypes {
517
- let type = try dataType.sampleType() as HKSampleType
518
- set.insert(type)
1167
+ // Special handling for activity - expand into all activity-related HealthKit types
1168
+ if dataType == .activity {
1169
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
1170
+ .stepCount,
1171
+ .distanceWalkingRunning,
1172
+ .flightsClimbed,
1173
+ .activeEnergyBurned,
1174
+ .appleExerciseTime
1175
+ ]
1176
+
1177
+ for identifier in activityIdentifiers {
1178
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1179
+ set.insert(quantityType)
1180
+ }
1181
+ }
1182
+
1183
+ // Add category type for stand hours
1184
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1185
+ set.insert(standHourType)
1186
+ }
1187
+ }
1188
+ // Special handling for heart - expand into all 6 HealthKit types
1189
+ else if dataType == .heart {
1190
+ let heartIdentifiers: [HKQuantityTypeIdentifier] = [
1191
+ .heartRate,
1192
+ .restingHeartRate,
1193
+ .vo2Max,
1194
+ .heartRateVariabilitySDNN,
1195
+ .oxygenSaturation,
1196
+ .respiratoryRate
1197
+ ]
1198
+
1199
+ for identifier in heartIdentifiers {
1200
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1201
+ set.insert(quantityType)
1202
+ }
1203
+ }
1204
+ }
1205
+ // Special handling for body - expand into body composition types
1206
+ else if dataType == .body {
1207
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
1208
+ .bodyMass,
1209
+ .bodyFatPercentage
1210
+ ]
1211
+
1212
+ for identifier in bodyIdentifiers {
1213
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1214
+ set.insert(quantityType)
1215
+ }
1216
+ }
1217
+ }
1218
+ // Special handling for mobility - expand into all 6 HealthKit types
1219
+ else if dataType == .mobility {
1220
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
1221
+ .walkingSpeed,
1222
+ .walkingStepLength,
1223
+ .walkingAsymmetryPercentage,
1224
+ .walkingDoubleSupportPercentage,
1225
+ .stairAscentSpeed,
1226
+ .sixMinuteWalkTestDistance
1227
+ ]
1228
+
1229
+ for identifier in mobilityIdentifiers {
1230
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1231
+ set.insert(quantityType)
1232
+ }
1233
+ }
1234
+ } else {
1235
+ let type = try dataType.sampleType() as HKSampleType
1236
+ set.insert(type)
1237
+ }
519
1238
  }
520
1239
  return set
521
1240
  }
1241
+
1242
+ // MARK: - Body Data Processing
1243
+
1244
+ private func processBodyData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1245
+ let calendar = Calendar.current
1246
+
1247
+ // Define body composition quantity types
1248
+ let bodyTypes: [(HKQuantityTypeIdentifier, String)] = [
1249
+ (.bodyMass, "bodyMass"),
1250
+ (.bodyFatPercentage, "bodyFatPercentage")
1251
+ ]
1252
+
1253
+ // Get all dates in the range
1254
+ var dates: [Date] = []
1255
+ var currentDate = calendar.startOfDay(for: startDate)
1256
+ let endOfDay = calendar.startOfDay(for: endDate)
1257
+
1258
+ while currentDate <= endOfDay {
1259
+ dates.append(currentDate)
1260
+ currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
1261
+ }
1262
+
1263
+ // Dictionary to store body data by date
1264
+ var bodyDataByDate: [String: [String: Any]] = [:]
1265
+
1266
+ // Initialize dates with empty dictionaries
1267
+ let dateFormatter = ISO8601DateFormatter()
1268
+ dateFormatter.formatOptions = [.withFullDate]
1269
+
1270
+ for date in dates {
1271
+ let dateStr = dateFormatter.string(from: date)
1272
+ bodyDataByDate[dateStr] = ["date": dateStr]
1273
+ }
1274
+
1275
+ let group = DispatchGroup()
1276
+
1277
+ // Query each body type
1278
+ for (identifier, _) in bodyTypes {
1279
+ guard let quantityType = HKObjectType.quantityType(forIdentifier: identifier) else {
1280
+ continue
1281
+ }
1282
+
1283
+ group.enter()
1284
+
1285
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1286
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
1287
+
1288
+ let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
1289
+ defer { group.leave() }
1290
+
1291
+ // Don't fail the entire request if one body type fails
1292
+ // Just skip this type and continue with others
1293
+ if error != nil {
1294
+ return
1295
+ }
1296
+
1297
+ guard let samples = samples as? [HKQuantitySample] else {
1298
+ return
1299
+ }
1300
+
1301
+ // Group samples by date and take the last measurement of each day
1302
+ var samplesByDate: [String: HKQuantitySample] = [:]
1303
+
1304
+ for sample in samples {
1305
+ let sampleDate = calendar.startOfDay(for: sample.startDate)
1306
+ let dateStr = dateFormatter.string(from: sampleDate)
1307
+
1308
+ // Keep the last (most recent) sample for each date
1309
+ if let existingSample = samplesByDate[dateStr] {
1310
+ if sample.startDate > existingSample.startDate {
1311
+ samplesByDate[dateStr] = sample
1312
+ }
1313
+ } else {
1314
+ samplesByDate[dateStr] = sample
1315
+ }
1316
+ }
1317
+
1318
+ // Process the last measurement for each date
1319
+ for (dateStr, sample) in samplesByDate {
1320
+ var dayData = bodyDataByDate[dateStr] ?? ["date": dateStr]
1321
+
1322
+ switch identifier {
1323
+ case .bodyMass:
1324
+ // Convert kg to lbs: kg * 2.20462
1325
+ let valueInKg = sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo))
1326
+ let valueInLbs = valueInKg * 2.20462
1327
+ dayData["weight"] = round(valueInLbs * 10) / 10 // 1 decimal place
1328
+
1329
+ case .bodyFatPercentage:
1330
+ // Convert decimal to percentage: 0.25 -> 25%
1331
+ let valueInDecimal = sample.quantity.doubleValue(for: HKUnit.percent())
1332
+ dayData["bodyFat"] = round(valueInDecimal * 10) / 10 // 1 decimal place
1333
+
1334
+ default:
1335
+ break
1336
+ }
1337
+
1338
+ bodyDataByDate[dateStr] = dayData
1339
+ }
1340
+ }
1341
+
1342
+ healthStore.execute(query)
1343
+ }
1344
+
1345
+ group.notify(queue: .main) {
1346
+ // Convert to array and sort by date
1347
+ let sortedData = bodyDataByDate.values
1348
+ .sorted { dict1, dict2 in
1349
+ guard let date1Str = dict1["date"] as? String,
1350
+ let date2Str = dict2["date"] as? String else {
1351
+ return false
1352
+ }
1353
+ return date1Str < date2Str
1354
+ }
1355
+ .filter { dict in
1356
+ // Only include dates that have at least one body measurement
1357
+ return dict.count > 1 // More than just the "date" field
1358
+ }
1359
+
1360
+ completion(.success(sortedData))
1361
+ }
1362
+ }
1363
+
1364
+ // MARK: - Mobility Data Processing
1365
+
1366
+ private func processMobilityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1367
+ let calendar = Calendar.current
1368
+
1369
+ // Define all mobility quantity types
1370
+ let mobilityTypes: [(HKQuantityTypeIdentifier, String)] = [
1371
+ (.walkingSpeed, "walkingSpeed"),
1372
+ (.walkingStepLength, "walkingStepLength"),
1373
+ (.walkingAsymmetryPercentage, "walkingAsymmetry"),
1374
+ (.walkingDoubleSupportPercentage, "walkingDoubleSupportTime"),
1375
+ (.stairAscentSpeed, "stairSpeed"),
1376
+ (.sixMinuteWalkTestDistance, "sixMinuteWalkDistance")
1377
+ ]
1378
+
1379
+ let group = DispatchGroup()
1380
+ let lock = NSLock()
1381
+
1382
+ // Maps to store values by date for each metric
1383
+ var walkingSpeedMap: [String: [Double]] = [:]
1384
+ var stepLengthMap: [String: [Double]] = [:]
1385
+ var asymmetryMap: [String: [Double]] = [:]
1386
+ var doubleSupportMap: [String: [Double]] = [:]
1387
+ var stairSpeedMap: [String: [Double]] = [:]
1388
+ var sixMinWalkMap: [String: [Double]] = [:]
1389
+
1390
+ var errors: [Error] = []
1391
+
1392
+ // Query each mobility metric
1393
+ for (typeIdentifier, key) in mobilityTypes {
1394
+ guard let quantityType = HKObjectType.quantityType(forIdentifier: typeIdentifier) else {
1395
+ continue
1396
+ }
1397
+
1398
+ group.enter()
1399
+
1400
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1401
+ let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1402
+ defer { group.leave() }
1403
+
1404
+ if let error = error {
1405
+ lock.lock()
1406
+ errors.append(error)
1407
+ lock.unlock()
1408
+ return
1409
+ }
1410
+
1411
+ guard let quantitySamples = samples as? [HKQuantitySample] else {
1412
+ return
1413
+ }
1414
+
1415
+ // Process samples and group by date
1416
+ for sample in quantitySamples {
1417
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1418
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1419
+
1420
+ var value: Double = 0.0
1421
+
1422
+ // Convert values to match XML parser units
1423
+ switch typeIdentifier {
1424
+ case .walkingSpeed:
1425
+ // Convert to mph (HealthKit stores in m/s)
1426
+ value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 2.23694
1427
+ lock.lock()
1428
+ if walkingSpeedMap[dateString] == nil {
1429
+ walkingSpeedMap[dateString] = []
1430
+ }
1431
+ walkingSpeedMap[dateString]?.append(value)
1432
+ lock.unlock()
1433
+
1434
+ case .walkingStepLength:
1435
+ // Convert to inches (HealthKit stores in meters)
1436
+ value = sample.quantity.doubleValue(for: HKUnit.meter()) * 39.3701
1437
+ lock.lock()
1438
+ if stepLengthMap[dateString] == nil {
1439
+ stepLengthMap[dateString] = []
1440
+ }
1441
+ stepLengthMap[dateString]?.append(value)
1442
+ lock.unlock()
1443
+
1444
+ case .walkingAsymmetryPercentage:
1445
+ // Convert to percentage (HealthKit stores as decimal)
1446
+ value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1447
+ lock.lock()
1448
+ if asymmetryMap[dateString] == nil {
1449
+ asymmetryMap[dateString] = []
1450
+ }
1451
+ asymmetryMap[dateString]?.append(value)
1452
+ lock.unlock()
1453
+
1454
+ case .walkingDoubleSupportPercentage:
1455
+ // Convert to percentage (HealthKit stores as decimal)
1456
+ value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1457
+ lock.lock()
1458
+ if doubleSupportMap[dateString] == nil {
1459
+ doubleSupportMap[dateString] = []
1460
+ }
1461
+ doubleSupportMap[dateString]?.append(value)
1462
+ lock.unlock()
1463
+
1464
+ case .stairAscentSpeed:
1465
+ // Convert to ft/s (HealthKit stores in m/s)
1466
+ value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 3.28084
1467
+ lock.lock()
1468
+ if stairSpeedMap[dateString] == nil {
1469
+ stairSpeedMap[dateString] = []
1470
+ }
1471
+ stairSpeedMap[dateString]?.append(value)
1472
+ lock.unlock()
1473
+
1474
+ case .sixMinuteWalkTestDistance:
1475
+ // Convert to yards (HealthKit stores in meters)
1476
+ value = sample.quantity.doubleValue(for: HKUnit.meter()) * 1.09361
1477
+ lock.lock()
1478
+ if sixMinWalkMap[dateString] == nil {
1479
+ sixMinWalkMap[dateString] = []
1480
+ }
1481
+ sixMinWalkMap[dateString]?.append(value)
1482
+ lock.unlock()
1483
+
1484
+ default:
1485
+ break
1486
+ }
1487
+ }
1488
+ }
1489
+
1490
+ healthStore.execute(query)
1491
+ }
1492
+
1493
+ group.notify(queue: .main) {
1494
+ if !errors.isEmpty {
1495
+ completion(.failure(errors.first!))
1496
+ return
1497
+ }
1498
+
1499
+ // Collect all unique dates
1500
+ var allDates = Set<String>()
1501
+ allDates.formUnion(walkingSpeedMap.keys)
1502
+ allDates.formUnion(stepLengthMap.keys)
1503
+ allDates.formUnion(asymmetryMap.keys)
1504
+ allDates.formUnion(doubleSupportMap.keys)
1505
+ allDates.formUnion(stairSpeedMap.keys)
1506
+ allDates.formUnion(sixMinWalkMap.keys)
1507
+
1508
+ // Create mobility data array with aggregated daily averages
1509
+ var mobilityData: [[String: Any]] = []
1510
+
1511
+ for date in allDates.sorted() {
1512
+ var result: [String: Any] = ["date": date]
1513
+
1514
+ // Average all measurements for the day
1515
+ if let values = walkingSpeedMap[date], !values.isEmpty {
1516
+ let average = values.reduce(0.0, +) / Double(values.count)
1517
+ result["walkingSpeed"] = round(average * 100) / 100
1518
+ }
1519
+
1520
+ if let values = stepLengthMap[date], !values.isEmpty {
1521
+ let average = values.reduce(0.0, +) / Double(values.count)
1522
+ result["walkingStepLength"] = round(average * 10) / 10
1523
+ }
1524
+
1525
+ if let values = asymmetryMap[date], !values.isEmpty {
1526
+ let average = values.reduce(0.0, +) / Double(values.count)
1527
+ result["walkingAsymmetry"] = round(average * 10) / 10
1528
+ }
1529
+
1530
+ if let values = doubleSupportMap[date], !values.isEmpty {
1531
+ let average = values.reduce(0.0, +) / Double(values.count)
1532
+ result["walkingDoubleSupportTime"] = round(average * 10) / 10
1533
+ }
1534
+
1535
+ if let values = stairSpeedMap[date], !values.isEmpty {
1536
+ let average = values.reduce(0.0, +) / Double(values.count)
1537
+ result["stairSpeed"] = round(average * 100) / 100
1538
+ }
1539
+
1540
+ if let values = sixMinWalkMap[date], !values.isEmpty {
1541
+ let average = values.reduce(0.0, +) / Double(values.count)
1542
+ result["sixMinuteWalkDistance"] = round(average * 10) / 10
1543
+ }
1544
+
1545
+ mobilityData.append(result)
1546
+ }
1547
+
1548
+ completion(.success(mobilityData))
1549
+ }
1550
+ }
1551
+
1552
+ // MARK: - Activity Data Processing
1553
+
1554
+ private func processActivityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1555
+ let calendar = Calendar.current
1556
+ let group = DispatchGroup()
1557
+ let lock = NSLock()
1558
+
1559
+ // Maps to store values by date for each metric
1560
+ var stepsMap: [String: Double] = [:]
1561
+ var distanceMap: [String: Double] = [:]
1562
+ var flightsMap: [String: Double] = [:]
1563
+ var activeEnergyMap: [String: Double] = [:]
1564
+ var exerciseMinutesMap: [String: Double] = [:]
1565
+ var standHoursMap: [String: Int] = [:]
1566
+
1567
+ var errors: [Error] = []
1568
+
1569
+ // === STEPS ===
1570
+ if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) {
1571
+ group.enter()
1572
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1573
+ let query = HKSampleQuery(sampleType: stepsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1574
+ defer { group.leave() }
1575
+
1576
+ if let error = error {
1577
+ lock.lock()
1578
+ errors.append(error)
1579
+ lock.unlock()
1580
+ return
1581
+ }
1582
+
1583
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1584
+
1585
+ for sample in quantitySamples {
1586
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1587
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1588
+
1589
+ let steps = sample.quantity.doubleValue(for: HKUnit.count())
1590
+ lock.lock()
1591
+ stepsMap[dateString] = (stepsMap[dateString] ?? 0) + steps
1592
+ lock.unlock()
1593
+ }
1594
+ }
1595
+ healthStore.execute(query)
1596
+ }
1597
+
1598
+ // === DISTANCE (Walking + Running) ===
1599
+ if let distanceType = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) {
1600
+ group.enter()
1601
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1602
+ let query = HKSampleQuery(sampleType: distanceType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1603
+ defer { group.leave() }
1604
+
1605
+ if let error = error {
1606
+ lock.lock()
1607
+ errors.append(error)
1608
+ lock.unlock()
1609
+ return
1610
+ }
1611
+
1612
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1613
+
1614
+ for sample in quantitySamples {
1615
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1616
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1617
+
1618
+ // Convert to miles (HealthKit stores in meters)
1619
+ let distanceMeters = sample.quantity.doubleValue(for: HKUnit.meter())
1620
+ let distanceMiles = distanceMeters * 0.000621371
1621
+ lock.lock()
1622
+ distanceMap[dateString] = (distanceMap[dateString] ?? 0) + distanceMiles
1623
+ lock.unlock()
1624
+ }
1625
+ }
1626
+ healthStore.execute(query)
1627
+ }
1628
+
1629
+ // === FLIGHTS CLIMBED ===
1630
+ if let flightsType = HKObjectType.quantityType(forIdentifier: .flightsClimbed) {
1631
+ group.enter()
1632
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1633
+ let query = HKSampleQuery(sampleType: flightsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1634
+ defer { group.leave() }
1635
+
1636
+ if let error = error {
1637
+ lock.lock()
1638
+ errors.append(error)
1639
+ lock.unlock()
1640
+ return
1641
+ }
1642
+
1643
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1644
+
1645
+ for sample in quantitySamples {
1646
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1647
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1648
+
1649
+ let flights = sample.quantity.doubleValue(for: HKUnit.count())
1650
+ lock.lock()
1651
+ flightsMap[dateString] = (flightsMap[dateString] ?? 0) + flights
1652
+ lock.unlock()
1653
+ }
1654
+ }
1655
+ healthStore.execute(query)
1656
+ }
1657
+
1658
+ // === ACTIVE ENERGY ===
1659
+ if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
1660
+ group.enter()
1661
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1662
+ let query = HKSampleQuery(sampleType: activeEnergyType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1663
+ defer { group.leave() }
1664
+
1665
+ if let error = error {
1666
+ lock.lock()
1667
+ errors.append(error)
1668
+ lock.unlock()
1669
+ return
1670
+ }
1671
+
1672
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1673
+
1674
+ for sample in quantitySamples {
1675
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1676
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1677
+
1678
+ let calories = sample.quantity.doubleValue(for: HKUnit.kilocalorie())
1679
+ lock.lock()
1680
+ activeEnergyMap[dateString] = (activeEnergyMap[dateString] ?? 0) + calories
1681
+ lock.unlock()
1682
+ }
1683
+ }
1684
+ healthStore.execute(query)
1685
+ }
1686
+
1687
+ // === EXERCISE MINUTES ===
1688
+ if let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) {
1689
+ group.enter()
1690
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1691
+ let query = HKSampleQuery(sampleType: exerciseType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1692
+ defer { group.leave() }
1693
+
1694
+ if let error = error {
1695
+ lock.lock()
1696
+ errors.append(error)
1697
+ lock.unlock()
1698
+ return
1699
+ }
1700
+
1701
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1702
+
1703
+ for sample in quantitySamples {
1704
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1705
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1706
+
1707
+ let minutes = sample.quantity.doubleValue(for: HKUnit.minute())
1708
+ lock.lock()
1709
+ exerciseMinutesMap[dateString] = (exerciseMinutesMap[dateString] ?? 0) + minutes
1710
+ lock.unlock()
1711
+ }
1712
+ }
1713
+ healthStore.execute(query)
1714
+ }
1715
+
1716
+ // === STAND HOURS ===
1717
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1718
+ group.enter()
1719
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1720
+ let query = HKSampleQuery(sampleType: standHourType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1721
+ defer { group.leave() }
1722
+
1723
+ if let error = error {
1724
+ lock.lock()
1725
+ errors.append(error)
1726
+ lock.unlock()
1727
+ return
1728
+ }
1729
+
1730
+ guard let categorySamples = samples as? [HKCategorySample] else { return }
1731
+
1732
+ for sample in categorySamples {
1733
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1734
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1735
+
1736
+ // Value is HKCategoryValueAppleStandHour.stood (1) or .idle (0)
1737
+ if sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
1738
+ lock.lock()
1739
+ standHoursMap[dateString] = (standHoursMap[dateString] ?? 0) + 1
1740
+ lock.unlock()
1741
+ }
1742
+ }
1743
+ }
1744
+ healthStore.execute(query)
1745
+ }
1746
+
1747
+ group.notify(queue: .main) {
1748
+ // Only fail if there are errors AND no data was collected
1749
+ // This allows partial data to be returned if some permissions are denied
1750
+ if !errors.isEmpty {
1751
+ // Check if any data was actually collected
1752
+ let hasData = !stepsMap.isEmpty || !distanceMap.isEmpty || !flightsMap.isEmpty ||
1753
+ !activeEnergyMap.isEmpty || !exerciseMinutesMap.isEmpty || !standHoursMap.isEmpty
1754
+
1755
+ if !hasData {
1756
+ // No data at all, return the error
1757
+ completion(.failure(errors.first!))
1758
+ return
1759
+ }
1760
+ // If we have some data, continue and return what we have
1761
+ }
1762
+
1763
+ // Collect all unique dates that have any activity data
1764
+ var allDates = Set<String>()
1765
+ allDates.formUnion(stepsMap.keys)
1766
+ allDates.formUnion(distanceMap.keys)
1767
+ allDates.formUnion(flightsMap.keys)
1768
+ allDates.formUnion(activeEnergyMap.keys)
1769
+ allDates.formUnion(exerciseMinutesMap.keys)
1770
+ allDates.formUnion(standHoursMap.keys)
1771
+
1772
+ // Create activity data array matching the reference format
1773
+ let activityData: [[String: Any]] = allDates.sorted().compactMap { date in
1774
+ var result: [String: Any] = ["date": date]
1775
+
1776
+ // Add each metric if available, with proper rounding
1777
+ if let steps = stepsMap[date] {
1778
+ result["steps"] = Int(round(steps))
1779
+ }
1780
+
1781
+ if let distance = distanceMap[date] {
1782
+ result["distance"] = round(distance * 100) / 100
1783
+ }
1784
+
1785
+ if let flights = flightsMap[date] {
1786
+ result["flightsClimbed"] = Int(round(flights))
1787
+ }
1788
+
1789
+ if let activeEnergy = activeEnergyMap[date] {
1790
+ result["activeEnergy"] = Int(round(activeEnergy))
1791
+ }
1792
+
1793
+ if let exerciseMinutes = exerciseMinutesMap[date] {
1794
+ result["exerciseMinutes"] = Int(round(exerciseMinutes))
1795
+ }
1796
+
1797
+ if let standHours = standHoursMap[date] {
1798
+ result["standHours"] = standHours
1799
+ }
1800
+
1801
+ return result
1802
+ }
1803
+
1804
+ completion(.success(activityData))
1805
+ }
1806
+ }
1807
+
1808
+ // MARK: - Heart Data Processing
1809
+
1810
+ private func processHeartData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1811
+ let calendar = Calendar.current
1812
+ let group = DispatchGroup()
1813
+ let lock = NSLock()
1814
+
1815
+ // Maps to store values by date for each metric
1816
+ struct HeartRateStats {
1817
+ var sum: Double = 0
1818
+ var count: Int = 0
1819
+ var min: Double = 999
1820
+ var max: Double = 0
1821
+ }
1822
+
1823
+ var heartRateMap: [String: HeartRateStats] = [:]
1824
+ var restingHRMap: [String: Double] = [:]
1825
+ var vo2MaxMap: [String: Double] = [:]
1826
+ var hrvMap: [String: Double] = [:]
1827
+ var spo2Map: [String: [Double]] = [:]
1828
+ var respirationRateMap: [String: [Double]] = [:]
1829
+
1830
+ var errors: [Error] = []
1831
+
1832
+ // === HEART RATE ===
1833
+ if let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) {
1834
+ group.enter()
1835
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1836
+ let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1837
+ defer { group.leave() }
1838
+
1839
+ if let error = error {
1840
+ lock.lock()
1841
+ errors.append(error)
1842
+ lock.unlock()
1843
+ return
1844
+ }
1845
+
1846
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1847
+
1848
+ for sample in quantitySamples {
1849
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1850
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1851
+
1852
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
1853
+ lock.lock()
1854
+ var stats = heartRateMap[dateString] ?? HeartRateStats()
1855
+ stats.sum += bpm
1856
+ stats.count += 1
1857
+ stats.min = min(stats.min, bpm)
1858
+ stats.max = max(stats.max, bpm)
1859
+ heartRateMap[dateString] = stats
1860
+ lock.unlock()
1861
+ }
1862
+ }
1863
+ healthStore.execute(query)
1864
+ }
1865
+
1866
+ // === RESTING HEART RATE ===
1867
+ if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) {
1868
+ group.enter()
1869
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1870
+ let query = HKSampleQuery(sampleType: restingHRType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1871
+ defer { group.leave() }
1872
+
1873
+ if let error = error {
1874
+ lock.lock()
1875
+ errors.append(error)
1876
+ lock.unlock()
1877
+ return
1878
+ }
1879
+
1880
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1881
+
1882
+ for sample in quantitySamples {
1883
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1884
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1885
+
1886
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
1887
+ lock.lock()
1888
+ restingHRMap[dateString] = bpm // Take last measurement
1889
+ lock.unlock()
1890
+ }
1891
+ }
1892
+ healthStore.execute(query)
1893
+ }
1894
+
1895
+ // === VO2 MAX ===
1896
+ if let vo2MaxType = HKObjectType.quantityType(forIdentifier: .vo2Max) {
1897
+ group.enter()
1898
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1899
+ let query = HKSampleQuery(sampleType: vo2MaxType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1900
+ defer { group.leave() }
1901
+
1902
+ if let error = error {
1903
+ lock.lock()
1904
+ errors.append(error)
1905
+ lock.unlock()
1906
+ return
1907
+ }
1908
+
1909
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1910
+
1911
+ for sample in quantitySamples {
1912
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1913
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1914
+
1915
+ let vo2 = sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli).unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: HKUnit.minute())))
1916
+ lock.lock()
1917
+ vo2MaxMap[dateString] = vo2 // Take last measurement
1918
+ lock.unlock()
1919
+ }
1920
+ }
1921
+ healthStore.execute(query)
1922
+ }
1923
+
1924
+ // === HRV (Heart Rate Variability SDNN) ===
1925
+ if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
1926
+ group.enter()
1927
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1928
+ let query = HKSampleQuery(sampleType: hrvType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1929
+ defer { group.leave() }
1930
+
1931
+ if let error = error {
1932
+ lock.lock()
1933
+ errors.append(error)
1934
+ lock.unlock()
1935
+ return
1936
+ }
1937
+
1938
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1939
+
1940
+ for sample in quantitySamples {
1941
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1942
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1943
+
1944
+ let hrv = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli))
1945
+ lock.lock()
1946
+ hrvMap[dateString] = hrv // Take last measurement
1947
+ lock.unlock()
1948
+ }
1949
+ }
1950
+ healthStore.execute(query)
1951
+ }
1952
+
1953
+ // === SPO2 (Blood Oxygen Saturation) ===
1954
+ if let spo2Type = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) {
1955
+ group.enter()
1956
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1957
+ let query = HKSampleQuery(sampleType: spo2Type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1958
+ defer { group.leave() }
1959
+
1960
+ if let error = error {
1961
+ lock.lock()
1962
+ errors.append(error)
1963
+ lock.unlock()
1964
+ return
1965
+ }
1966
+
1967
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1968
+
1969
+ for sample in quantitySamples {
1970
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1971
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1972
+
1973
+ // HealthKit stores as decimal (0.96), convert to percentage (96)
1974
+ let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1975
+ lock.lock()
1976
+ if spo2Map[dateString] == nil {
1977
+ spo2Map[dateString] = []
1978
+ }
1979
+ spo2Map[dateString]?.append(value)
1980
+ lock.unlock()
1981
+ }
1982
+ }
1983
+ healthStore.execute(query)
1984
+ }
1985
+
1986
+ // === RESPIRATION RATE ===
1987
+ if let respirationRateType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) {
1988
+ group.enter()
1989
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1990
+ let query = HKSampleQuery(sampleType: respirationRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1991
+ defer { group.leave() }
1992
+
1993
+ if let error = error {
1994
+ lock.lock()
1995
+ errors.append(error)
1996
+ lock.unlock()
1997
+ return
1998
+ }
1999
+
2000
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
2001
+
2002
+ for sample in quantitySamples {
2003
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
2004
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
2005
+
2006
+ let breathsPerMin = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2007
+ lock.lock()
2008
+ if respirationRateMap[dateString] == nil {
2009
+ respirationRateMap[dateString] = []
2010
+ }
2011
+ respirationRateMap[dateString]?.append(breathsPerMin)
2012
+ lock.unlock()
2013
+ }
2014
+ }
2015
+ healthStore.execute(query)
2016
+ }
2017
+
2018
+ group.notify(queue: .main) {
2019
+ // Only fail if there are errors AND no data was collected
2020
+ // This allows partial data to be returned if some permissions are denied
2021
+ if !errors.isEmpty {
2022
+ // Check if any data was actually collected
2023
+ let hasData = !heartRateMap.isEmpty || !restingHRMap.isEmpty || !vo2MaxMap.isEmpty ||
2024
+ !hrvMap.isEmpty || !spo2Map.isEmpty || !respirationRateMap.isEmpty
2025
+
2026
+ if !hasData {
2027
+ // No data at all, return the error
2028
+ completion(.failure(errors.first!))
2029
+ return
2030
+ }
2031
+ // If we have some data, continue and return what we have
2032
+ }
2033
+
2034
+ // Collect all unique dates that have any heart data
2035
+ var allDates = Set<String>()
2036
+ allDates.formUnion(heartRateMap.keys)
2037
+ allDates.formUnion(restingHRMap.keys)
2038
+ allDates.formUnion(vo2MaxMap.keys)
2039
+ allDates.formUnion(hrvMap.keys)
2040
+ allDates.formUnion(spo2Map.keys)
2041
+ allDates.formUnion(respirationRateMap.keys)
2042
+
2043
+ // Create heart data array matching the TypeScript format
2044
+ let heartData: [[String: Any]] = allDates.sorted().compactMap { date in
2045
+ var result: [String: Any] = ["date": date]
2046
+
2047
+ // Heart Rate (avg, min, max, count)
2048
+ if let stats = heartRateMap[date] {
2049
+ result["avgBpm"] = Int(round(stats.sum / Double(stats.count)))
2050
+ result["minBpm"] = Int(round(stats.min))
2051
+ result["maxBpm"] = Int(round(stats.max))
2052
+ result["heartRateMeasurements"] = stats.count
2053
+ }
2054
+
2055
+ // Resting Heart Rate
2056
+ if let restingBpm = restingHRMap[date] {
2057
+ result["restingBpm"] = Int(round(restingBpm))
2058
+ }
2059
+
2060
+ // VO2 Max (rounded to 1 decimal)
2061
+ if let vo2 = vo2MaxMap[date] {
2062
+ result["vo2Max"] = round(vo2 * 10) / 10
2063
+ }
2064
+
2065
+ // HRV (rounded to 3 decimals)
2066
+ if let hrv = hrvMap[date] {
2067
+ result["hrv"] = round(hrv * 1000) / 1000
2068
+ }
2069
+
2070
+ // SpO2 (average)
2071
+ if let readings = spo2Map[date], !readings.isEmpty {
2072
+ let average = readings.reduce(0.0, +) / Double(readings.count)
2073
+ result["spo2"] = Int(round(average))
2074
+ }
2075
+
2076
+ // Respiration Rate (average, rounded to 1 decimal)
2077
+ if let readings = respirationRateMap[date], !readings.isEmpty {
2078
+ let average = readings.reduce(0.0, +) / Double(readings.count)
2079
+ result["respirationRate"] = round(average * 10) / 10
2080
+ }
2081
+
2082
+ return result
2083
+ }
2084
+
2085
+ completion(.success(heartData))
2086
+ }
2087
+ }
2088
+
2089
+ // MARK: - Workout Data Processing
2090
+
2091
+ private func processWorkoutData(startDate: Date, endDate: Date, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
2092
+ let workoutType = HKObjectType.workoutType()
2093
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2094
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
2095
+ let queryLimit = limit ?? HKObjectQueryNoLimit
2096
+
2097
+ let query = HKSampleQuery(sampleType: workoutType, predicate: predicate, limit: queryLimit, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, error in
2098
+ guard let self = self else { return }
2099
+
2100
+ if let error = error {
2101
+ completion(.failure(error))
2102
+ return
2103
+ }
2104
+
2105
+ guard let workouts = samples as? [HKWorkout], !workouts.isEmpty else {
2106
+ completion(.success([]))
2107
+ return
2108
+ }
2109
+
2110
+ var workoutData: [[String: Any]] = []
2111
+ let group = DispatchGroup()
2112
+ let lock = NSLock()
2113
+
2114
+ for workout in workouts {
2115
+ group.enter()
2116
+
2117
+ // Extract basic workout info
2118
+ let dateFormatter = ISO8601DateFormatter()
2119
+ dateFormatter.formatOptions = [.withFullDate]
2120
+ let date = dateFormatter.string(from: workout.startDate)
2121
+
2122
+ // Get workout type (remove "HKWorkoutActivityType" prefix to match XML format)
2123
+ let activityTypeString = self.workoutActivityTypeString(for: workout.workoutActivityType)
2124
+
2125
+ // Duration in minutes (matching XML parser logic)
2126
+ let durationInMinutes = Int(round(workout.duration / 60.0))
2127
+
2128
+ // Distance in miles (if available)
2129
+ var distance: Double?
2130
+ if let totalDistance = workout.totalDistance {
2131
+ let distanceInMeters = totalDistance.doubleValue(for: HKUnit.meter())
2132
+ distance = distanceInMeters * 0.000621371 // meters to miles
2133
+ }
2134
+
2135
+ // Calories (if available)
2136
+ var calories: Int?
2137
+ if let totalEnergy = workout.totalEnergyBurned {
2138
+ let caloriesValue = totalEnergy.doubleValue(for: HKUnit.kilocalorie())
2139
+ calories = Int(round(caloriesValue))
2140
+ }
2141
+
2142
+ // Source name
2143
+ let source = workout.sourceRevision.source.name
2144
+
2145
+ // Query for heart rate statistics during this workout
2146
+ self.queryWorkoutHeartRateStatistics(for: workout) { avgHR, maxHR in
2147
+ // Query for heart rate zones
2148
+ self.queryWorkoutHeartRateZones(for: workout) { zones in
2149
+ lock.lock()
2150
+
2151
+ var workoutDict: [String: Any] = [
2152
+ "date": date,
2153
+ "type": activityTypeString,
2154
+ "duration": durationInMinutes,
2155
+ "source": source
2156
+ ]
2157
+
2158
+ if let distance = distance {
2159
+ workoutDict["distance"] = round(distance * 100) / 100 // 2 decimal places
2160
+ }
2161
+
2162
+ if let calories = calories {
2163
+ workoutDict["calories"] = calories
2164
+ }
2165
+
2166
+ if let avgHR = avgHR {
2167
+ workoutDict["avgHeartRate"] = avgHR
2168
+ }
2169
+
2170
+ if let maxHR = maxHR {
2171
+ workoutDict["maxHeartRate"] = maxHR
2172
+ }
2173
+
2174
+ if !zones.isEmpty {
2175
+ workoutDict["zones"] = zones
2176
+ }
2177
+
2178
+ workoutData.append(workoutDict)
2179
+ lock.unlock()
2180
+
2181
+ group.leave()
2182
+ }
2183
+ }
2184
+ }
2185
+
2186
+ group.notify(queue: .main) {
2187
+ // Sort by date if needed
2188
+ let sortedData = workoutData.sorted { dict1, dict2 in
2189
+ guard let date1 = dict1["date"] as? String,
2190
+ let date2 = dict2["date"] as? String else {
2191
+ return false
2192
+ }
2193
+ return ascending ? date1 < date2 : date1 > date2
2194
+ }
2195
+
2196
+ completion(.success(sortedData))
2197
+ }
2198
+ }
2199
+
2200
+ healthStore.execute(query)
2201
+ }
2202
+
2203
+ private func workoutActivityTypeString(for type: HKWorkoutActivityType) -> String {
2204
+ // Return the activity type name without the "HKWorkoutActivityType" prefix
2205
+ // to match the XML export format
2206
+ switch type {
2207
+ case .running: return "Running"
2208
+ case .cycling: return "Cycling"
2209
+ case .walking: return "Walking"
2210
+ case .swimming: return "Swimming"
2211
+ case .yoga: return "Yoga"
2212
+ case .functionalStrengthTraining: return "FunctionalStrengthTraining"
2213
+ case .traditionalStrengthTraining: return "TraditionalStrengthTraining"
2214
+ case .elliptical: return "Elliptical"
2215
+ case .rowing: return "Rowing"
2216
+ case .hiking: return "Hiking"
2217
+ case .highIntensityIntervalTraining: return "HighIntensityIntervalTraining"
2218
+ case .dance: return "Dance"
2219
+ case .basketball: return "Basketball"
2220
+ case .soccer: return "Soccer"
2221
+ case .tennis: return "Tennis"
2222
+ case .golf: return "Golf"
2223
+ case .stairClimbing: return "StairClimbing"
2224
+ case .stepTraining: return "StepTraining"
2225
+ case .kickboxing: return "Kickboxing"
2226
+ case .pilates: return "Pilates"
2227
+ case .boxing: return "Boxing"
2228
+ case .taiChi: return "TaiChi"
2229
+ case .crossTraining: return "CrossTraining"
2230
+ case .mindAndBody: return "MindAndBody"
2231
+ case .coreTraining: return "CoreTraining"
2232
+ case .flexibility: return "Flexibility"
2233
+ case .cooldown: return "Cooldown"
2234
+ case .wheelchairWalkPace: return "WheelchairWalkPace"
2235
+ case .wheelchairRunPace: return "WheelchairRunPace"
2236
+ case .other: return "Other"
2237
+ default:
2238
+ // For any unknown or new types
2239
+ return "Other"
2240
+ }
2241
+ }
2242
+
2243
+ private func queryWorkoutHeartRateStatistics(for workout: HKWorkout, completion: @escaping (Int?, Int?) -> Void) {
2244
+ guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
2245
+ completion(nil, nil)
2246
+ return
2247
+ }
2248
+
2249
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
2250
+
2251
+ let query = HKStatisticsQuery(quantityType: heartRateType, quantitySamplePredicate: predicate, options: [.discreteAverage, .discreteMax]) { _, statistics, error in
2252
+ if error != nil {
2253
+ completion(nil, nil)
2254
+ return
2255
+ }
2256
+
2257
+ var avgHR: Int?
2258
+ var maxHR: Int?
2259
+
2260
+ if let avgQuantity = statistics?.averageQuantity() {
2261
+ let bpm = avgQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2262
+ avgHR = Int(round(bpm))
2263
+ }
2264
+
2265
+ if let maxQuantity = statistics?.maximumQuantity() {
2266
+ let bpm = maxQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2267
+ maxHR = Int(round(bpm))
2268
+ }
2269
+
2270
+ completion(avgHR, maxHR)
2271
+ }
2272
+
2273
+ healthStore.execute(query)
2274
+ }
2275
+
2276
+ private func queryWorkoutHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
2277
+ // Check if heart rate zone data is available in workout metadata
2278
+ var zones: [String: Int] = [:]
2279
+
2280
+ if let metadata = workout.metadata {
2281
+ // Look for heart rate zone keys in metadata
2282
+ // Apple Health stores zones as HKMetadataKeyHeartRateEventThreshold or custom keys
2283
+ for (key, value) in metadata {
2284
+ if key.contains("Zone") || key.contains("zone") {
2285
+ // Try to extract zone number
2286
+ if let zoneMatch = key.range(of: #"\d+"#, options: .regularExpression),
2287
+ let zoneNum = Int(key[zoneMatch]) {
2288
+ // Value might be in seconds, convert to minutes
2289
+ if let seconds = value as? Double {
2290
+ let minutes = Int(round(seconds / 60.0))
2291
+ zones["zone\(zoneNum)"] = minutes
2292
+ } else if let minutes = value as? Int {
2293
+ zones["zone\(zoneNum)"] = minutes
2294
+ }
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2299
+
2300
+ // If no zones found in metadata, try querying heart rate samples to calculate zones
2301
+ if zones.isEmpty {
2302
+ calculateHeartRateZones(for: workout) { calculatedZones in
2303
+ completion(calculatedZones)
2304
+ }
2305
+ } else {
2306
+ completion(zones)
2307
+ }
2308
+ }
2309
+
2310
+ private func calculateHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
2311
+ guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
2312
+ completion([:])
2313
+ return
2314
+ }
2315
+
2316
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
2317
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
2318
+
2319
+ let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
2320
+ if error != nil || samples == nil {
2321
+ completion([:])
2322
+ return
2323
+ }
2324
+
2325
+ guard let heartRateSamples = samples as? [HKQuantitySample], !heartRateSamples.isEmpty else {
2326
+ completion([:])
2327
+ return
2328
+ }
2329
+
2330
+ // Calculate zones based on standard heart rate zone definitions
2331
+ // Zone 1: 50-60% max HR
2332
+ // Zone 2: 60-70% max HR
2333
+ // Zone 3: 70-80% max HR
2334
+ // Zone 4: 80-90% max HR
2335
+ // Zone 5: 90-100% max HR
2336
+
2337
+ // Estimate max HR (220 - age), or use 180 as a reasonable default
2338
+ let estimatedMaxHR = 180.0
2339
+
2340
+ var zoneMinutes: [Int: TimeInterval] = [1: 0, 2: 0, 3: 0, 4: 0, 5: 0]
2341
+
2342
+ for i in 0..<heartRateSamples.count {
2343
+ let sample = heartRateSamples[i]
2344
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2345
+ let percentMax = (bpm / estimatedMaxHR) * 100
2346
+
2347
+ // Determine zone
2348
+ let zone: Int
2349
+ if percentMax < 60 {
2350
+ zone = 1
2351
+ } else if percentMax < 70 {
2352
+ zone = 2
2353
+ } else if percentMax < 80 {
2354
+ zone = 3
2355
+ } else if percentMax < 90 {
2356
+ zone = 4
2357
+ } else {
2358
+ zone = 5
2359
+ }
2360
+
2361
+ // Calculate time in this zone (use interval to next sample or default to 5 seconds)
2362
+ let duration: TimeInterval
2363
+ if i < heartRateSamples.count - 1 {
2364
+ duration = heartRateSamples[i + 1].startDate.timeIntervalSince(sample.startDate)
2365
+ } else {
2366
+ duration = 5.0 // Default 5 seconds for last sample
2367
+ }
2368
+
2369
+ zoneMinutes[zone, default: 0] += duration
2370
+ }
2371
+
2372
+ // Convert seconds to minutes and filter out zones with 0 minutes
2373
+ var zones: [String: Int] = [:]
2374
+ for (zone, seconds) in zoneMinutes {
2375
+ let minutes = Int(round(seconds / 60.0))
2376
+ if minutes > 0 {
2377
+ zones["zone\(zone)"] = minutes
2378
+ }
2379
+ }
2380
+
2381
+ completion(zones)
2382
+ }
2383
+
2384
+ healthStore.execute(query)
2385
+ }
522
2386
  }