@interval-health/capacitor-health 1.1.1 → 1.3.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.
@@ -351,9 +351,26 @@ final class Health {
351
351
 
352
352
  // For all other data types, use the standard query approach
353
353
  let sampleType = try dataType.sampleType()
354
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
354
+
355
+ // For sleep data, extend the query window to capture overnight sleep
356
+ // Sleep typically starts in the evening (e.g., 10 PM) but should be attributed to the next day
357
+ // So we need to fetch samples that started up to 12 hours before the requested start date
358
+ let queryStartDate: Date
359
+ if dataType == .sleep {
360
+ // Extend query 12 hours earlier to capture overnight sleep that started previous evening
361
+ queryStartDate = startDate.addingTimeInterval(-12 * 60 * 60) // -12 hours
362
+ } else {
363
+ queryStartDate = startDate
364
+ }
365
+
366
+ // Use .strictStartDate to include samples that start within the range (even if they end after endDate)
367
+ let predicate = HKQuery.predicateForSamples(withStart: queryStartDate, end: endDate, options: .strictStartDate)
355
368
  let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
356
- let queryLimit = limit ?? 100
369
+
370
+ // Use no limit by default to ensure all samples are retrieved
371
+ // This is important for data types like sleep, heart rate, steps, etc.
372
+ // where a single day or long date ranges can have many samples
373
+ let queryLimit = limit ?? HKObjectQueryNoLimit
357
374
 
358
375
  let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: queryLimit, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, error in
359
376
  guard let self = self else { return }
@@ -842,16 +859,23 @@ final class Health {
842
859
  }
843
860
 
844
861
  private func processSleepSamples(_ samples: [HKCategorySample]) -> [[String: Any]] {
845
- // Filter for detailed stage data only (Deep, REM, Core, Awake)
862
+ // Filter for sleep stage data - include both detailed stages (iOS 16+) and legacy values
863
+ // This ensures we capture data from all sources (Apple Watch, third-party apps, etc.)
846
864
  let detailedSamples = samples.filter { sample in
847
865
  if #available(iOS 16.0, *) {
866
+ // Include all sleep-related values:
867
+ // - Detailed stages: asleepDeep, asleepREM, asleepCore, awake (iOS 16+)
868
+ // - Legacy/basic: asleepUnspecified, asleep (for older data or third-party apps)
869
+ // - Exclude: inBed (not actual sleep, just time in bed)
848
870
  return sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
849
871
  sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
850
872
  sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
851
- sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
873
+ sample.value == HKCategoryValueSleepAnalysis.awake.rawValue ||
874
+ sample.value == HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue
852
875
  } else {
853
- // For older iOS, just process what's available
854
- return true
876
+ // For older iOS, include asleep and awake, exclude inBed
877
+ return sample.value == HKCategoryValueSleepAnalysis.asleep.rawValue ||
878
+ sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
855
879
  }
856
880
  }
857
881
 
@@ -920,22 +944,41 @@ final class Health {
920
944
  ))
921
945
  }
922
946
 
923
- // Now attribute each session to the day you WOKE UP (end date)
947
+ // Attribute sleep based on 6 PM rule:
948
+ // - Sleep starting AFTER 6 PM → attribute to the NEXT day (overnight sleep)
949
+ // - Sleep starting BEFORE 6 PM → attribute to the SAME day (naps)
950
+ // This makes logical sense because evening sleep continues into the next morning
924
951
  struct DayData {
925
952
  var sessions: [SleepSession]
926
953
  var deepMinutes: Double
927
954
  var remMinutes: Double
928
955
  var coreMinutes: Double
929
956
  var awakeMinutes: Double
957
+ var unspecifiedMinutes: Double // For legacy/third-party sleep data without stage details
930
958
  }
931
959
 
932
960
  var sleepByDate: [String: DayData] = [:]
933
961
 
934
962
  for session in sessions {
935
- // Wake-up date (local)
936
963
  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!)
964
+
965
+ // Get the hour when sleep started
966
+ let startHour = calendar.component(.hour, from: session.start)
967
+
968
+ // Determine which date this sleep belongs to:
969
+ // If sleep starts at or after 6 PM (18:00), it belongs to the NEXT day
970
+ // If sleep starts before 6 PM, it belongs to the SAME day (e.g., naps)
971
+ let attributionDate: Date
972
+ if startHour >= 18 {
973
+ // Sleep started at/after 6 PM - attribute to next day
974
+ attributionDate = calendar.date(byAdding: .day, value: 1, to: session.start)!
975
+ } else {
976
+ // Sleep started before 6 PM (nap or early morning) - attribute to same day
977
+ attributionDate = session.start
978
+ }
979
+
980
+ let dateComponents = calendar.dateComponents([.year, .month, .day], from: attributionDate)
981
+ let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
939
982
 
