@interval-health/capacitor-health 1.0.1 → 1.0.3

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,10 @@ 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
37
41
 
38
42
  func sampleType() throws -> HKSampleType {
39
43
  if self == .sleep {
@@ -43,6 +47,38 @@ enum HealthDataType: String, CaseIterable {
43
47
  return type
44
48
  }
45
49
 
50
+ if self == .mobility {
51
+ // Mobility uses multiple types, return walkingSpeed as representative
52
+ guard let type = HKObjectType.quantityType(forIdentifier: .walkingSpeed) else {
53
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
54
+ }
55
+ return type
56
+ }
57
+
58
+ if self == .activity {
59
+ // Activity uses multiple types, return stepCount as representative
60
+ guard let type = HKObjectType.quantityType(forIdentifier: .stepCount) else {
61
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
62
+ }
63
+ return type
64
+ }
65
+
66
+ if self == .heart {
67
+ // Heart uses multiple types, return heartRate as representative
68
+ guard let type = HKObjectType.quantityType(forIdentifier: .heartRate) else {
69
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
70
+ }
71
+ return type
72
+ }
73
+
74
+ if self == .body {
75
+ // Body uses multiple types, return bodyMass as representative
76
+ guard let type = HKObjectType.quantityType(forIdentifier: .bodyMass) else {
77
+ throw HealthManagerError.dataTypeUnavailable(rawValue)
78
+ }
79
+ return type
80
+ }
81
+
46
82
  let identifier: HKQuantityTypeIdentifier
47
83
  switch self {
48
84
  case .steps:
@@ -57,6 +93,14 @@ enum HealthDataType: String, CaseIterable {
57
93
  identifier = .bodyMass
58
94
  case .sleep:
59
95
  fatalError("Sleep should have been handled above")
96
+ case .mobility:
97
+ fatalError("Mobility should have been handled above")
98
+ case .activity:
99
+ fatalError("Activity should have been handled above")
100
+ case .heart:
101
+ fatalError("Heart should have been handled above")
102
+ case .body:
103
+ fatalError("Body should have been handled above")
60
104
  }
61
105
 
62
106
  guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
@@ -79,6 +123,14 @@ enum HealthDataType: String, CaseIterable {
79
123
  return HKUnit.gramUnit(with: .kilo)
80
124
  case .sleep:
81
125
  return HKUnit.minute() // Sleep duration in minutes
126
+ case .mobility:
127
+ return HKUnit.meter() // Placeholder, mobility has multiple units
128
+ case .activity:
129
+ return HKUnit.count() // Placeholder, activity has multiple units
130
+ case .heart:
131
+ return HKUnit.count().unitDivided(by: HKUnit.minute()) // Placeholder, heart has multiple units
132
+ case .body:
133
+ return HKUnit.gramUnit(with: .kilo) // Placeholder, body has multiple units
82
134
  }
83
135
  }
84
136
 
@@ -96,6 +148,14 @@ enum HealthDataType: String, CaseIterable {
96
148
  return "kilogram"
97
149
  case .sleep:
98
150
  return "minute"
151
+ case .mobility:
152
+ return "mixed" // Mobility has multiple units
153
+ case .activity:
154
+ return "mixed" // Activity has multiple units
155
+ case .heart:
156
+ return "mixed" // Heart has multiple units
157
+ case .body:
158
+ return "mixed" // Body has multiple units
99
159
  }
100
160
  }
101
161
 
@@ -200,8 +260,7 @@ final class Health {
200
260
 
201
261
  func readSamples(dataTypeIdentifier: String, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) throws {
202
262
  let dataType = try parseDataType(identifier: dataTypeIdentifier)
203
- let sampleType = try dataType.sampleType()
204
-
263
+
205
264
  let startDate = try parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))
206
265
  let endDate = try parseDate(endDateString, defaultValue: Date())
207
266
 
@@ -209,6 +268,64 @@ final class Health {
209
268
  throw HealthManagerError.invalidDateRange
210
269
  }
211
270
 
271
+ // Handle body data (multiple quantity types)
272
+ // Skip the initial query and go directly to processing to avoid authorization issues
273
+ if dataType == .body {
274
+ processBodyData(startDate: startDate, endDate: endDate) { result in
275
+ switch result {
276
+ case .success(let bodyData):
277
+ completion(.success(bodyData))
278
+ case .failure(let error):
279
+ completion(.failure(error))
280
+ }
281
+ }
282
+ return
283
+ }
284
+
285
+ // Handle heart data (multiple quantity types)
286
+ // Skip the initial query and go directly to processing to avoid authorization issues
287
+ if dataType == .heart {
288
+ processHeartData(startDate: startDate, endDate: endDate) { result in
289
+ switch result {
290
+ case .success(let heartData):
291
+ completion(.success(heartData))
292
+ case .failure(let error):
293
+ completion(.failure(error))
294
+ }
295
+ }
296
+ return
297
+ }
298
+
299
+ // Handle activity data (multiple quantity and category types)
300
+ // Skip the initial query and go directly to processing to avoid authorization issues
301
+ if dataType == .activity {
302
+ processActivityData(startDate: startDate, endDate: endDate) { result in
303
+ switch result {
304
+ case .success(let activityData):
305
+ completion(.success(activityData))
306
+ case .failure(let error):
307
+ completion(.failure(error))
308
+ }
309
+ }
310
+ return
311
+ }
312
+
313
+ // Handle mobility data (multiple quantity types)
314
+ // Skip the initial query and go directly to processing to avoid authorization issues
315
+ if dataType == .mobility {
316
+ processMobilityData(startDate: startDate, endDate: endDate) { result in
317
+ switch result {
318
+ case .success(let mobilityData):
319
+ completion(.success(mobilityData))
320
+ case .failure(let error):
321
+ completion(.failure(error))
322
+ }
323
+ }
324
+ return
325
+ }
326
+
327
+ // For all other data types, use the standard query approach
328
+ let sampleType = try dataType.sampleType()
212
329
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
213
330
  let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
214
331
  let queryLimit = limit ?? 100
@@ -225,7 +342,7 @@ final class Health {
225
342
  completion(.success([]))
226
343
  return
227
344
  }
228
-
345
+
229
346
  // Handle sleep data (category samples)
230
347
  if dataType == .sleep {
231
348
  guard let categorySamples = samples as? [HKCategorySample] else {
@@ -233,27 +350,9 @@ final class Health {
233
350
  return
234
351
  }
235
352
 
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))
353
+ // Process sleep data similar to client-side parser
354
+ let processedSleepData = self.processSleepSamples(categorySamples)
355
+ completion(.success(processedSleepData))
257
356
  return
258
357
  }
259
358
 
@@ -363,18 +462,110 @@ final class Health {
363
462
  var denied: [HealthDataType] = []
364
463
 
365
464
  for type in types {
366
- guard let sampleType = try? type.sampleType() else {
367
- denied.append(type)
368
- continue
465
+ // Special handling for activity - check all activity types
466
+ // Consider authorized if at least one activity type is authorized
467
+ if type == .activity {
468
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
469
+ .stepCount,
470
+ .distanceWalkingRunning,
471
+ .flightsClimbed,
472
+ .activeEnergyBurned,
473
+ .appleExerciseTime
474
+ ]
475
+
476
+ var hasAnyAuthorized = false
477
+ for identifier in activityIdentifiers {
478
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
479
+ let status = healthStore.authorizationStatus(for: quantityType)
480
+ if status == .sharingAuthorized {
481
+ hasAnyAuthorized = true
482
+ break
483
+ }
484
+ }
485
+ }
486
+
487
+ // Also check stand hour (category type)
488
+ if !hasAnyAuthorized {
489
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
490
+ let status = healthStore.authorizationStatus(for: standHourType)
491
+ if status == .sharingAuthorized {
492
+ hasAnyAuthorized = true
493
+ }
494
+ }
495
+ }
496
+
497
+ if hasAnyAuthorized {
498
+ authorized.append(type)
499
+ } else {
500
+ denied.append(type)
501
+ }
369
502
  }
