@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
|
|
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 `
|
|
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")
|
|
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
|
@@ -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
|
|
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]] =
|
|
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]] =
|
|
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