@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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
854
|
-
return
|
|
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
|
-
//
|
|
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
|
-
|
|
938
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
982
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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