503
+ // Special handling for body - check body composition types
504
+ // Consider authorized if at least one body type is authorized
505
+ else if type == .body {
506
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
507
+ .bodyMass,
508
+ .bodyFatPercentage
509
+ ]
510
+
511
+ var hasAnyAuthorized = false
512
+ for identifier in bodyIdentifiers {
513
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
514
+ let status = healthStore.authorizationStatus(for: quantityType)
515
+ if status == .sharingAuthorized {
516
+ hasAnyAuthorized = true
517
+ break
518
+ }
519
+ }
520
+ }
521
+
522
+ if hasAnyAuthorized {
523
+ authorized.append(type)
524
+ } else {
525
+ denied.append(type)
526
+ }
527
+ }
528
+ // Special handling for mobility - check all 6 types
529
+ else if type == .mobility {
530
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
531
+ .walkingSpeed,
532
+ .walkingStepLength,
533
+ .walkingAsymmetryPercentage,
534
+ .walkingDoubleSupportPercentage,
535
+ .stairAscentSpeed,
536
+ .sixMinuteWalkTestDistance
537
+ ]
538
+
539
+ var allAuthorized = true
540
+ for identifier in mobilityIdentifiers {
541
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
542
+ let status = healthStore.authorizationStatus(for: quantityType)
543
+ if status != .sharingAuthorized {
544
+ allAuthorized = false
545
+ break
546
+ }
547
+ }
548
+ }
549
+
550
+ if allAuthorized {
551
+ authorized.append(type)
552
+ } else {
553
+ denied.append(type)
554
+ }
555
+ } else {
556
+ guard let sampleType = try? type.sampleType() else {
557
+ denied.append(type)
558
+ continue
559
+ }
370
560
 
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)
561
+ switch healthStore.authorizationStatus(for: sampleType) {
562
+ case .sharingAuthorized:
563
+ authorized.append(type)
564
+ case .sharingDenied, .notDetermined:
565
+ denied.append(type)
566
+ @unknown default:
567
+ denied.append(type)
568
+ }
378
569
  }
379
570
  }
380
571
 
@@ -394,28 +585,174 @@ final class Health {
394
585
  var denied: [HealthDataType] = []
395
586
 
396
587
  for type in types {
397
- guard let objectType = try? type.sampleType() else {
398
- denied.append(type)
399
- continue
588
+ // Special handling for activity - check all activity types
589
+ if type == .activity {
590
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
591
+ .stepCount,
592
+ .distanceWalkingRunning,
593
+ .flightsClimbed,
594
+ .activeEnergyBurned,
595
+ .appleExerciseTime
596
+ ]
597
+
598
+ // Check all 5 quantity types + 1 category type
599
+ var activityAuthorizedCount = 0
600
+ let totalActivityTypes = activityIdentifiers.count + 1 // +1 for stand hour
601
+
602
+ // Create a nested group for activity checks
603
+ let activityGroup = DispatchGroup()
604
+
605
+ for identifier in activityIdentifiers {
606
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
607
+ activityGroup.enter()
608
+ let readSet = Set<HKObjectType>([quantityType])
609
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
610
+ defer { activityGroup.leave() }
611
+
612
+ if error == nil && status == .unnecessary {
613
+ lock.lock()
614
+ activityAuthorizedCount += 1
615
+ lock.unlock()
616
+ }
617
+ }
618
+ }
619
+ }
620
+
621
+ // Check stand hour (category type)
622
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
623
+ activityGroup.enter()
624
+ let readSet = Set<HKObjectType>([standHourType])
625
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
626
+ defer { activityGroup.leave() }
627
+
628
+ if error == nil && status == .unnecessary {
629
+ lock.lock()
630
+ activityAuthorizedCount += 1
631
+ lock.unlock()
632
+ }
633
+ }
634
+ }
635
+
636
+ // Wait for all activity checks, then determine if activity is authorized
637
+ // Consider authorized if at least one activity type is authorized
638
+ activityGroup.notify(queue: .main) {
639
+ lock.lock()
640
+ if activityAuthorizedCount > 0 {
641
+ authorized.append(type)
642
+ } else {
643
+ denied.append(type)
644
+ }
645
+ lock.unlock()
646
+ }
400
647
  }
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
648
+ // Special handling for body - check body composition types
649
+ else if type == .body {
650
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
651
+ .bodyMass,
652
+ .bodyFatPercentage
653
+ ]
654
+
655
+ // Check all body types
656
+ var bodyAuthorizedCount = 0
657
+
658
+ // Create a nested group for body checks
659
+ let bodyGroup = DispatchGroup()
660
+
661
+ for identifier in bodyIdentifiers {
662
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
663
+ bodyGroup.enter()
664
+ let readSet = Set<HKObjectType>([quantityType])
665
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
666
+ defer { bodyGroup.leave() }
667
+
668
+ if error == nil && status == .unnecessary {
669
+ lock.lock()
670
+ bodyAuthorizedCount += 1
671
+ lock.unlock()
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ // Wait for all body checks, then determine if body is authorized
678
+ // Consider authorized if at least one body type is authorized
679
+ bodyGroup.notify(queue: .main) {
680
+ lock.lock()
681
+ if bodyAuthorizedCount > 0 {
682
+ authorized.append(type)
683
+ } else {
684
+ denied.append(type)
685
+ }
686
+ lock.unlock()
687
+ }
688
+ }
689
+ // Special handling for mobility - check all 6 types
690
+ else if type == .mobility {
691
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
692
+ .walkingSpeed,
693
+ .walkingStepLength,
694
+ .walkingAsymmetryPercentage,
695
+ .walkingDoubleSupportPercentage,
696
+ .stairAscentSpeed,
697
+ .sixMinuteWalkTestDistance
698
+ ]
699
+
700
+ // Check all 6 mobility types
701
+ var mobilityAuthorizedCount = 0
702
+
703
+ // Create a nested group for mobility checks
704
+ let mobilityGroup = DispatchGroup()
705
+
706
+ for identifier in mobilityIdentifiers {
707
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
708
+ mobilityGroup.enter()
709
+ let readSet = Set<HKObjectType>([quantityType])
710
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
711
+ defer { mobilityGroup.leave() }
712
+
713
+ if error == nil && status == .unnecessary {
714
+ lock.lock()
715
+ mobilityAuthorizedCount += 1
716
+ lock.unlock()
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ // Wait for all mobility checks, then determine if mobility is authorized
723
+ mobilityGroup.notify(queue: .main) {
724
+ lock.lock()
725
+ if mobilityAuthorizedCount == mobilityIdentifiers.count {
726
+ authorized.append(type)
727
+ } else {
728
+ denied.append(type)
729
+ }
730
+ lock.unlock()
731
+ }
732
+ } else {
733
+ guard let objectType = try? type.sampleType() else {
734
+ denied.append(type)
735
+ continue
410
736
  }
411
737
 
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()
738
+ group.enter()
739
+ let readSet = Set<HKObjectType>([objectType])
740
+ healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
741
+ defer { group.leave() }
742
+
743
+ if error != nil {
744
+ lock.lock(); denied.append(type); lock.unlock()
745
+ return
746
+ }
747
+
748
+ switch status {
749
+ case .unnecessary:
750
+ lock.lock(); authorized.append(type); lock.unlock()
751
+ case .shouldRequest, .unknown:
752
+ lock.lock(); denied.append(type); lock.unlock()
753
+ @unknown default:
754
+ lock.lock(); denied.append(type); lock.unlock()
755
+ }
419
756
  }
420
757
  }
421
758
  }
