@interval-health/capacitor-health 1.0.3 → 1.1.1

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.
package/README.md CHANGED
@@ -173,7 +173,8 @@ export type HealthDataType =
173
173
  | 'mobility'
174
174
  | 'activity'
175
175
  | 'heart'
176
- | 'body';
176
+ | 'body'
177
+ | 'workout';
177
178
  ```
178
179
 
179
180
  | Data Type | Description | Unit | iOS | Android | Read | Write |
@@ -188,12 +189,13 @@ export type HealthDataType =
188
189
  | `activity` | Activity metrics | mixed | ✅ | ❌ | ✅ | ❌ |
189
190
  | `heart` | Heart health metrics | mixed | ✅ | ❌ | ✅ | ❌ |
190
191
  | `body` | Body measurements | mixed | ✅ | ❌ | ✅ | ❌ |
192
+ | `workout` | Workout/exercise sessions | minute | ✅ | ❌ | ✅ | ❌ |
191
193
 
192
194
  **Platform Support Notes**:
193
- - **iOS** supports all 10 data types
195
+ - **iOS** supports all 11 data types
194
196
  - **Android** supports only 5 basic data types: `steps`, `distance`, `calories`, `heartRate`, and `weight`
195
- - The `sleep`, `mobility`, `activity`, `heart`, and `body` types are **iOS-only** and not available on Android
196
- - Composite types (`mobility`, `activity`, `heart`, `body`) are **read-only** on iOS
197
+ - The `sleep`, `mobility`, `activity`, `heart`, `body`, and `workout` types are **iOS-only** and not available on Android
198
+ - Composite types (`mobility`, `activity`, `heart`, `body`, `workout`) are **read-only** on iOS
197
199
 
198
200
  ### Sleep States
199
201
 
@@ -209,6 +211,55 @@ When reading sleep data, each sample may include a `sleepState` property:
209
211
  | `asleepREM` | REM (Rapid Eye Movement) sleep stage |
210
212
  | `unknown` | Sleep state could not be determined |
211
213
 
214
+ ### Workout Data
215
+
216
+ When reading workout data (`dataType: 'workout'`), the returned data structure includes:
217
+
218
+ ```typescript
219
+ interface WorkoutData {
220
+ date: string; // ISO date (YYYY-MM-DD)
221
+ type: string; // Activity type (e.g., "Running", "Cycling", "Swimming")
222
+ duration: number; // Duration in minutes
223
+ distance?: number; // Distance in miles (optional)
224
+ calories?: number; // Calories burned (optional)
225
+ source?: string; // Source app name (optional)
226
+ avgHeartRate?: number; // Average heart rate in BPM (optional)
227
+ maxHeartRate?: number; // Maximum heart rate in BPM (optional)
228
+ zones?: { // Heart rate zones in minutes (optional)
229
+ zone1?: number; // 50-60% max HR
230
+ zone2?: number; // 60-70% max HR
231
+ zone3?: number; // 70-80% max HR
232
+ zone4?: number; // 80-90% max HR
233
+ zone5?: number; // 90-100% max HR
234
+ };
235
+ }
236
+ ```
237
+
238
+ **Supported Workout Types**: Running, Cycling, Walking, Swimming, Yoga, FunctionalStrengthTraining, TraditionalStrengthTraining, Elliptical, Rowing, Hiking, HighIntensityIntervalTraining, Dance, Basketball, Soccer, Tennis, Golf, StairClimbing, and more.
239
+
240
+ **Example**:
241
+ ```typescript
242
+ const result = await Health.readSamples({
243
+ dataType: 'workout',
244
+ startDate: '2024-01-01T00:00:00Z',
245
+ endDate: '2024-01-31T23:59:59Z',
246
+ limit: 50
247
+ });
248
+
249
+ // Sample output:
250
+ // {
251
+ // date: "2024-01-15",
252
+ // type: "Running",
253
+ // duration: 45,
254
+ // distance: 5.23,
255
+ // calories: 450,
256
+ // source: "Apple Watch",
257
+ // avgHeartRate: 145,
258
+ // maxHeartRate: 175,
259
+ // zones: { zone2: 10, zone3: 20, zone4: 15 }
260
+ // }
261
+ ```
262
+
212
263
  ---
213
264
 
214
265
  ## 🔧 Methods
@@ -3,6 +3,7 @@ package app.capgo.plugin.health
3
3
  import androidx.health.connect.client.permission.HealthPermission
4
4
  import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
5
5
  import androidx.health.connect.client.records.DistanceRecord
6
+ import androidx.health.connect.client.records.ExerciseSessionRecord
6
7
  import androidx.health.connect.client.records.HeartRateRecord
7
8
  import androidx.health.connect.client.records.Record
8
9
  import androidx.health.connect.client.records.SleepSessionRecord
@@ -21,7 +22,8 @@ enum class HealthDataType(
21
22
  HEART_RATE("heartRate", HeartRateRecord::class, "bpm"),
22
23
  WEIGHT("weight", WeightRecord::class, "kilogram"),
23
24
  SLEEP("sleep", SleepSessionRecord::class, "minute"),
24
- MOBILITY("mobility", StepsRecord::class, "mixed"); // Using StepsRecord as placeholder
25
+ MOBILITY("mobility", StepsRecord::class, "mixed"), // Using StepsRecord as placeholder
26
+ WORKOUT("workout", ExerciseSessionRecord::class, "minute");
25
27
 
26
28
  val readPermission: String
27
29
  get() = HealthPermission.getReadPermission(recordClass)
package/dist/docs.json CHANGED
@@ -456,6 +456,10 @@
456
456
  {
457
457
  "text": "'body'",
458
458
  "complexTypes": []
459
+ },
460
+ {
461
+ "text": "'workout'",
462
+ "complexTypes": []
459
463
  }
460
464
  ]
461
465
  },
@@ -1,6 +1,24 @@
1
- export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body';
1
+ export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body' | 'workout';
2
2
  export type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'mixed';
3
3
  export type SleepState = 'inBed' | 'asleep' | 'awake' | 'asleepCore' | 'asleepDeep' | 'asleepREM' | 'unknown';
4
+ export interface WorkoutHeartRateZones {
5
+ zone1?: number;
6
+ zone2?: number;
7
+ zone3?: number;
8
+ zone4?: number;
9
+ zone5?: number;
10
+ }
11
+ export interface WorkoutData {
12
+ date: string;
13
+ type: string;
14
+ duration: number;
15
+ distance?: number;
16
+ calories?: number;
17
+ source?: string;
18
+ avgHeartRate?: number;
19
+ maxHeartRate?: number;
20
+ zones?: WorkoutHeartRateZones;
21
+ }
4
22
  export interface AuthorizationOptions {
5
23
  /** Data types that should be readable after authorization. */
6
24
  read?: HealthDataType[];
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":[" export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'mixed';\nexport type SleepState = 'inBed' | 'asleep' | 'awake' | 'asleepCore' | 'asleepDeep' | 'asleepREM' | 'unknown';\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n /** Sleep state (only present when dataType is 'sleep') */\n sleepState?: SleepState;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`, minute for `sleep`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ id: string }>} an Promise with version for this device\n * @throws An error if the something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":[" export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body' | 'workout';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'mixed';\nexport type SleepState = 'inBed' | 'asleep' | 'awake' | 'asleepCore' | 'asleepDeep' | 'asleepREM' | 'unknown';\n\nexport interface WorkoutHeartRateZones {\n zone1?: number; // 50-60% max HR, in minutes\n zone2?: number; // 60-70% max HR, in minutes\n zone3?: number; // 70-80% max HR, in minutes\n zone4?: number; // 80-90% max HR, in minutes\n zone5?: number; // 90-100% max HR, in minutes\n}\n\nexport interface WorkoutData {\n date: string; // ISO date (YYYY-MM-DD)\n type: string; // Activity type (e.g., \"Running\", \"Cycling\")\n duration: number; // Duration in minutes\n distance?: number; // Distance in miles\n calories?: number; // Calories burned\n source?: string; // Source app name\n avgHeartRate?: number; // Average heart rate in BPM\n maxHeartRate?: number; // Maximum heart rate in BPM\n zones?: WorkoutHeartRateZones; // Heart rate zones\n}\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n /** Sleep state (only present when dataType is 'sleep') */\n sleepState?: SleepState;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`, minute for `sleep`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ id: string }>} an Promise with version for this device\n * @throws An error if the something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
@@ -38,6 +38,7 @@ enum HealthDataType: String, CaseIterable {
38
38
  case activity
39
39
  case heart
40
40
  case body
41
+ case workout
41
42
 
42
43
  func sampleType() throws -> HKSampleType {
43
44
  if self == .sleep {
@@ -79,6 +80,11 @@ enum HealthDataType: String, CaseIterable {
79
80
  return type
80
81
  }
81
82
 
83
+ if self == .workout {
84
+ // Workout uses HKWorkoutType
85
+ return HKObjectType.workoutType()
86
+ }
87
+
82
88
  let identifier: HKQuantityTypeIdentifier
83
89
  switch self {
84
90
  case .steps:
@@ -101,6 +107,8 @@ enum HealthDataType: String, CaseIterable {
101
107
  fatalError("Heart should have been handled above")
102
108
  case .body:
103
109
  fatalError("Body should have been handled above")
110
+ case .workout:
111
+ fatalError("Workout should have been handled above")
104
112
  }
105
113
 
106
114
  guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
@@ -131,6 +139,8 @@ enum HealthDataType: String, CaseIterable {
131
139
  return HKUnit.count().unitDivided(by: HKUnit.minute()) // Placeholder, heart has multiple units
132
140
  case .body:
133
141
  return HKUnit.gramUnit(with: .kilo) // Placeholder, body has multiple units
142
+ case .workout:
143
+ return HKUnit.minute() // Workout duration in minutes
134
144
  }
135
145
  }
136
146
 
@@ -156,6 +166,8 @@ enum HealthDataType: String, CaseIterable {
156
166
  return "mixed" // Heart has multiple units
157
167
  case .body:
158
168
  return "mixed" // Body has multiple units
169
+ case .workout:
170
+ return "minute" // Workout duration in minutes
159
171
  }
160
172
  }
161
173
 
@@ -324,6 +336,19 @@ final class Health {
324
336
  return
325
337
  }
326
338
 
339
+ // Handle workout data
340
+ if dataType == .workout {
341
+ processWorkoutData(startDate: startDate, endDate: endDate, limit: limit, ascending: ascending) { result in
342
+ switch result {
343
+ case .success(let workoutData):
344
+ completion(.success(workoutData))
345
+ case .failure(let error):
346
+ completion(.failure(error))
347
+ }
348
+ }
349
+ return
350
+ }
351
+
327
352
  // For all other data types, use the standard query approach
328
353
  let sampleType = try dataType.sampleType()
329
354
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
@@ -1341,6 +1366,12 @@ final class Health {
1341
1366
  private func processMobilityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
1342
1367
  let calendar = Calendar.current
1343
1368
 
1369
+ // Calculate the local date range that corresponds to the input UTC date range
1370
+ let startDateComponents = calendar.dateComponents([.year, .month, .day], from: startDate)
1371
+ let endDateComponents = calendar.dateComponents([.year, .month, .day], from: endDate)
1372
+ let localStartDateString = String(format: "%04d-%02d-%02d", startDateComponents.year!, startDateComponents.month!, startDateComponents.day!)
1373
+ let localEndDateString = String(format: "%04d-%02d-%02d", endDateComponents.year!, endDateComponents.month!, endDateComponents.day!)
1374
+
1344
1375
  // Define all mobility quantity types
1345
1376
  let mobilityTypes: [(HKQuantityTypeIdentifier, String)] = [
1346
1377
  (.walkingSpeed, "walkingSpeed"),
@@ -1480,10 +1511,15 @@ final class Health {
1480
1511
  allDates.formUnion(stairSpeedMap.keys)
1481
1512
  allDates.formUnion(sixMinWalkMap.keys)
1482
1513
 
1514
+ // Filter dates to only include those within the requested local date range
1515
+ let filteredDates = allDates.filter { date in
1516
+ return date >= localStartDateString && date <= localEndDateString
1517
+ }
1518
+
1483
1519
  // Create mobility data array with aggregated daily averages
1484
1520
  var mobilityData: [[String: Any]] = []
1485
1521
 
1486
- for date in allDates.sorted() {
1522
+ for date in filteredDates.sorted() {
1487
1523
  var result: [String: Any] = ["date": date]
1488
1524
 
1489
1525
  // Average all measurements for the day
@@ -1531,6 +1567,13 @@ final class Health {
1531
1567
  let group = DispatchGroup()
1532
1568
  let lock = NSLock()
1533
1569
 
1570
+ // Calculate the local date range that corresponds to the input UTC date range
1571
+ // This ensures we only return data for dates that fall within the requested range
1572
+ let startDateComponents = calendar.dateComponents([.year, .month, .day], from: startDate)
1573
+ let endDateComponents = calendar.dateComponents([.year, .month, .day], from: endDate)
1574
+ let localStartDateString = String(format: "%04d-%02d-%02d", startDateComponents.year!, startDateComponents.month!, startDateComponents.day!)
1575
+ let localEndDateString = String(format: "%04d-%02d-%02d", endDateComponents.year!, endDateComponents.month!, endDateComponents.day!)
1576
+
1534
1577
  // Maps to store values by date for each metric
1535
1578
  var stepsMap: [String: Double] = [:]
1536
1579
  var distanceMap: [String: Double] = [:]
@@ -1744,8 +1787,14 @@ final class Health {
1744
1787
  allDates.formUnion(exerciseMinutesMap.keys)
1745
1788
  allDates.formUnion(standHoursMap.keys)
1746
1789
 
1790
+ // Filter dates to only include those within the requested local date range
1791
+ // This prevents returning data from dates outside the user's intended range
1792
+ let filteredDates = allDates.filter { date in
1793
+ return date >= localStartDateString && date <= localEndDateString
1794
+ }
1795
+
1747
1796
  // Create activity data array matching the reference format
1748
- let activityData: [[String: Any]] = allDates.sorted().compactMap { date in
1797
+ let activityData: [[String: Any]] = filteredDates.sorted().compactMap { date in
1749
1798
  var result: [String: Any] = ["date": date]
1750
1799
 
1751
1800
  // Add each metric if available, with proper rounding
@@ -1787,6 +1836,12 @@ final class Health {
1787
1836
  let group = DispatchGroup()
1788
1837
  let lock = NSLock()
1789
1838
 
1839
+ // Calculate the local date range that corresponds to the input UTC date range
1840
+ let startDateComponents = calendar.dateComponents([.year, .month, .day], from: startDate)
1841
+ let endDateComponents = calendar.dateComponents([.year, .month, .day], from: endDate)
1842
+ let localStartDateString = String(format: "%04d-%02d-%02d", startDateComponents.year!, startDateComponents.month!, startDateComponents.day!)
1843
+ let localEndDateString = String(format: "%04d-%02d-%02d", endDateComponents.year!, endDateComponents.month!, endDateComponents.day!)
1844
+
1790
1845
  // Maps to store values by date for each metric
1791
1846
  struct HeartRateStats {
1792
1847
  var sum: Double = 0
@@ -2015,8 +2070,13 @@ final class Health {
2015
2070
  allDates.formUnion(spo2Map.keys)
2016
2071
  allDates.formUnion(respirationRateMap.keys)
2017
2072
 
2073
+ // Filter dates to only include those within the requested local date range
2074
+ let filteredDates = allDates.filter { date in
2075
+ return date >= localStartDateString && date <= localEndDateString
2076
+ }
2077
+
2018
2078
  // Create heart data array matching the TypeScript format
2019
- let heartData: [[String: Any]] = allDates.sorted().compactMap { date in
2079
+ let heartData: [[String: Any]] = filteredDates.sorted().compactMap { date in
2020
2080
  var result: [String: Any] = ["date": date]
2021
2081
 
2022
2082
  // Heart Rate (avg, min, max, count)
@@ -2060,4 +2120,302 @@ final class Health {
2060
2120
  completion(.success(heartData))
2061
2121
  }
2062
2122
  }
2123
+
2124
+ // MARK: - Workout Data Processing
2125
+
2126
+ private func processWorkoutData(startDate: Date, endDate: Date, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
2127
+ let workoutType = HKObjectType.workoutType()
2128
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2129
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
2130
+ let queryLimit = limit ?? HKObjectQueryNoLimit
2131
+
2132
+ let query = HKSampleQuery(sampleType: workoutType, predicate: predicate, limit: queryLimit, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, error in
2133
+ guard let self = self else { return }
2134
+
2135
+ if let error = error {
2136
+ completion(.failure(error))
2137
+ return
2138
+ }
2139
+
2140
+ guard let workouts = samples as? [HKWorkout], !workouts.isEmpty else {
2141
+ completion(.success([]))
2142
+ return
2143
+ }
2144
+
2145
+ var workoutData: [[String: Any]] = []
2146
+ let group = DispatchGroup()
2147
+ let lock = NSLock()
2148
+
2149
+ for workout in workouts {
2150
+ group.enter()
2151
+
2152
+ // Extract basic workout info
2153
+ let dateFormatter = ISO8601DateFormatter()
2154
+ dateFormatter.formatOptions = [.withFullDate]
2155
+ let date = dateFormatter.string(from: workout.startDate)
2156
+
2157
+ // Get workout type (remove "HKWorkoutActivityType" prefix to match XML format)
2158
+ let activityTypeString = self.workoutActivityTypeString(for: workout.workoutActivityType)
2159
+
2160
+ // Duration in minutes (matching XML parser logic)
2161
+ let durationInMinutes = Int(round(workout.duration / 60.0))
2162
+
2163
+ // Distance in miles (if available)
2164
+ var distance: Double?
2165
+ if let totalDistance = workout.totalDistance {
2166
+ let distanceInMeters = totalDistance.doubleValue(for: HKUnit.meter())
2167
+ distance = distanceInMeters * 0.000621371 // meters to miles
2168
+ }
2169
+
2170
+ // Calories (if available)
2171
+ var calories: Int?
2172
+ if let totalEnergy = workout.totalEnergyBurned {
2173
+ let caloriesValue = totalEnergy.doubleValue(for: HKUnit.kilocalorie())
2174
+ calories = Int(round(caloriesValue))
2175
+ }
2176
+
2177
+ // Source name
2178
+ let source = workout.sourceRevision.source.name
2179
+
2180
+ // Query for heart rate statistics during this workout
2181
+ self.queryWorkoutHeartRateStatistics(for: workout) { avgHR, maxHR in
2182
+ // Query for heart rate zones
2183
+ self.queryWorkoutHeartRateZones(for: workout) { zones in
2184
+ lock.lock()
2185
+
2186
+ var workoutDict: [String: Any] = [
2187
+ "date": date,
2188
+ "type": activityTypeString,
2189
+ "duration": durationInMinutes,
2190
+ "source": source
2191
+ ]
2192
+
2193
+ if let distance = distance {
2194
+ workoutDict["distance"] = round(distance * 100) / 100 // 2 decimal places
2195
+ }
2196
+
2197
+ if let calories = calories {
2198
+ workoutDict["calories"] = calories
2199
+ }
2200
+
2201
+ if let avgHR = avgHR {
2202
+ workoutDict["avgHeartRate"] = avgHR
2203
+ }
2204
+
2205
+ if let maxHR = maxHR {
2206
+ workoutDict["maxHeartRate"] = maxHR
2207
+ }
2208
+
2209
+ if !zones.isEmpty {
2210
+ workoutDict["zones"] = zones
2211
+ }
2212
+
2213
+ workoutData.append(workoutDict)
2214
+ lock.unlock()
2215
+
2216
+ group.leave()
2217
+ }
2218
+ }
2219
+ }
2220
+
2221
+ group.notify(queue: .main) {
2222
+ // Sort by date if needed
2223
+ let sortedData = workoutData.sorted { dict1, dict2 in
2224
+ guard let date1 = dict1["date"] as? String,
2225
+ let date2 = dict2["date"] as? String else {
2226
+ return false
2227
+ }
2228
+ return ascending ? date1 < date2 : date1 > date2
2229
+ }
2230
+
2231
+ completion(.success(sortedData))
2232
+ }
2233
+ }
2234
+
2235
+ healthStore.execute(query)
2236
+ }
2237
+
2238
+ private func workoutActivityTypeString(for type: HKWorkoutActivityType) -> String {
2239
+ // Return the activity type name without the "HKWorkoutActivityType" prefix
2240
+ // to match the XML export format
2241
+ switch type {
2242
+ case .running: return "Running"
2243
+ case .cycling: return "Cycling"
2244
+ case .walking: return "Walking"
2245
+ case .swimming: return "Swimming"
2246
+ case .yoga: return "Yoga"
2247
+ case .functionalStrengthTraining: return "FunctionalStrengthTraining"
2248
+ case .traditionalStrengthTraining: return "TraditionalStrengthTraining"
2249
+ case .elliptical: return "Elliptical"
2250
+ case .rowing: return "Rowing"
2251
+ case .hiking: return "Hiking"
2252
+ case .highIntensityIntervalTraining: return "HighIntensityIntervalTraining"
2253
+ case .dance: return "Dance"
2254
+ case .basketball: return "Basketball"
2255
+ case .soccer: return "Soccer"
2256
+ case .tennis: return "Tennis"
2257
+ case .golf: return "Golf"
2258
+ case .stairClimbing: return "StairClimbing"
2259
+ case .stepTraining: return "StepTraining"
2260
+ case .kickboxing: return "Kickboxing"
2261
+ case .pilates: return "Pilates"
2262
+ case .boxing: return "Boxing"
2263
+ case .taiChi: return "TaiChi"
2264
+ case .crossTraining: return "CrossTraining"
2265
+ case .mindAndBody: return "MindAndBody"
2266
+ case .coreTraining: return "CoreTraining"
2267
+ case .flexibility: return "Flexibility"
2268
+ case .cooldown: return "Cooldown"
2269
+ case .wheelchairWalkPace: return "WheelchairWalkPace"
2270
+ case .wheelchairRunPace: return "WheelchairRunPace"
2271
+ case .other: return "Other"
2272
+ default:
2273
+ // For any unknown or new types
2274
+ return "Other"
2275
+ }
2276
+ }
2277
+
2278
+ private func queryWorkoutHeartRateStatistics(for workout: HKWorkout, completion: @escaping (Int?, Int?) -> Void) {
2279
+ guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
2280
+ completion(nil, nil)
2281
+ return
2282
+ }
2283
+
2284
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
2285
+
2286
+ let query = HKStatisticsQuery(quantityType: heartRateType, quantitySamplePredicate: predicate, options: [.discreteAverage, .discreteMax]) { _, statistics, error in
2287
+ if error != nil {
2288
+ completion(nil, nil)
2289
+ return
2290
+ }
2291
+
2292
+ var avgHR: Int?
2293
+ var maxHR: Int?
2294
+
2295
+ if let avgQuantity = statistics?.averageQuantity() {
2296
+ let bpm = avgQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2297
+ avgHR = Int(round(bpm))
2298
+ }
2299
+
2300
+ if let maxQuantity = statistics?.maximumQuantity() {
2301
+ let bpm = maxQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2302
+ maxHR = Int(round(bpm))
2303
+ }
2304
+
2305
+ completion(avgHR, maxHR)
2306
+ }
2307
+
2308
+ healthStore.execute(query)
2309
+ }
2310
+
2311
+ private func queryWorkoutHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
2312
+ // Check if heart rate zone data is available in workout metadata
2313
+ var zones: [String: Int] = [:]
2314
+
2315
+ if let metadata = workout.metadata {
2316
+ // Look for heart rate zone keys in metadata
2317
+ // Apple Health stores zones as HKMetadataKeyHeartRateEventThreshold or custom keys
2318
+ for (key, value) in metadata {
2319
+ if key.contains("Zone") || key.contains("zone") {
2320
+ // Try to extract zone number
2321
+ if let zoneMatch = key.range(of: #"\d+"#, options: .regularExpression),
2322
+ let zoneNum = Int(key[zoneMatch]) {
2323
+ // Value might be in seconds, convert to minutes
2324
+ if let seconds = value as? Double {
2325
+ let minutes = Int(round(seconds / 60.0))
2326
+ zones["zone\(zoneNum)"] = minutes
2327
+ } else if let minutes = value as? Int {
2328
+ zones["zone\(zoneNum)"] = minutes
2329
+ }
2330
+ }
2331
+ }
2332
+ }
2333
+ }
2334
+
2335
+ // If no zones found in metadata, try querying heart rate samples to calculate zones
2336
+ if zones.isEmpty {
2337
+ calculateHeartRateZones(for: workout) { calculatedZones in
2338
+ completion(calculatedZones)
2339
+ }
2340
+ } else {
2341
+ completion(zones)
2342
+ }
2343
+ }
2344
+
2345
+ private func calculateHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
2346
+ guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
2347
+ completion([:])
2348
+ return
2349
+ }
2350
+
2351
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
2352
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
2353
+
2354
+ let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
2355
+ if error != nil || samples == nil {
2356
+ completion([:])
2357
+ return
2358
+ }
2359
+
2360
+ guard let heartRateSamples = samples as? [HKQuantitySample], !heartRateSamples.isEmpty else {
2361
+ completion([:])
2362
+ return
2363
+ }
2364
+
2365
+ // Calculate zones based on standard heart rate zone definitions
2366
+ // Zone 1: 50-60% max HR
2367
+ // Zone 2: 60-70% max HR
2368
+ // Zone 3: 70-80% max HR
2369
+ // Zone 4: 80-90% max HR
2370
+ // Zone 5: 90-100% max HR
2371
+
2372
+ // Estimate max HR (220 - age), or use 180 as a reasonable default
2373
+ let estimatedMaxHR = 180.0
2374
+
2375
+ var zoneMinutes: [Int: TimeInterval] = [1: 0, 2: 0, 3: 0, 4: 0, 5: 0]
2376
+
2377
+ for i in 0..<heartRateSamples.count {
2378
+ let sample = heartRateSamples[i]
2379
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2380
+ let percentMax = (bpm / estimatedMaxHR) * 100
2381
+
2382
+ // Determine zone
2383
+ let zone: Int
2384
+ if percentMax < 60 {
2385
+ zone = 1
2386
+ } else if percentMax < 70 {
2387
+ zone = 2
2388
+ } else if percentMax < 80 {
2389
+ zone = 3
2390
+ } else if percentMax < 90 {
2391
+ zone = 4
2392
+ } else {
2393
+ zone = 5
2394
+ }
2395
+
2396
+ // Calculate time in this zone (use interval to next sample or default to 5 seconds)
2397
+ let duration: TimeInterval
2398
+ if i < heartRateSamples.count - 1 {
2399
+ duration = heartRateSamples[i + 1].startDate.timeIntervalSince(sample.startDate)
2400
+ } else {
2401
+ duration = 5.0 // Default 5 seconds for last sample
2402
+ }
2403
+
2404
+ zoneMinutes[zone, default: 0] += duration
2405
+ }
2406
+
2407
+ // Convert seconds to minutes and filter out zones with 0 minutes
2408
+ var zones: [String: Int] = [:]
2409
+ for (zone, seconds) in zoneMinutes {
2410
+ let minutes = Int(round(seconds / 60.0))
2411
+ if minutes > 0 {
2412
+ zones["zone\(zone)"] = minutes
2413
+ }
2414
+ }
2415
+
2416
+ completion(zones)
2417
+ }
2418
+
2419
+ healthStore.execute(query)
2420
+ }
2063
2421
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interval-health/capacitor-health",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
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",