@interval-health/capacitor-health 1.1.0 → 1.2.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,14 @@ 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
+ // Use .strictStartDate to include samples that start within the range (even if they end after endDate)
355
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
355
356
  let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
356
- let queryLimit = limit ?? 100
357
+
358
+ // Use no limit by default to ensure all samples are retrieved
359
+ // This is important for data types like sleep, heart rate, steps, etc.
360
+ // where a single day or long date ranges can have many samples
361
+ let queryLimit = limit ?? HKObjectQueryNoLimit
357
362
 
358
363
  let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: queryLimit, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, error in
359
364
  guard let self = self else { return }
@@ -842,16 +847,23 @@ final class Health {
842
847
  }
843
848
 
844
849
  private func processSleepSamples(_ samples: [HKCategorySample]) -> [[String: Any]] {
845
- // Filter for detailed stage data only (Deep, REM, Core, Awake)
850
+ // Filter for sleep stage data - include both detailed stages (iOS 16+) and legacy values
851
+ // This ensures we capture data from all sources (Apple Watch, third-party apps, etc.)
846
852
  let detailedSamples = samples.filter { sample in
847
853
  if #available(iOS 16.0, *) {
854
+ // Include all sleep-related values:
855
+ // - Detailed stages: asleepDeep, asleepREM, asleepCore, awake (iOS 16+)
856
+ // - Legacy/basic: asleepUnspecified, asleep (for older data or third-party apps)
857
+ // - Exclude: inBed (not actual sleep, just time in bed)
848
858
  return sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
849
859
  sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
850
860
  sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
851
- sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
861
+ sample.value == HKCategoryValueSleepAnalysis.awake.rawValue ||
862
+ sample.value == HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue
852
863
  } else {
853
- // For older iOS, just process what's available
854
- return true
864
+ // For older iOS, include asleep and awake, exclude inBed
865
+ return sample.value == HKCategoryValueSleepAnalysis.asleep.rawValue ||
866
+ sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
855
867
  }
856
868
  }
857
869
 
@@ -927,6 +939,7 @@ final class Health {
927
939
  var remMinutes: Double
928
940
  var coreMinutes: Double
929
941
  var awakeMinutes: Double
942
+ var unspecifiedMinutes: Double // For legacy/third-party sleep data without stage details
930
943
  }
931
944
 
932
945
  var sleepByDate: [String: DayData] = [:]
@@ -944,7 +957,8 @@ final class Health {
944
957
  deepMinutes: 0,
945
958
  remMinutes: 0,
946
959
  coreMinutes: 0,
947
- awakeMinutes: 0
960
+ awakeMinutes: 0,
961
+ unspecifiedMinutes: 0
948
962
  )
949
963
  }
950
964
 
@@ -962,6 +976,10 @@ final class Health {
962
976
  sleepByDate[dateString]!.coreMinutes += minutes
963
977
  } else if segment.stage == "HKCategoryValueSleepAnalysisAwake" {
964
978
  sleepByDate[dateString]!.awakeMinutes += minutes
979
+ } else if segment.stage == "HKCategoryValueSleepAnalysisAsleepUnspecified" ||
980
+ segment.stage == "HKCategoryValueSleepAnalysisAsleep" {
981
+ // Legacy sleep data or third-party apps that don't provide stage details
982
+ sleepByDate[dateString]!.unspecifiedMinutes += minutes
965
983
  }
966
984
  }
967
985
  }