@@ -478,6 +815,225 @@ final class Health {
478
815
  }
479
816
  }
480
817
  }
818
+
819
+ private func processSleepSamples(_ samples: [HKCategorySample]) -> [[String: Any]] {
820
+ // Filter for detailed stage data only (Deep, REM, Core, Awake)
821
+ let detailedSamples = samples.filter { sample in
822
+ if #available(iOS 16.0, *) {
823
+ return sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
824
+ sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
825
+ sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
826
+ sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
827
+ } else {
828
+ // For older iOS, just process what's available
829
+ return true
830
+ }
831
+ }
832
+
833
+ // Sort by start date
834
+ let sortedSamples = detailedSamples.sorted { $0.startDate < $1.startDate }
835
+
836
+ // Collect ALL stage segments (raw data) - matching TypeScript structure
837
+ struct SleepSegment {
838
+ let start: Date
839
+ let end: Date
840
+ let stage: String
841
+ }
842
+
843
+ var allSegments: [SleepSegment] = []
844
+
845
+ for sample in sortedSamples {
846
+ let stageValue = getHealthKitStageConstantName(for: sample.value)
847
+ allSegments.append(SleepSegment(
848
+ start: sample.startDate,
849
+ end: sample.endDate,
850
+ stage: stageValue
851
+ ))
852
+ }
853
+
854
+ // Group segments by SLEEP SESSION (not by day!)
855
+ // Sessions are separated by 30+ minute gaps
856
+ struct SleepSession {
857
+ let start: Date
858
+ let end: Date
859
+ let segments: [SleepSegment]
860
+ }
861
+
862
+ var sessions: [SleepSession] = []
863
+ var currentSession: [SleepSegment] = []
864
+
865
+ for segment in allSegments {
866
+ if currentSession.isEmpty {
867
+ currentSession.append(segment)
868
+ } else {
869
+ let lastSegment = currentSession.last!
870
+ let gapMinutes = segment.start.timeIntervalSince(lastSegment.end) / 60.0
871
+
872
+ if gapMinutes < 30 {
873
+ // Same session
874
+ currentSession.append(segment)
875
+ } else {
876
+ // New session (gap > 30 min)
877
+ if !currentSession.isEmpty {
878
+ sessions.append(SleepSession(
879
+ start: currentSession.first!.start,
880
+ end: currentSession.last!.end,
881
+ segments: currentSession
882
+ ))
883
+ }
884
+ currentSession = [segment]
885
+ }
886
+ }
887
+ }
888
+
889
+ // Don't forget last session
890
+ if !currentSession.isEmpty {
891
+ sessions.append(SleepSession(
892
+ start: currentSession.first!.start,
893
+ end: currentSession.last!.end,
894
+ segments: currentSession
895
+ ))
896
+ }
897
+
898
+ // Now attribute each session to the day you WOKE UP (end date)
899
+ struct DayData {
900
+ var sessions: [SleepSession]
901
+ var deepMinutes: Double
902
+ var remMinutes: Double
903
+ var coreMinutes: Double
904
+ var awakeMinutes: Double
905
+ }
906
+
907
+ var sleepByDate: [String: DayData] = [:]
908
+
909
+ for session in sessions {
910
+ // Wake-up date (local)
911
+ let calendar = Calendar.current
912
+ let wakeDate = calendar.dateComponents([.year, .month, .day], from: session.end)
913
+ let dateString = String(format: "%04d-%02d-%02d", wakeDate.year!, wakeDate.month!, wakeDate.day!)
914
+
915
+ // Initialize day data if needed
916
+ if sleepByDate[dateString] == nil {
917
+ sleepByDate[dateString] = DayData(
918
+ sessions: [],
919
+ deepMinutes: 0,
920
+ remMinutes: 0,
921
+ coreMinutes: 0,
922
+ awakeMinutes: 0
923
+ )
924
+ }
925
+
926
+ sleepByDate[dateString]!.sessions.append(session)
927
+
928
+ // Calculate minutes per stage for THIS session
929
+ for segment in session.segments {
930
+ let minutes = segment.end.timeIntervalSince(segment.start) / 60.0
931
+
932
+ if segment.stage == "HKCategoryValueSleepAnalysisAsleepDeep" {
933
+ sleepByDate[dateString]!.deepMinutes += minutes
934
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAsleepREM" {
935
+ sleepByDate[dateString]!.remMinutes += minutes
936
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAsleepCore" {
937
+ sleepByDate[dateString]!.coreMinutes += minutes
938
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAwake" {
939
+ sleepByDate[dateString]!.awakeMinutes += minutes
940
+ }
941
+ }
942
+ }
943
+
944
+ // Convert to final format
945
+ var sleepData: [[String: Any]] = []
946
+
947
+ for (date, data) in sleepByDate {
948
+ let deepHours = data.deepMinutes / 60.0
949
+ let remHours = data.remMinutes / 60.0
950
+ let coreHours = data.coreMinutes / 60.0
951
+ let awakeHours = data.awakeMinutes / 60.0
952
+ let totalSleepHours = deepHours + remHours + coreHours
953
+
954
+ // Calculate time in bed from merged sessions (first start to last end)
955
+ var timeInBed = 0.0
956
+ if !data.sessions.isEmpty {
957
+ let bedtime = data.sessions.first!.start
958
+ let wakeTime = data.sessions.last!.end
959
+ timeInBed = wakeTime.timeIntervalSince(bedtime) / 3600.0
960
+ }
961
+
962
+ // Calculate efficiency
963
+ let efficiency = timeInBed > 0 ? Int(round((totalSleepHours / timeInBed) * 100)) : 0
964
+
965
+ // Map sessions to output format with segments matching TypeScript structure
966
+ let mergedSessions: [[String: Any]] = data.sessions.map { session in
967
+ let segments: [[String: Any]] = session.segments.map { segment in
968
+ return [
969
+ "start": isoFormatter.string(from: segment.start),
970
+ "end": isoFormatter.string(from: segment.end),
971
+ "stage": segment.stage
972
+ ]
973
+ }
974
+
975
+ return [
976
+ "start": isoFormatter.string(from: session.start),
977
+ "end": isoFormatter.string(from: session.end),
978
+ "segments": segments
979
+ ]
980
+ }
981
+
982
+ sleepData.append([
983
+ "date": date,
984
+ "totalSleepHours": round(totalSleepHours * 10) / 10,
985
+ "sleepSessions": data.sessions.count,
986
+ "deepSleep": round(deepHours * 10) / 10,
987
+ "remSleep": round(remHours * 10) / 10,
988
+ "coreSleep": round(coreHours * 10) / 10,
989
+ "awakeTime": round(awakeHours * 10) / 10,
990
+ "timeInBed": round(timeInBed * 10) / 10,
991
+ "efficiency": efficiency,
992
+ "mergedSessions": mergedSessions
993
+ ])
994
+ }
995
+
996
+ // Sort by date
997
+ sleepData.sort { (a, b) -> Bool in
998
+ let dateA = a["date"] as! String
999
+ let dateB = b["date"] as! String
1000
+ return dateA < dateB
1001
+ }
1002
+
1003
+ return sleepData
1004
+ }
1005
+
1006
+ private func getHealthKitStageConstantName(for value: Int) -> String {
1007
+ if #available(iOS 16.0, *) {
1008
+ switch value {
1009
+ case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
1010
+ return "HKCategoryValueSleepAnalysisAsleepDeep"
1011
+ case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
1012
+ return "HKCategoryValueSleepAnalysisAsleepREM"
1013
+ case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
1014
+ return "HKCategoryValueSleepAnalysisAsleepCore"
1015
+ case HKCategoryValueSleepAnalysis.awake.rawValue:
1016
+ return "HKCategoryValueSleepAnalysisAwake"
1017
+ case HKCategoryValueSleepAnalysis.inBed.rawValue:
1018
+ return "HKCategoryValueSleepAnalysisInBed"
1019
+ case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
1020
+ return "HKCategoryValueSleepAnalysisAsleepUnspecified"
1021
+ default:
1022
+ return "HKCategoryValueSleepAnalysisUnknown"
1023
+ }
1024
+ } else {
1025
+ switch value {
1026
+ case HKCategoryValueSleepAnalysis.inBed.rawValue:
1027
+ return "HKCategoryValueSleepAnalysisInBed"
1028
+ case HKCategoryValueSleepAnalysis.asleep.rawValue:
1029
+ return "HKCategoryValueSleepAnalysisAsleep"
1030
+ case HKCategoryValueSleepAnalysis.awake.rawValue:
1031
+ return "HKCategoryValueSleepAnalysisAwake"
1032
+ default:
1033
+ return "HKCategoryValueSleepAnalysisUnknown"
1034
+ }
1035
+ }
1036
+ }
481
1037
 