940
983
  // Initialize day data if needed
941
984
  if sleepByDate[dateString] == nil {
@@ -944,7 +987,8 @@ final class Health {
944
987
  deepMinutes: 0,
945
988
  remMinutes: 0,
946
989
  coreMinutes: 0,
947
- awakeMinutes: 0
990
+ awakeMinutes: 0,
991
+ unspecifiedMinutes: 0
948
992
  )
949
993
  }
950
994
 
@@ -962,6 +1006,10 @@ final class Health {
962
1006
  sleepByDate[dateString]!.coreMinutes += minutes
963
1007
  } else if segment.stage == "HKCategoryValueSleepAnalysisAwake" {
964
1008
  sleepByDate[dateString]!.awakeMinutes += minutes
1009
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAsleepUnspecified" ||
1010
+ segment.stage == "HKCategoryValueSleepAnalysisAsleep" {
1011
+ // Legacy sleep data or third-party apps that don't provide stage details
1012
+ sleepByDate[dateString]!.unspecifiedMinutes += minutes
965
1013
  }
966
1014
  }
967
1015
  }
@@ -974,14 +1022,15 @@ final class Health {
974
1022
  let remHours = data.remMinutes / 60.0
975
1023
  let coreHours = data.coreMinutes / 60.0
976
1024
  let awakeHours = data.awakeMinutes / 60.0
977
- let totalSleepHours = deepHours + remHours + coreHours
1025
+ let unspecifiedHours = data.unspecifiedMinutes / 60.0
1026
+ // Include unspecified sleep in total (this captures legacy/third-party app data)
1027
+ let totalSleepHours = deepHours + remHours + coreHours + unspecifiedHours
978
1028
 
979
- // Calculate time in bed from merged sessions (first start to last end)
1029
+ // Calculate time in bed by summing each session's duration
1030
+ // This avoids counting gaps between sessions (e.g., nap + main sleep)
980
1031
  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
1032
+ for session in data.sessions {
1033
+ timeInBed += session.end.timeIntervalSince(session.start) / 3600.0
985
1034
  }
986
1035
 
987
1036
  // Calculate efficiency
@@ -1011,6 +1060,7 @@ final class Health {
1011
1060
  "deepSleep": round(deepHours * 10) / 10,
1012
1061
  "remSleep": round(remHours * 10) / 10,
1013
1062
  "coreSleep": round(coreHours * 10) / 10,
1063
+ "unspecifiedSleep": round(unspecifiedHours * 10) / 10, // Legacy/third-party sleep without stage details
1014
1064
  "awakeTime": round(awakeHours * 10) / 10,
1015
1065
  "timeInBed": round(timeInBed * 10) / 10,
1016
1066
  "efficiency": efficiency,
@@ -1403,7 +1453,7 @@ final class Health {
1403
1453
 
1404
1454
  group.enter()
1405
1455
 
1406
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1456
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1407
1457
  let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1408
1458
  defer { group.leave() }
1409
1459
 
@@ -1448,7 +1498,7 @@ final class Health {
1448
1498
  lock.unlock()
1449
1499
 
1450
1500
  case .walkingAsymmetryPercentage:
1451
- // Convert to percentage (HealthKit stores as decimal)
1501
+ // HKUnit.percent() returns fractional value (0.05 = 5%), multiply by 100 for percentage
1452
1502
  value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1453
1503
  lock.lock()
1454
1504
  if asymmetryMap[dateString] == nil {
@@ -1458,7 +1508,7 @@ final class Health {
1458
1508
  lock.unlock()
1459
1509
 
1460
1510
  case .walkingDoubleSupportPercentage:
1461
- // Convert to percentage (HealthKit stores as decimal)
1511
+ // HKUnit.percent() returns fractional value (0.30 = 30%), multiply by 100 for percentage
1462
1512
  value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1463
1513
  lock.lock()
1464
1514
  if doubleSupportMap[dateString] == nil {
@@ -1587,7 +1637,7 @@ final class Health {
1587
1637
  // === STEPS ===
1588
1638
  if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) {
1589
1639
  group.enter()
1590
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1640
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1591
1641
  let query = HKSampleQuery(sampleType: stepsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1592
1642
  defer { group.leave() }
1593
1643
 
@@ -1616,7 +1666,7 @@ final class Health {
1616
1666
  // === DISTANCE (Walking + Running) ===
1617
1667
  if let distanceType = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) {
1618
1668
  group.enter()
1619
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1669
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1620
1670
  let query = HKSampleQuery(sampleType: distanceType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1621
1671
  defer { group.leave() }
1622
1672
 
@@ -1647,7 +1697,7 @@ final class Health {
1647
1697
  // === FLIGHTS CLIMBED ===
1648
1698
  if let flightsType = HKObjectType.quantityType(forIdentifier: .flightsClimbed) {
1649
1699
  group.enter()
1650
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1700
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1651
1701
  let query = HKSampleQuery(sampleType: flightsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1652
1702
  defer { group.leave() }
1653
1703
 
@@ -1676,7 +1726,7 @@ final class Health {
1676
1726
  // === ACTIVE ENERGY ===
1677
1727
  if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
1678
1728
  group.enter()
1679
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1729
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1680
1730
  let query = HKSampleQuery(sampleType: activeEnergyType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1681
1731
  defer { group.leave() }
1682
1732
 
@@ -1705,7 +1755,7 @@ final class Health {
1705
1755
  // === EXERCISE MINUTES ===
1706
1756
  if let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) {
1707
1757
  group.enter()
1708
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1758
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1709
1759
  let query = HKSampleQuery(sampleType: exerciseType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1710
1760
  defer { group.leave() }
1711
1761
 
@@ -1734,7 +1784,7 @@ final class Health {
1734
1784
  // === STAND HOURS ===
1735
1785
  if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1736
1786
  group.enter()
1737
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1787
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1738
1788
  let query = HKSampleQuery(sampleType: standHourType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1739
1789
  defer { group.leave() }
1740
1790
 
@@ -1862,7 +1912,7 @@ final class Health {
1862
1912
  // === HEART RATE ===
1863
1913
  if let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) {
1864
1914
  group.enter()
1865
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1915
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1866
1916
  let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1867
1917
  defer { group.leave() }
1868
1918
 
@@ -1896,7 +1946,7 @@ final class Health {
1896
1946
  // === RESTING HEART RATE ===
1897
1947
  if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) {
1898
1948
  group.enter()
1899
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1949
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1900
1950
  let query = HKSampleQuery(sampleType: restingHRType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1901
1951
  defer { group.leave() }
1902
1952
 
@@ -1925,7 +1975,7 @@ final class Health {
1925
1975
  // === VO2 MAX ===
1926
1976
  if let vo2MaxType = HKObjectType.quantityType(forIdentifier: .vo2Max) {
1927
1977
  group.enter()
1928
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1978
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1929
1979
  let query = HKSampleQuery(sampleType: vo2MaxType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1930
1980
  defer { group.leave() }
1931
1981
 
@@ -1954,7 +2004,7 @@ final class Health {
1954
2004
  // === HRV (Heart Rate Variability SDNN) ===
1955
2005
  if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
1956
2006
  group.enter()
1957
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2007
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1958
2008
  let query = HKSampleQuery(sampleType: hrvType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1959
2009
  defer { group.leave() }
1960
2010
 
@@ -1983,7 +2033,7 @@ final class Health {
1983
2033
  // === SPO2 (Blood Oxygen Saturation) ===
1984
2034
  if let spo2Type = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) {
1985
2035
  group.enter()
1986
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2036
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1987
2037
  let query = HKSampleQuery(sampleType: spo2Type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1988
2038
  defer { group.leave() }
1989
2039
 
@@ -2000,7 +2050,7 @@ final class Health {
2000
2050
  let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
2001
2051
  let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
2002
2052
 
2003
- // HealthKit stores as decimal (0.96), convert to percentage (96)
2053
+ // HKUnit.percent() returns fractional value (0.98 = 98%), multiply by 100 for percentage
2004
2054
  let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
2005
2055
  lock.lock()
2006
2056
  if spo2Map[dateString] == nil {
@@ -2016,7 +2066,7 @@ final class Health {
2016
2066
  // === RESPIRATION RATE ===
2017
2067
  if let respirationRateType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) {
2018
2068
  group.enter()
2019
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2069
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
2020
2070
  let query = HKSampleQuery(sampleType: respirationRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
2021
2071
  defer { group.leave() }
2022
2072
 
@@ -2125,7 +2175,7 @@ final class Health {
2125
2175
 
2126
2176
  private func processWorkoutData(startDate: Date, endDate: Date, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
2127
2177
  let workoutType = HKObjectType.workoutType()
2128
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2178
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
2129
2179
  let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
2130
2180
  let queryLimit = limit ?? HKObjectQueryNoLimit
2131
2181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interval-health/capacitor-health",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Capacitor plugin to interact with data from Apple HealthKit and Health Connect",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",