@@ -974,14 +992,15 @@ final class Health {
974
992
  let remHours = data.remMinutes / 60.0
975
993
  let coreHours = data.coreMinutes / 60.0
976
994
  let awakeHours = data.awakeMinutes / 60.0
977
- let totalSleepHours = deepHours + remHours + coreHours
995
+ let unspecifiedHours = data.unspecifiedMinutes / 60.0
996
+ // Include unspecified sleep in total (this captures legacy/third-party app data)
997
+ let totalSleepHours = deepHours + remHours + coreHours + unspecifiedHours
978
998
 
979
- // Calculate time in bed from merged sessions (first start to last end)
999
+ // Calculate time in bed by summing each session's duration
1000
+ // This avoids counting gaps between sessions (e.g., nap + main sleep)
980
1001
  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
1002
+ for session in data.sessions {
1003
+ timeInBed += session.end.timeIntervalSince(session.start) / 3600.0
985
1004
  }
986
1005
 
987
1006
  // Calculate efficiency
@@ -1011,6 +1030,7 @@ final class Health {
1011
1030
  "deepSleep": round(deepHours * 10) / 10,
1012
1031
  "remSleep": round(remHours * 10) / 10,
1013
1032
  "coreSleep": round(coreHours * 10) / 10,
1033
+ "unspecifiedSleep": round(unspecifiedHours * 10) / 10, // Legacy/third-party sleep without stage details
1014
1034
  "awakeTime": round(awakeHours * 10) / 10,
1015
1035
  "timeInBed": round(timeInBed * 10) / 10,
1016
1036
  "efficiency": efficiency,
@@ -1366,6 +1386,12 @@ final class Health {
1366
1386
  private func processMobilityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1367
1387
  let calendar = Calendar.current
1368
1388
 
1389
+ // Calculate the local date range that corresponds to the input UTC date range
1390
+ let startDateComponents = calendar.dateComponents([.year, .month, .day], from: startDate)
1391
+ let endDateComponents = calendar.dateComponents([.year, .month, .day], from: endDate)
1392
+ let localStartDateString = String(format: "%04d-%02d-%02d", startDateComponents.year!, startDateComponents.month!, startDateComponents.day!)
1393
+ let localEndDateString = String(format: "%04d-%02d-%02d", endDateComponents.year!, endDateComponents.month!, endDateComponents.day!)
1394
+
1369
1395
  // Define all mobility quantity types
1370
1396
  let mobilityTypes: [(HKQuantityTypeIdentifier, String)] = [
1371
1397
  (.walkingSpeed, "walkingSpeed"),
@@ -1397,7 +1423,7 @@ final class Health {
1397
1423
 
1398
1424
  group.enter()
1399
1425
 
1400
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1426
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1401
1427
  let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1402
1428
  defer { group.leave() }
1403
1429
 
@@ -1442,7 +1468,7 @@ final class Health {
1442
1468
  lock.unlock()
1443
1469
 
1444
1470
  case .walkingAsymmetryPercentage:
1445
- // Convert to percentage (HealthKit stores as decimal)
1471
+ // HKUnit.percent() returns fractional value (0.05 = 5%), multiply by 100 for percentage
1446
1472
  value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1447
1473
  lock.lock()
1448
1474
  if asymmetryMap[dateString] == nil {
@@ -1452,7 +1478,7 @@ final class Health {
1452
1478
  lock.unlock()
1453
1479
 
1454
1480
  case .walkingDoubleSupportPercentage:
1455
- // Convert to percentage (HealthKit stores as decimal)
1481
+ // HKUnit.percent() returns fractional value (0.30 = 30%), multiply by 100 for percentage
1456
1482
  value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1457
1483
  lock.lock()
1458
1484
  if doubleSupportMap[dateString] == nil {
@@ -1505,10 +1531,15 @@ final class Health {
1505
1531
  allDates.formUnion(stairSpeedMap.keys)
1506
1532
  allDates.formUnion(sixMinWalkMap.keys)
1507
1533
 
1534
+ // Filter dates to only include those within the requested local date range
1535
+ let filteredDates = allDates.filter { date in
1536
+ return date >= localStartDateString && date <= localEndDateString
1537
+ }
1538
+
1508
1539
  // Create mobility data array with aggregated daily averages
1509
1540
  var mobilityData: [[String: Any]] = []
1510
1541
 
1511
- for date in allDates.sorted() {
1542
+ for date in filteredDates.sorted() {
1512
1543
  var result: [String: Any] = ["date": date]
1513
1544
 
1514
1545
  // Average all measurements for the day
@@ -1556,6 +1587,13 @@ final class Health {
1556
1587
  let group = DispatchGroup()
1557
1588
  let lock = NSLock()
1558
1589
 
1590
+ // Calculate the local date range that corresponds to the input UTC date range
1591
+ // This ensures we only return data for dates that fall within the requested range
1592
+ let startDateComponents = calendar.dateComponents([.year, .month, .day], from: startDate)
1593
+ let endDateComponents = calendar.dateComponents([.year, .month, .day], from: endDate)
1594
+ let localStartDateString = String(format: "%04d-%02d-%02d", startDateComponents.year!, startDateComponents.month!, startDateComponents.day!)
1595
+ let localEndDateString = String(format: "%04d-%02d-%02d", endDateComponents.year!, endDateComponents.month!, endDateComponents.day!)
1596
+
1559
1597
  // Maps to store values by date for each metric
1560
1598
  var stepsMap: [String: Double] = [:]
1561
1599
  var distanceMap: [String: Double] = [:]
@@ -1569,7 +1607,7 @@ final class Health {
1569
1607
  // === STEPS ===
1570
1608
  if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) {
1571
1609
  group.enter()
1572
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1610
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1573
1611
  let query = HKSampleQuery(sampleType: stepsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1574
1612
  defer { group.leave() }
1575
1613
 
@@ -1598,7 +1636,7 @@ final class Health {
1598
1636
  // === DISTANCE (Walking + Running) ===
1599
1637
  if let distanceType = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) {
1600
1638
  group.enter()
1601
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1639
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1602
1640
  let query = HKSampleQuery(sampleType: distanceType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1603
1641
  defer { group.leave() }
1604
1642
 
@@ -1629,7 +1667,7 @@ final class Health {
1629
1667
  // === FLIGHTS CLIMBED ===
1630
1668
  if let flightsType = HKObjectType.quantityType(forIdentifier: .flightsClimbed) {
1631
1669
  group.enter()
1632
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1670
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1633
1671
  let query = HKSampleQuery(sampleType: flightsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1634
1672
  defer { group.leave() }
1635
1673
 
@@ -1658,7 +1696,7 @@ final class Health {
1658
1696
  // === ACTIVE ENERGY ===
1659
1697
  if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
1660
1698
  group.enter()
1661
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1699
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1662
1700
  let query = HKSampleQuery(sampleType: activeEnergyType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1663
1701
  defer { group.leave() }
1664
1702
 
@@ -1687,7 +1725,7 @@ final class Health {
1687
1725
  // === EXERCISE MINUTES ===
1688
1726
  if let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) {
1689
1727
  group.enter()
1690
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1728
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1691
1729
  let query = HKSampleQuery(sampleType: exerciseType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1692
1730
  defer { group.leave() }
1693
1731
 
@@ -1716,7 +1754,7 @@ final class Health {
1716
1754
  // === STAND HOURS ===
1717
1755
  if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
1718
1756
  group.enter()
1719
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1757
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1720
1758
  let query = HKSampleQuery(sampleType: standHourType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1721
1759
  defer { group.leave() }
1722
1760
 
@@ -1769,8 +1807,14 @@ final class Health {
1769
1807
  allDates.formUnion(exerciseMinutesMap.keys)
1770
1808
  allDates.formUnion(standHoursMap.keys)
1771
1809
 
1810
+ // Filter dates to only include those within the requested local date range
1811
+ // This prevents returning data from dates outside the user's intended range
1812
+ let filteredDates = allDates.filter { date in
1813
+ return date >= localStartDateString && date <= localEndDateString
1814
+ }
1815
+
1772
1816
  // Create activity data array matching the reference format
1773
- let activityData: [[String: Any]] = allDates.sorted().compactMap { date in
1817
+ let activityData: [[String: Any]] = filteredDates.sorted().compactMap { date in
1774
1818
  var result: [String: Any] = ["date": date]
1775
1819
 
1776
1820
  // Add each metric if available, with proper rounding
@@ -1812,6 +1856,12 @@ final class Health {
1812
1856
  let group = DispatchGroup()
1813
1857
  let lock = NSLock()
1814
1858
 
1859
+ // Calculate the local date range that corresponds to the input UTC date range
1860
+ let startDateComponents = calendar.dateComponents([.year, .month, .day], from: startDate)
1861
+ let endDateComponents = calendar.dateComponents([.year, .month, .day], from: endDate)
1862
+ let localStartDateString = String(format: "%04d-%02d-%02d", startDateComponents.year!, startDateComponents.month!, startDateComponents.day!)
1863
+ let localEndDateString = String(format: "%04d-%02d-%02d", endDateComponents.year!, endDateComponents.month!, endDateComponents.day!)
1864
+
1815
1865
  // Maps to store values by date for each metric
1816
1866
  struct HeartRateStats {
1817
1867
  var sum: Double = 0
@@ -1832,7 +1882,7 @@ final class Health {
1832
1882
  // === HEART RATE ===
1833
1883
  if let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) {
1834
1884
  group.enter()
1835
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1885
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1836
1886
  let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1837
1887
  defer { group.leave() }
1838
1888
 
@@ -1866,7 +1916,7 @@ final class Health {
1866
1916
  // === RESTING HEART RATE ===
1867
1917
  if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) {
1868
1918
  group.enter()
1869
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1919
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1870
1920
  let query = HKSampleQuery(sampleType: restingHRType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1871
1921
  defer { group.leave() }
1872
1922
 
@@ -1895,7 +1945,7 @@ final class Health {
1895
1945
  // === VO2 MAX ===
1896
1946
  if let vo2MaxType = HKObjectType.quantityType(forIdentifier: .vo2Max) {
1897
1947
  group.enter()
1898
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1948
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1899
1949
  let query = HKSampleQuery(sampleType: vo2MaxType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1900
1950
  defer { group.leave() }
1901
1951
 
@@ -1924,7 +1974,7 @@ final class Health {
1924
1974
  // === HRV (Heart Rate Variability SDNN) ===
1925
1975
  if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
1926
1976
  group.enter()
1927
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
1977
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1928
1978
  let query = HKSampleQuery(sampleType: hrvType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1929
1979
  defer { group.leave() }
1930
1980
 
@@ -1953,7 +2003,7 @@ final class Health {
1953
2003
  // === SPO2 (Blood Oxygen Saturation) ===
1954
2004
  if let spo2Type = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) {
1955
2005
  group.enter()
1956
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2006
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1957
2007
  let query = HKSampleQuery(sampleType: spo2Type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1958
2008
  defer { group.leave() }
1959
2009
 
@@ -1970,7 +2020,7 @@ final class Health {
1970
2020
  let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
1971
2021
  let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
1972
2022
 
1973
- // HealthKit stores as decimal (0.96), convert to percentage (96)
2023
+ // HKUnit.percent() returns fractional value (0.98 = 98%), multiply by 100 for percentage
1974
2024
  let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
1975
2025
  lock.lock()
1976
2026
  if spo2Map[dateString] == nil {
@@ -1986,7 +2036,7 @@ final class Health {
1986
2036
  // === RESPIRATION RATE ===
1987
2037
  if let respirationRateType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) {
1988
2038
  group.enter()
1989
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2039
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
1990
2040
  let query = HKSampleQuery(sampleType: respirationRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
1991
2041
  defer { group.leave() }
1992
2042
 
@@ -2040,8 +2090,13 @@ final class Health {
2040
2090
  allDates.formUnion(spo2Map.keys)
2041
2091
  allDates.formUnion(respirationRateMap.keys)
2042
2092
 
2093
+ // Filter dates to only include those within the requested local date range
2094
+ let filteredDates = allDates.filter { date in
2095
+ return date >= localStartDateString && date <= localEndDateString
2096
+ }
2097
+
2043
2098
  // Create heart data array matching the TypeScript format
2044
- let heartData: [[String: Any]] = allDates.sorted().compactMap { date in
2099
+ let heartData: [[String: Any]] = filteredDates.sorted().compactMap { date in
2045
2100
  var result: [String: Any] = ["date": date]
2046
2101
 
2047
2102
  // Heart Rate (avg, min, max, count)
@@ -2090,7 +2145,7 @@ final class Health {
2090
2145
 
2091
2146
  private func processWorkoutData(startDate: Date, endDate: Date, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
2092
2147
  let workoutType = HKObjectType.workoutType()
2093
- let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2148
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
2094
2149
  let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
2095
2150
  let queryLimit = limit ?? HKObjectQueryNoLimit
2096
2151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interval-health/capacitor-health",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",