482
1038
  private func unit(for identifier: String?, dataType: HealthDataType) -> HKUnit {
483
1039
  guard let identifier = identifier else {
@@ -505,8 +1061,77 @@ final class Health {
505
1061
  private func objectTypes(for dataTypes: [HealthDataType]) throws -> Set<HKObjectType> {
506
1062
  var set = Set<HKObjectType>()
507
1063
  for dataType in dataTypes {
508
- let type = try dataType.sampleType()
509
- set.insert(type)
1064
+ // Special handling for activity - expand into all activity-related HealthKit types
1065
+ if dataType == .activity {
1066
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
1067
+ .stepCount,
1068
+ .distanceWalkingRunning,
1069
+ .flightsClimbed,
1070
+ .activeEnergyBurned,
1071
+ .appleExerciseTime
1072
+ ]
1073
+
1074
+ for identifier in activityIdentifiers {
1075
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1076
+ set.insert(quantityType)
1077
+ }
1078
+ }
1079
+
1080
+ // Add category type for stand hours
1081
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1082
+ set.insert(standHourType)
1083
+ }
1084
+ }
1085
+ // Special handling for heart - expand into all 6 HealthKit types
1086
+ else if dataType == .heart {
1087
+ let heartIdentifiers: [HKQuantityTypeIdentifier] = [
1088
+ .heartRate,
1089
+ .restingHeartRate,
1090
+ .vo2Max,
1091
+ .heartRateVariabilitySDNN,
1092
+ .oxygenSaturation,
1093
+ .respiratoryRate
1094
+ ]
1095
+
1096
+ for identifier in heartIdentifiers {
1097
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1098
+ set.insert(quantityType)
1099
+ }
1100
+ }
1101
+ }
1102
+ // Special handling for body - expand into body composition types
1103
+ else if dataType == .body {
1104
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
1105
+ .bodyMass,
1106
+ .bodyFatPercentage
1107
+ ]
1108
+
1109
+ for identifier in bodyIdentifiers {
1110
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1111
+ set.insert(quantityType)
1112
+ }
1113
+ }
1114
+ }
1115
+ // Special handling for mobility - expand into all 6 HealthKit types
1116
+ else if dataType == .mobility {
1117
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
1118
+ .walkingSpeed,
1119
+ .walkingStepLength,
1120
+ .walkingAsymmetryPercentage,
1121
+ .walkingDoubleSupportPercentage,
1122
+ .stairAscentSpeed,
1123
+ .sixMinuteWalkTestDistance
1124
+ ]
1125
+
1126
+ for identifier in mobilityIdentifiers {
1127
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1128
+ set.insert(quantityType)
1129
+ }
1130
+ }
1131
+ } else {
1132
+ let type = try dataType.sampleType()
1133
+ set.insert(type)
1134
+ }
510
1135
  }
511
1136
  return set
512
1137
  }
@@ -514,9 +1139,925 @@ final class Health {
514
1139
  private func sampleTypes(for dataTypes: [HealthDataType]) throws -> Set<HKSampleType> {
515
1140
  var set = Set<HKSampleType>()
516
1141
  for dataType in dataTypes {
517
- let type = try dataType.sampleType() as HKSampleType
518
- set.insert(type)
1142
+ // Special handling for activity - expand into all activity-related HealthKit types
1143
+ if dataType == .activity {
1144
+ let activityIdentifiers: [HKQuantityTypeIdentifier] = [
1145
+ .stepCount,
1146
+ .distanceWalkingRunning,
1147
+ .flightsClimbed,
1148
+ .activeEnergyBurned,
1149
+ .appleExerciseTime
1150
+ ]
1151
+
1152
+ for identifier in activityIdentifiers {
1153
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1154
+ set.insert(quantityType)
1155
+ }
1156
+ }
1157
+
1158
+ // Add category type for stand hours
1159
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1160
+ set.insert(standHourType)
1161
+ }
1162
+ }
1163
+ // Special handling for heart - expand into all 6 HealthKit types
1164
+ else if dataType == .heart {
1165
+ let heartIdentifiers: [HKQuantityTypeIdentifier] = [
1166
+ .heartRate,
1167
+ .restingHeartRate,
1168
+ .vo2Max,
1169
+ .heartRateVariabilitySDNN,
1170
+ .oxygenSaturation,
1171
+ .respiratoryRate
1172
+ ]
1173
+
1174
+ for identifier in heartIdentifiers {
1175
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1176
+ set.insert(quantityType)
1177
+ }
1178
+ }
1179
+ }
1180
+ // Special handling for body - expand into body composition types
1181
+ else if dataType == .body {
1182
+ let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
1183
+ .bodyMass,
1184
+ .bodyFatPercentage
1185
+ ]
1186
+
1187
+ for identifier in bodyIdentifiers {
1188
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1189
+ set.insert(quantityType)
1190
+ }
1191
+ }
1192
+ }
1193
+ // Special handling for mobility - expand into all 6 HealthKit types
1194
+ else if dataType == .mobility {
1195
+ let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
1196
+ .walkingSpeed,
1197
+ .walkingStepLength,
1198
+ .walkingAsymmetryPercentage,
1199
+ .walkingDoubleSupportPercentage,
1200
+ .stairAscentSpeed,
1201
+ .sixMinuteWalkTestDistance
1202
+ ]
1203
+
1204
+ for identifier in mobilityIdentifiers {
1205
+ if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
1206
+ set.insert(quantityType)
1207
+ }
1208
+ }
1209
+ } else {
1210
+ let type = try dataType.sampleType() as HKSampleType
1211
+ set.insert(type)
1212
+ }
519
1213
  }
520
1214
  return set
521
1215
  }
1216
+
1217
+ // MARK: - Body Data Processing
1218
+
1219
+ private func processBodyData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1220
+ let calendar = Calendar.current
1221
+
1222
+ // Define body composition quantity types
1223
+ let bodyTypes: [(HKQuantityTypeIdentifier, String)] = [
1224
+ (.bodyMass, "bodyMass"),
1225
+ (.bodyFatPercentage, "bodyFatPercentage")
1226
+ ]
1227
+
1228
+ // Get all dates in the range
1229
+ var dates: [Date] = []
1230
+ var currentDate = calendar.startOfDay(for: startDate)
1231
+ let endOfDay = calendar.startOfDay(for: endDate)
1232
+
1233
+ while currentDate <= endOfDay {
1234
+ dates.append(currentDate)
1235
+ currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
1236
+ }
1237
+
1238
+ // Dictionary to store body data by date
1239
+ var bodyDataByDate: [String: [String: Any]] = [:]
1240
+
1241
+ // Initialize dates with empty dictionaries
1242
+ let dateFormatter = ISO8601DateFormatter()
1243
+ dateFormatter.formatOptions = [.withFullDate]
1244
+
1245
+ for date in dates {
1246
+ let dateStr = dateFormatter.string(from: date)
1247
+ bodyDataByDate[dateStr] = ["date": dateStr]
1248
+ }
1249
+
1250
+ let group = DispatchGroup()
1251
+
1252
+ // Query each body type
1253
+ for (identifier, _) in bodyTypes {
1254
+ guard let quantityType = HKObjectType.quantityType(forIdentifier: identifier) else {
1255
+ continue
1256
+ }
1257
+
1258
+ group.enter()
1259
+
1260
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1261
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
1262
+
1263
+ let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
1264
+ defer { group.leave() }
1265
+
1266
+ // Don't fail the entire request if one body type fails
1267
+ // Just skip this type and continue with others
1268
+ if error != nil {
1269
+ return
1270
+ }
1271
+
1272
+ guard let samples = samples as? [HKQuantitySample] else {
1273
+ return
1274
+ }
1275
+
1276
+ // Group samples by date and take the last measurement of each day
1277
+ var samplesByDate: [String: HKQuantitySample] = [:]
1278
+
1279
+ for sample in samples {
1280
+ let sampleDate = calendar.startOfDay(for: sample.startDate)
1281
+ let dateStr = dateFormatter.string(from: sampleDate)
1282
+
1283
+ // Keep the last (most recent) sample for each date
1284
+ if let existingSample = samplesByDate[dateStr] {
1285
+ if sample.startDate > existingSample.startDate {
1286
+ samplesByDate[dateStr] = sample
1287
+ }
1288
+ } else {
1289
+ samplesByDate[dateStr] = sample
1290
+ }
1291
+ }
1292
+
1293
+ // Process the last measurement for each date
1294
+ for (dateStr, sample) in samplesByDate {
1295
+ var dayData = bodyDataByDate[dateStr] ?? ["date": dateStr]
1296
+
1297
+ switch identifier {
1298
+ case .bodyMass:
1299
+ // Convert kg to lbs: kg * 2.20462
1300
+ let valueInKg = sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo))
1301
+ let valueInLbs = valueInKg * 2.20462
1302
+ dayData["weight"] = round(valueInLbs * 10) / 10 // 1 decimal place
1303
+
1304
+ case .bodyFatPercentage:
1305
+ // Convert decimal to percentage: 0.25 -> 25%
1306
+ let valueInDecimal = sample.quantity.doubleValue(for: HKUnit.percent())
1307
+ dayData["bodyFat"] = round(valueInDecimal * 10) / 10 // 1 decimal place
1308
+
1309
+ default:
1310
+ break
1311
+ }
1312
+
1313
+ bodyDataByDate[dateStr] = dayData
1314
+ }
1315
+ }
1316
+
1317
+ healthStore.execute(query)
1318
+ }
1319
+
1320
+ group.notify(queue: .main) {
1321
+ // Convert to array and sort by date
1322
+ let sortedData = bodyDataByDate.values
1323
+ .sorted { dict1, dict2 in
1324
+ guard let date1Str = dict1["date"] as? String,
1325
+ let date2Str = dict2["date"] as? String else {
1326
+ return false
1327
+ }
1328
+ return date1Str < date2Str
1329
+ }
1330
+ .filter { dict in
1331
+ // Only include dates that have at least one body measurement
1332
+ return dict.count > 1 // More than just the "date" field
1333
+ }
1334
+
1335
+ completion(.success(sortedData))
1336
+ }
1337
+ }
1338
+
1339
+ // MARK: - Mobility Data Processing
1340
+
1341
+ private func processMobilityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1342
+ let calendar = Calendar.current
1343
+
1344
+ // Define all mobility quantity types
1345
+ let mobilityTypes: [(HKQuantityTypeIdentifier, String)] = [
1346
+ (.walkingSpeed, "walkingSpeed"),
1347
+ (.walkingStepLength, "walkingStepLength"),
1348
+ (.walkingAsymmetryPercentage, "walkingAsymmetry"),
1349
+ (.walkingDoubleSupportPercentage, "walkingDoubleSupportTime"),
1350
+ (.stairAscentSpeed, "stairSpeed"),
1351
+ (.sixMinuteWalkTestDistance, "sixMinuteWalkDistance")
1352
+ ]
1353
+
1354
+ let group = DispatchGroup()
1355
+ let lock = NSLock()
1356
+
1357
+ // Maps to store values by date for each metric
1358
+ var walkingSpeedMap: [String: [Double]] = [:]
1359
+ var stepLengthMap: [String: [Double]] = [:]
1360
+ var asymmetryMap: [String: [Double]] = [:]
1361
+ var doubleSupportMap: [String: [Double]] = [:]
1362
+ var stairSpeedMap: [String: [Double]] = [:]
1363
+ var sixMinWalkMap: [String: [Double]] = [:]
1364
+
1365
+ var errors: [Error] = []
1366
+
1367
+ // Query each mobility metric
1368
+ for (typeIdentifier, key) in mobilityTypes {
1369
+ guard let quantityType = HKObjectType.quantityType(forIdentifier: typeIdentifier) else {
1370
+ continue
1371
+ }
1372
+
1373
+ group.enter()
1374
+
1375
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1376
+ let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1377
+ defer { group.leave() }
1378
+
1379
+ if let error = error {
1380
+ lock.lock()
1381
+ errors.append(error)
1382
+ lock.unlock()
1383
+ return
1384
+ }
1385
+
1386
+ guard let quantitySamples = samples as? [HKQuantitySample] else {
1387
+ return
1388
+ }
1389
+
1390
+ // Process samples and group by date
1391
+ for sample in quantitySamples {
1392
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1393
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1394
+
1395
+ var value: Double = 0.0
1396
+
1397
+ // Convert values to match XML parser units
1398
+ switch typeIdentifier {
1399
+ case .walkingSpeed:
1400
+ // Convert to mph (HealthKit stores in m/s)
1401
+ value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 2.23694
1402
+ lock.lock()
1403
+ if walkingSpeedMap[dateString] == nil {
1404
+ walkingSpeedMap[dateString] = []
1405
+ }
1406
+ walkingSpeedMap[dateString]?.append(value)
1407
+ lock.unlock()
1408
+
1409
+ case .walkingStepLength:
1410
+ // Convert to inches (HealthKit stores in meters)
1411
+ value = sample.quantity.doubleValue(for: HKUnit.meter()) * 39.3701
1412
+ lock.lock()
1413
+ if stepLengthMap[dateString] == nil {
1414
+ stepLengthMap[dateString] = []
1415
+ }
1416
+ stepLengthMap[dateString]?.append(value)
1417
+ lock.unlock()
1418
+
1419
+ case .walkingAsymmetryPercentage:
1420
+ // Convert to percentage (HealthKit stores as decimal)
1421
+ value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1422
+ lock.lock()
1423
+ if asymmetryMap[dateString] == nil {
1424
+ asymmetryMap[dateString] = []
1425
+ }
1426
+ asymmetryMap[dateString]?.append(value)
1427
+ lock.unlock()
1428
+
1429
+ case .walkingDoubleSupportPercentage:
1430
+ // Convert to percentage (HealthKit stores as decimal)
1431
+ value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1432
+ lock.lock()
1433
+ if doubleSupportMap[dateString] == nil {
1434
+ doubleSupportMap[dateString] = []
1435
+ }
1436
+ doubleSupportMap[dateString]?.append(value)
1437
+ lock.unlock()
1438
+
1439
+ case .stairAscentSpeed:
1440
+ // Convert to ft/s (HealthKit stores in m/s)
1441
+ value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 3.28084
1442
+ lock.lock()
1443
+ if stairSpeedMap[dateString] == nil {
1444
+ stairSpeedMap[dateString] = []
1445
+ }
1446
+ stairSpeedMap[dateString]?.append(value)
1447
+ lock.unlock()
1448
+
1449
+ case .sixMinuteWalkTestDistance:
1450
+ // Convert to yards (HealthKit stores in meters)
1451
+ value = sample.quantity.doubleValue(for: HKUnit.meter()) * 1.09361
1452
+ lock.lock()
1453
+ if sixMinWalkMap[dateString] == nil {
1454
+ sixMinWalkMap[dateString] = []
1455
+ }
1456
+ sixMinWalkMap[dateString]?.append(value)
1457
+ lock.unlock()
1458
+
1459
+ default:
1460
+ break
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ healthStore.execute(query)
1466
+ }
1467
+
1468
+ group.notify(queue: .main) {
1469
+ if !errors.isEmpty {
1470
+ completion(.failure(errors.first!))
1471
+ return
1472
+ }
1473
+
1474
+ // Collect all unique dates
1475
+ var allDates = Set<String>()
1476
+ allDates.formUnion(walkingSpeedMap.keys)
1477
+ allDates.formUnion(stepLengthMap.keys)
1478
+ allDates.formUnion(asymmetryMap.keys)
1479
+ allDates.formUnion(doubleSupportMap.keys)
1480
+ allDates.formUnion(stairSpeedMap.keys)
1481
+ allDates.formUnion(sixMinWalkMap.keys)
1482
+
1483
+ // Create mobility data array with aggregated daily averages
1484
+ var mobilityData: [[String: Any]] = []
1485
+
1486
+ for date in allDates.sorted() {
1487
+ var result: [String: Any] = ["date": date]
1488
+
1489
+ // Average all measurements for the day
1490
+ if let values = walkingSpeedMap[date], !values.isEmpty {
1491
+ let average = values.reduce(0.0, +) / Double(values.count)
1492
+ result["walkingSpeed"] = round(average * 100) / 100
1493
+ }
1494
+
1495
+ if let values = stepLengthMap[date], !values.isEmpty {
1496
+ let average = values.reduce(0.0, +) / Double(values.count)
1497
+ result["walkingStepLength"] = round(average * 10) / 10
1498
+ }
1499
+
1500
+ if let values = asymmetryMap[date], !values.isEmpty {
1501
+ let average = values.reduce(0.0, +) / Double(values.count)
1502
+ result["walkingAsymmetry"] = round(average * 10) / 10
1503
+ }
1504
+
1505
+ if let values = doubleSupportMap[date], !values.isEmpty {
1506
+ let average = values.reduce(0.0, +) / Double(values.count)
1507
+ result["walkingDoubleSupportTime"] = round(average * 10) / 10
1508
+ }
1509
+
1510
+ if let values = stairSpeedMap[date], !values.isEmpty {
1511
+ let average = values.reduce(0.0, +) / Double(values.count)
1512
+ result["stairSpeed"] = round(average * 100) / 100
1513
+ }
1514
+
1515
+ if let values = sixMinWalkMap[date], !values.isEmpty {
1516
+ let average = values.reduce(0.0, +) / Double(values.count)
1517
+ result["sixMinuteWalkDistance"] = round(average * 10) / 10
1518
+ }
1519
+
1520
+ mobilityData.append(result)
1521
+ }
1522
+
1523
+ completion(.success(mobilityData))
1524
+ }
1525
+ }
1526
+
1527
+ // MARK: - Activity Data Processing
1528
+
1529
+ private func processActivityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1530
+ let calendar = Calendar.current
1531
+ let group = DispatchGroup()
1532
+ let lock = NSLock()
1533
+
1534
+ // Maps to store values by date for each metric
1535
+ var stepsMap: [String: Double] = [:]
1536
+ var distanceMap: [String: Double] = [:]
1537
+ var flightsMap: [String: Double] = [:]
1538
+ var activeEnergyMap: [String: Double] = [:]
1539
+ var exerciseMinutesMap: [String: Double] = [:]
1540
+ var standHoursMap: [String: Int] = [:]
1541
+
1542
+ var errors: [Error] = []
1543
+
1544
+ // === STEPS ===
1545
+ if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) {
1546
+ group.enter()
1547
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1548
+ let query = HKSampleQuery(sampleType: stepsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1549
+ defer { group.leave() }
1550
+
1551
+ if let error = error {
1552
+ lock.lock()
1553
+ errors.append(error)
1554
+ lock.unlock()
1555
+ return
1556
+ }
1557
+
1558
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1559
+
1560
+ for sample in quantitySamples {
1561
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1562
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1563
+
1564
+ let steps = sample.quantity.doubleValue(for: HKUnit.count())
1565
+ lock.lock()
1566
+ stepsMap[dateString] = (stepsMap[dateString] ?? 0) + steps
1567
+ lock.unlock()
1568
+ }
1569
+ }
1570
+ healthStore.execute(query)
1571
+ }
1572
+
1573
+ // === DISTANCE (Walking + Running) ===
1574
+ if let distanceType = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) {
1575
+ group.enter()
1576
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1577
+ let query = HKSampleQuery(sampleType: distanceType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1578
+ defer { group.leave() }
1579
+
1580
+ if let error = error {
1581
+ lock.lock()
1582
+ errors.append(error)
1583
+ lock.unlock()
1584
+ return
1585
+ }
1586
+
1587
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1588
+
1589
+ for sample in quantitySamples {
1590
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1591
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1592
+
1593
+ // Convert to miles (HealthKit stores in meters)
1594
+ let distanceMeters = sample.quantity.doubleValue(for: HKUnit.meter())
1595
+ let distanceMiles = distanceMeters * 0.000621371
1596
+ lock.lock()
1597
+ distanceMap[dateString] = (distanceMap[dateString] ?? 0) + distanceMiles
1598
+ lock.unlock()
1599
+ }
1600
+ }
1601
+ healthStore.execute(query)
1602
+ }
1603
+
1604
+ // === FLIGHTS CLIMBED ===
1605
+ if let flightsType = HKObjectType.quantityType(forIdentifier: .flightsClimbed) {
1606
+ group.enter()
1607
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1608
+ let query = HKSampleQuery(sampleType: flightsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1609
+ defer { group.leave() }
1610
+
1611
+ if let error = error {
1612
+ lock.lock()
1613
+ errors.append(error)
1614
+ lock.unlock()
1615
+ return
1616
+ }
1617
+
1618
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1619
+
1620
+ for sample in quantitySamples {
1621
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1622
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1623
+
1624
+ let flights = sample.quantity.doubleValue(for: HKUnit.count())
1625
+ lock.lock()
1626
+ flightsMap[dateString] = (flightsMap[dateString] ?? 0) + flights
1627
+ lock.unlock()
1628
+ }
1629
+ }
1630
+ healthStore.execute(query)
1631
+ }
1632
+
1633
+ // === ACTIVE ENERGY ===
1634
+ if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
1635
+ group.enter()
1636
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1637
+ let query = HKSampleQuery(sampleType: activeEnergyType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1638
+ defer { group.leave() }
1639
+
1640
+ if let error = error {
1641
+ lock.lock()
1642
+ errors.append(error)
1643
+ lock.unlock()
1644
+ return
1645
+ }
1646
+
1647
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1648
+
1649
+ for sample in quantitySamples {
1650
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1651
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1652
+
1653
+ let calories = sample.quantity.doubleValue(for: HKUnit.kilocalorie())
1654
+ lock.lock()
1655
+ activeEnergyMap[dateString] = (activeEnergyMap[dateString] ?? 0) + calories
1656
+ lock.unlock()
1657
+ }
1658
+ }
1659
+ healthStore.execute(query)
1660
+ }
1661
+
1662
+ // === EXERCISE MINUTES ===
1663
+ if let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) {
1664
+ group.enter()
1665
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1666
+ let query = HKSampleQuery(sampleType: exerciseType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1667
+ defer { group.leave() }
1668
+
1669
+ if let error = error {
1670
+ lock.lock()
1671
+ errors.append(error)
1672
+ lock.unlock()
1673
+ return
1674
+ }
1675
+
1676
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1677
+
1678
+ for sample in quantitySamples {
1679
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1680
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1681
+
1682
+ let minutes = sample.quantity.doubleValue(for: HKUnit.minute())
1683
+ lock.lock()
1684
+ exerciseMinutesMap[dateString] = (exerciseMinutesMap[dateString] ?? 0) + minutes
1685
+ lock.unlock()
1686
+ }
1687
+ }
1688
+ healthStore.execute(query)
1689
+ }
1690
+
1691
+ // === STAND HOURS ===
1692
+ if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1693
+ group.enter()
1694
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1695
+ let query = HKSampleQuery(sampleType: standHourType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1696
+ defer { group.leave() }
1697
+
1698
+ if let error = error {
1699
+ lock.lock()
1700
+ errors.append(error)
1701
+ lock.unlock()
1702
+ return
1703
+ }
1704
+
1705
+ guard let categorySamples = samples as? [HKCategorySample] else { return }
1706
+
1707
+ for sample in categorySamples {
1708
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1709
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1710
+
1711
+ // Value is HKCategoryValueAppleStandHour.stood (1) or .idle (0)
1712
+ if sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
1713
+ lock.lock()
1714
+ standHoursMap[dateString] = (standHoursMap[dateString] ?? 0) + 1
1715
+ lock.unlock()
1716
+ }
1717
+ }
1718
+ }
1719
+ healthStore.execute(query)
1720
+ }
1721
+
1722
+ group.notify(queue: .main) {
1723
+ // Only fail if there are errors AND no data was collected
1724
+ // This allows partial data to be returned if some permissions are denied
1725
+ if !errors.isEmpty {
1726
+ // Check if any data was actually collected
1727
+ let hasData = !stepsMap.isEmpty || !distanceMap.isEmpty || !flightsMap.isEmpty ||
1728
+ !activeEnergyMap.isEmpty || !exerciseMinutesMap.isEmpty || !standHoursMap.isEmpty
1729
+
1730
+ if !hasData {
1731
+ // No data at all, return the error
1732
+ completion(.failure(errors.first!))
1733
+ return
1734
+ }
1735
+ // If we have some data, continue and return what we have
1736
+ }
1737
+
1738
+ // Collect all unique dates that have any activity data
1739
+ var allDates = Set<String>()
1740
+ allDates.formUnion(stepsMap.keys)
1741
+ allDates.formUnion(distanceMap.keys)
1742
+ allDates.formUnion(flightsMap.keys)
1743
+ allDates.formUnion(activeEnergyMap.keys)
1744
+ allDates.formUnion(exerciseMinutesMap.keys)
1745
+ allDates.formUnion(standHoursMap.keys)
1746
+
1747
+ // Create activity data array matching the reference format
1748
+ let activityData: [[String: Any]] = allDates.sorted().compactMap { date in
1749
+ var result: [String: Any] = ["date": date]
1750
+
1751
+ // Add each metric if available, with proper rounding
1752
+ if let steps = stepsMap[date] {
1753
+ result["steps"] = Int(round(steps))
1754
+ }
1755
+
1756
+ if let distance = distanceMap[date] {
1757
+ result["distance"] = round(distance * 100) / 100
1758
+ }
1759
+
1760
+ if let flights = flightsMap[date] {
1761
+ result["flightsClimbed"] = Int(round(flights))
1762
+ }
1763
+
1764
+ if let activeEnergy = activeEnergyMap[date] {
1765
+ result["activeEnergy"] = Int(round(activeEnergy))
1766
+ }
1767
+
1768
+ if let exerciseMinutes = exerciseMinutesMap[date] {
1769
+ result["exerciseMinutes"] = Int(round(exerciseMinutes))
1770
+ }
1771
+
1772
+ if let standHours = standHoursMap[date] {
1773
+ result["standHours"] = standHours
1774
+ }
1775
+
1776
+ return result
1777
+ }
1778
+
1779
+ completion(.success(activityData))
1780
+ }
1781
+ }
1782
+
1783
+ // MARK: - Heart Data Processing
1784
+
1785
+ private func processHeartData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1786
+ let calendar = Calendar.current
1787
+ let group = DispatchGroup()
1788
+ let lock = NSLock()
1789
+
1790
+ // Maps to store values by date for each metric
1791
+ struct HeartRateStats {
1792
+ var sum: Double = 0
1793
+ var count: Int = 0
1794
+ var min: Double = 999
1795
+ var max: Double = 0
1796
+ }
1797
+
1798
+ var heartRateMap: [String: HeartRateStats] = [:]
1799
+ var restingHRMap: [String: Double] = [:]
1800
+ var vo2MaxMap: [String: Double] = [:]
1801
+ var hrvMap: [String: Double] = [:]
1802
+ var spo2Map: [String: [Double]] = [:]
1803
+ var respirationRateMap: [String: [Double]] = [:]
1804
+
1805
+ var errors: [Error] = []
1806
+
1807
+ // === HEART RATE ===
1808
+ if let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) {
1809
+ group.enter()
1810
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1811
+ let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1812
+ defer { group.leave() }
1813
+
1814
+ if let error = error {
1815
+ lock.lock()
1816
+ errors.append(error)
1817
+ lock.unlock()
1818
+ return
1819
+ }
1820
+
1821
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1822
+
1823
+ for sample in quantitySamples {
1824
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1825
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1826
+
1827
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
1828
+ lock.lock()
1829
+ var stats = heartRateMap[dateString] ?? HeartRateStats()
1830
+ stats.sum += bpm
1831
+ stats.count += 1
1832
+ stats.min = min(stats.min, bpm)
1833
+ stats.max = max(stats.max, bpm)
1834
+ heartRateMap[dateString] = stats
1835
+ lock.unlock()
1836
+ }
1837
+ }
1838
+ healthStore.execute(query)
1839
+ }
1840
+
1841
+ // === RESTING HEART RATE ===
1842
+ if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) {
1843
+ group.enter()
1844
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1845
+ let query = HKSampleQuery(sampleType: restingHRType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1846
+ defer { group.leave() }
1847
+
1848
+ if let error = error {
1849
+ lock.lock()
1850
+ errors.append(error)
1851
+ lock.unlock()
1852
+ return
1853
+ }
1854
+
1855
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1856
+
1857
+ for sample in quantitySamples {
1858
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1859
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1860
+
1861
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
1862
+ lock.lock()
1863
+ restingHRMap[dateString] = bpm // Take last measurement
1864
+ lock.unlock()
1865
+ }
1866
+ }
1867
+ healthStore.execute(query)
1868
+ }
1869
+
1870
+ // === VO2 MAX ===
1871
+ if let vo2MaxType = HKObjectType.quantityType(forIdentifier: .vo2Max) {
1872
+ group.enter()
1873
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1874
+ let query = HKSampleQuery(sampleType: vo2MaxType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1875
+ defer { group.leave() }
1876
+
1877
+ if let error = error {
1878
+ lock.lock()
1879
+ errors.append(error)
1880
+ lock.unlock()
1881
+ return
1882
+ }
1883
+
1884
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1885
+
1886
+ for sample in quantitySamples {
1887
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1888
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1889
+
1890
+ let vo2 = sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli).unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: HKUnit.minute())))
1891
+ lock.lock()
1892
+ vo2MaxMap[dateString] = vo2 // Take last measurement
1893
+ lock.unlock()
1894
+ }
1895
+ }
1896
+ healthStore.execute(query)
1897
+ }
1898
+
1899
+ // === HRV (Heart Rate Variability SDNN) ===
1900
+ if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
1901
+ group.enter()
1902
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1903
+ let query = HKSampleQuery(sampleType: hrvType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1904
+ defer { group.leave() }
1905
+
1906
+ if let error = error {
1907
+ lock.lock()
1908
+ errors.append(error)
1909
+ lock.unlock()
1910
+ return
1911
+ }
1912
+
1913
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1914
+
1915
+ for sample in quantitySamples {
1916
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1917
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1918
+
1919
+ let hrv = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli))
1920
+ lock.lock()
1921
+ hrvMap[dateString] = hrv // Take last measurement
1922
+ lock.unlock()
1923
+ }
1924
+ }
1925
+ healthStore.execute(query)
1926
+ }
1927
+
1928
+ // === SPO2 (Blood Oxygen Saturation) ===
1929
+ if let spo2Type = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) {
1930
+ group.enter()
1931
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1932
+ let query = HKSampleQuery(sampleType: spo2Type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1933
+ defer { group.leave() }
1934
+
1935
+ if let error = error {
1936
+ lock.lock()
1937
+ errors.append(error)
1938
+ lock.unlock()
1939
+ return
1940
+ }
1941
+
1942
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1943
+
1944
+ for sample in quantitySamples {
1945
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1946
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1947
+
1948
+ // HealthKit stores as decimal (0.96), convert to percentage (96)
1949
+ let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1950
+ lock.lock()
1951
+ if spo2Map[dateString] == nil {
1952
+ spo2Map[dateString] = []
1953
+ }
1954
+ spo2Map[dateString]?.append(value)
1955
+ lock.unlock()
1956
+ }
1957
+ }
1958
+ healthStore.execute(query)
1959
+ }
1960
+
1961
+ // === RESPIRATION RATE ===
1962
+ if let respirationRateType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) {
1963
+ group.enter()
1964
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1965
+ let query = HKSampleQuery(sampleType: respirationRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1966
+ defer { group.leave() }
1967
+
1968
+ if let error = error {
1969
+ lock.lock()
1970
+ errors.append(error)
1971
+ lock.unlock()
1972
+ return
1973
+ }
1974
+
1975
+ guard let quantitySamples = samples as? [HKQuantitySample] else { return }
1976
+
1977
+ for sample in quantitySamples {
1978
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1979
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1980
+
1981
+ let breathsPerMin = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
1982
+ lock.lock()
1983
+ if respirationRateMap[dateString] == nil {
1984
+ respirationRateMap[dateString] = []
1985
+ }
1986
+ respirationRateMap[dateString]?.append(breathsPerMin)
1987
+ lock.unlock()
1988
+ }
1989
+ }
1990
+ healthStore.execute(query)
1991
+ }
1992
+
1993
+ group.notify(queue: .main) {
1994
+ // Only fail if there are errors AND no data was collected
1995
+ // This allows partial data to be returned if some permissions are denied
1996
+ if !errors.isEmpty {
1997
+ // Check if any data was actually collected
1998
+ let hasData = !heartRateMap.isEmpty || !restingHRMap.isEmpty || !vo2MaxMap.isEmpty ||
1999
+ !hrvMap.isEmpty || !spo2Map.isEmpty || !respirationRateMap.isEmpty
2000
+
2001
+ if !hasData {
2002
+ // No data at all, return the error
2003
+ completion(.failure(errors.first!))
2004
+ return
2005
+ }
2006
+ // If we have some data, continue and return what we have
2007
+ }
2008
+
2009
+ // Collect all unique dates that have any heart data
2010
+ var allDates = Set<String>()
2011
+ allDates.formUnion(heartRateMap.keys)
2012
+ allDates.formUnion(restingHRMap.keys)
2013
+ allDates.formUnion(vo2MaxMap.keys)
2014
+ allDates.formUnion(hrvMap.keys)
2015
+ allDates.formUnion(spo2Map.keys)
2016
+ allDates.formUnion(respirationRateMap.keys)
2017
+
2018
+ // Create heart data array matching the TypeScript format
2019
+ let heartData: [[String: Any]] = allDates.sorted().compactMap { date in
2020
+ var result: [String: Any] = ["date": date]
2021
+
2022
+ // Heart Rate (avg, min, max, count)
2023
+ if let stats = heartRateMap[date] {
2024
+ result["avgBpm"] = Int(round(stats.sum / Double(stats.count)))
2025
+ result["minBpm"] = Int(round(stats.min))
2026
+ result["maxBpm"] = Int(round(stats.max))
2027
+ result["heartRateMeasurements"] = stats.count
2028
+ }
2029
+
2030
+ // Resting Heart Rate
2031
+ if let restingBpm = restingHRMap[date] {
2032
+ result["restingBpm"] = Int(round(restingBpm))
2033
+ }
2034
+
2035
+ // VO2 Max (rounded to 1 decimal)
2036
+ if let vo2 = vo2MaxMap[date] {
2037
+ result["vo2Max"] = round(vo2 * 10) / 10
2038
+ }
2039
+
2040
+ // HRV (rounded to 3 decimals)
2041
+ if let hrv = hrvMap[date] {
2042
+ result["hrv"] = round(hrv * 1000) / 1000
2043
+ }
2044
+
2045
+ // SpO2 (average)
2046
+ if let readings = spo2Map[date], !readings.isEmpty {
2047
+ let average = readings.reduce(0.0, +) / Double(readings.count)
2048
+ result["spo2"] = Int(round(average))
2049
+ }
2050
+
2051
+ // Respiration Rate (average, rounded to 1 decimal)
2052
+ if let readings = respirationRateMap[date], !readings.isEmpty {
2053
+ let average = readings.reduce(0.0, +) / Double(readings.count)
2054
+ result["respirationRate"] = round(average * 10) / 10
2055
+ }
2056
+
2057
+ return result
2058
+ }
2059
+
2060
+ completion(.success(heartData))
2061
+ }
2062
+ }
522
2063
  }