@robinhealth/health-sdk 0.0.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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +3 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +13 -0
- package/dist/provider.js.map +1 -0
- package/dist/providers/healthconnect.d.ts +16 -0
- package/dist/providers/healthconnect.d.ts.map +1 -0
- package/dist/providers/healthconnect.js +601 -0
- package/dist/providers/healthconnect.js.map +1 -0
- package/dist/providers/healthkit.d.ts +15 -0
- package/dist/providers/healthkit.d.ts.map +1 -0
- package/dist/providers/healthkit.js +392 -0
- package/dist/providers/healthkit.js.map +1 -0
- package/dist/storage.d.ts +3 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +71 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +122 -0
- package/dist/types.js.map +1 -0
- package/package.json +70 -0
- package/src/index.ts +15 -0
- package/src/provider.ts +18 -0
- package/src/providers/healthconnect.ts +725 -0
- package/src/providers/healthkit.ts +499 -0
- package/src/storage.ts +85 -0
- package/src/types.ts +189 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initialize,
|
|
3
|
+
getSdkStatus,
|
|
4
|
+
requestPermission,
|
|
5
|
+
getGrantedPermissions,
|
|
6
|
+
readRecords,
|
|
7
|
+
aggregateRecord,
|
|
8
|
+
SdkAvailabilityStatus,
|
|
9
|
+
type RecordType,
|
|
10
|
+
type HeartRateRecord,
|
|
11
|
+
type BloodPressureRecord,
|
|
12
|
+
type SleepSessionRecord,
|
|
13
|
+
type StepsRecord,
|
|
14
|
+
type FloorsClimbedRecord,
|
|
15
|
+
type RestingHeartRateRecord,
|
|
16
|
+
type OxygenSaturationRecord,
|
|
17
|
+
type RespiratoryRateRecord,
|
|
18
|
+
type ExerciseSessionRecord,
|
|
19
|
+
} from 'react-native-health-connect';
|
|
20
|
+
import {
|
|
21
|
+
HealthDataType,
|
|
22
|
+
WorkoutActivityType,
|
|
23
|
+
PermissionStatus,
|
|
24
|
+
SleepStage,
|
|
25
|
+
type HealthProvider,
|
|
26
|
+
type HealthSample,
|
|
27
|
+
type HealthAggregate,
|
|
28
|
+
type Workout,
|
|
29
|
+
type SleepStageSample,
|
|
30
|
+
type SleepSession,
|
|
31
|
+
} from '../types';
|
|
32
|
+
import { getPermissionsRequested, setPermissionsRequested } from '../storage';
|
|
33
|
+
|
|
34
|
+
interface HCTypeMapping {
|
|
35
|
+
recordType: RecordType;
|
|
36
|
+
unit: string;
|
|
37
|
+
supportsAggregation: boolean;
|
|
38
|
+
field?: string;
|
|
39
|
+
isSession?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TYPE_MAP: Partial<Record<HealthDataType, HCTypeMapping>> = {
|
|
43
|
+
[HealthDataType.Steps]: {
|
|
44
|
+
recordType: 'Steps',
|
|
45
|
+
unit: 'count',
|
|
46
|
+
supportsAggregation: true,
|
|
47
|
+
},
|
|
48
|
+
[HealthDataType.DistanceWalkingRunning]: {
|
|
49
|
+
recordType: 'Distance',
|
|
50
|
+
unit: 'm',
|
|
51
|
+
supportsAggregation: true,
|
|
52
|
+
},
|
|
53
|
+
[HealthDataType.ActiveEnergyBurned]: {
|
|
54
|
+
recordType: 'ActiveCaloriesBurned',
|
|
55
|
+
unit: 'kcal',
|
|
56
|
+
supportsAggregation: true,
|
|
57
|
+
},
|
|
58
|
+
[HealthDataType.FlightsClimbed]: {
|
|
59
|
+
recordType: 'FloorsClimbed',
|
|
60
|
+
unit: 'count',
|
|
61
|
+
supportsAggregation: true,
|
|
62
|
+
},
|
|
63
|
+
[HealthDataType.HeartRate]: {
|
|
64
|
+
recordType: 'HeartRate',
|
|
65
|
+
unit: 'bpm',
|
|
66
|
+
supportsAggregation: true,
|
|
67
|
+
},
|
|
68
|
+
[HealthDataType.RestingHeartRate]: {
|
|
69
|
+
recordType: 'RestingHeartRate',
|
|
70
|
+
unit: 'bpm',
|
|
71
|
+
supportsAggregation: false,
|
|
72
|
+
},
|
|
73
|
+
[HealthDataType.BloodPressureSystolic]: {
|
|
74
|
+
recordType: 'BloodPressure',
|
|
75
|
+
unit: 'mmHg',
|
|
76
|
+
supportsAggregation: false,
|
|
77
|
+
field: 'systolic',
|
|
78
|
+
},
|
|
79
|
+
[HealthDataType.BloodPressureDiastolic]: {
|
|
80
|
+
recordType: 'BloodPressure',
|
|
81
|
+
unit: 'mmHg',
|
|
82
|
+
supportsAggregation: false,
|
|
83
|
+
field: 'diastolic',
|
|
84
|
+
},
|
|
85
|
+
[HealthDataType.BloodOxygen]: {
|
|
86
|
+
recordType: 'OxygenSaturation',
|
|
87
|
+
unit: '%',
|
|
88
|
+
supportsAggregation: false,
|
|
89
|
+
},
|
|
90
|
+
[HealthDataType.RespiratoryRate]: {
|
|
91
|
+
recordType: 'RespiratoryRate',
|
|
92
|
+
unit: 'breaths/min',
|
|
93
|
+
supportsAggregation: false,
|
|
94
|
+
},
|
|
95
|
+
[HealthDataType.SleepAnalysis]: {
|
|
96
|
+
recordType: 'SleepSession',
|
|
97
|
+
unit: 'hr',
|
|
98
|
+
supportsAggregation: false,
|
|
99
|
+
isSession: true,
|
|
100
|
+
},
|
|
101
|
+
[HealthDataType.BodyFatPercentage]: {
|
|
102
|
+
recordType: 'BodyFat' as RecordType,
|
|
103
|
+
unit: '%',
|
|
104
|
+
supportsAggregation: false,
|
|
105
|
+
},
|
|
106
|
+
[HealthDataType.BasalEnergyBurned]: {
|
|
107
|
+
recordType: 'BasalMetabolicRate' as RecordType,
|
|
108
|
+
unit: 'kcal/day',
|
|
109
|
+
supportsAggregation: false,
|
|
110
|
+
},
|
|
111
|
+
[HealthDataType.Water]: {
|
|
112
|
+
recordType: 'Hydration' as RecordType,
|
|
113
|
+
unit: 'mL',
|
|
114
|
+
supportsAggregation: false,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const HC_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
|
|
119
|
+
0: SleepStage.Unknown, // STAGE_TYPE_UNKNOWN
|
|
120
|
+
1: SleepStage.Awake, // STAGE_TYPE_AWAKE
|
|
121
|
+
2: SleepStage.Unknown, // STAGE_TYPE_SLEEPING (generic)
|
|
122
|
+
3: SleepStage.Unknown, // STAGE_TYPE_OUT_OF_BED
|
|
123
|
+
4: SleepStage.Light, // STAGE_TYPE_LIGHT
|
|
124
|
+
5: SleepStage.Deep, // STAGE_TYPE_DEEP
|
|
125
|
+
6: SleepStage.REM, // STAGE_TYPE_REM
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Health Connect ExerciseType numeric constants → SDK WorkoutActivityType
|
|
129
|
+
const EXERCISE_TYPE_MAP: Record<number, WorkoutActivityType> = {
|
|
130
|
+
2: WorkoutActivityType.badminton,
|
|
131
|
+
4: WorkoutActivityType.baseball,
|
|
132
|
+
5: WorkoutActivityType.basketball,
|
|
133
|
+
8: WorkoutActivityType.cycling,
|
|
134
|
+
11: WorkoutActivityType.boxing,
|
|
135
|
+
14: WorkoutActivityType.cricket,
|
|
136
|
+
16: WorkoutActivityType.dance,
|
|
137
|
+
25: WorkoutActivityType.elliptical,
|
|
138
|
+
27: WorkoutActivityType.fencing,
|
|
139
|
+
28: WorkoutActivityType.americanFootball,
|
|
140
|
+
29: WorkoutActivityType.australianFootball,
|
|
141
|
+
32: WorkoutActivityType.golf,
|
|
142
|
+
34: WorkoutActivityType.gymnastics,
|
|
143
|
+
35: WorkoutActivityType.handball,
|
|
144
|
+
36: WorkoutActivityType.highIntensityIntervalTraining,
|
|
145
|
+
37: WorkoutActivityType.hiking,
|
|
146
|
+
38: WorkoutActivityType.hockey,
|
|
147
|
+
41: WorkoutActivityType.jumpRope,
|
|
148
|
+
44: WorkoutActivityType.martialArts,
|
|
149
|
+
48: WorkoutActivityType.pilates,
|
|
150
|
+
50: WorkoutActivityType.racquetball,
|
|
151
|
+
51: WorkoutActivityType.climbing,
|
|
152
|
+
53: WorkoutActivityType.rowing,
|
|
153
|
+
54: WorkoutActivityType.rowing,
|
|
154
|
+
55: WorkoutActivityType.rugby,
|
|
155
|
+
56: WorkoutActivityType.running,
|
|
156
|
+
58: WorkoutActivityType.sailing,
|
|
157
|
+
59: WorkoutActivityType.underwaterDiving,
|
|
158
|
+
60: WorkoutActivityType.skatingSports,
|
|
159
|
+
61: WorkoutActivityType.snowSports,
|
|
160
|
+
62: WorkoutActivityType.snowboarding,
|
|
161
|
+
64: WorkoutActivityType.soccer,
|
|
162
|
+
65: WorkoutActivityType.softball,
|
|
163
|
+
66: WorkoutActivityType.squash,
|
|
164
|
+
68: WorkoutActivityType.stairClimbing,
|
|
165
|
+
70: WorkoutActivityType.traditionalStrengthTraining,
|
|
166
|
+
71: WorkoutActivityType.flexibility,
|
|
167
|
+
72: WorkoutActivityType.surfingSports,
|
|
168
|
+
73: WorkoutActivityType.swimming,
|
|
169
|
+
74: WorkoutActivityType.swimming,
|
|
170
|
+
75: WorkoutActivityType.tableTennis,
|
|
171
|
+
76: WorkoutActivityType.tennis,
|
|
172
|
+
78: WorkoutActivityType.volleyball,
|
|
173
|
+
79: WorkoutActivityType.walking,
|
|
174
|
+
80: WorkoutActivityType.waterPolo,
|
|
175
|
+
81: WorkoutActivityType.traditionalStrengthTraining,
|
|
176
|
+
82: WorkoutActivityType.wheelchairWalkPace,
|
|
177
|
+
83: WorkoutActivityType.yoga,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export class HealthConnectProvider implements HealthProvider {
|
|
181
|
+
private initPromise: Promise<boolean> | null = null;
|
|
182
|
+
|
|
183
|
+
private async ensureInitialized(): Promise<void> {
|
|
184
|
+
if (!this.initPromise) {
|
|
185
|
+
this.initPromise = initialize().catch((e) => {
|
|
186
|
+
this.initPromise = null;
|
|
187
|
+
throw e;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
await this.initPromise;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async isAvailable(): Promise<boolean> {
|
|
194
|
+
try {
|
|
195
|
+
await this.ensureInitialized();
|
|
196
|
+
const status = await getSdkStatus();
|
|
197
|
+
return status === SdkAvailabilityStatus.SDK_AVAILABLE;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async shouldRequestAuthorization(dataTypes: HealthDataType[]): Promise<boolean> {
|
|
204
|
+
try {
|
|
205
|
+
await this.ensureInitialized();
|
|
206
|
+
const granted = await getGrantedPermissions();
|
|
207
|
+
const grantedSet = new Set(
|
|
208
|
+
granted
|
|
209
|
+
.filter((p: { accessType: string }) => p.accessType === 'read')
|
|
210
|
+
.map((p: { recordType: string }) => p.recordType),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const requiredRecordTypes = new Set<RecordType>();
|
|
214
|
+
for (const dt of dataTypes) {
|
|
215
|
+
const mapping = TYPE_MAP[dt];
|
|
216
|
+
if (mapping) {
|
|
217
|
+
requiredRecordTypes.add(mapping.recordType);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (dataTypes.includes(HealthDataType.BodyMassIndex)) {
|
|
221
|
+
requiredRecordTypes.add('Height' as RecordType);
|
|
222
|
+
requiredRecordTypes.add('Weight' as RecordType);
|
|
223
|
+
}
|
|
224
|
+
requiredRecordTypes.add('ExerciseSession');
|
|
225
|
+
requiredRecordTypes.add('TotalCaloriesBurned' as RecordType);
|
|
226
|
+
|
|
227
|
+
for (const rt of requiredRecordTypes) {
|
|
228
|
+
if (!grantedSet.has(rt)) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
} catch {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async requestAuthorization(dataTypes: HealthDataType[]): Promise<boolean> {
|
|
239
|
+
try {
|
|
240
|
+
await this.ensureInitialized();
|
|
241
|
+
|
|
242
|
+
const recordTypes = new Set<RecordType>();
|
|
243
|
+
for (const dt of dataTypes) {
|
|
244
|
+
const mapping = TYPE_MAP[dt];
|
|
245
|
+
if (mapping) {
|
|
246
|
+
recordTypes.add(mapping.recordType);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
recordTypes.add('ExerciseSession');
|
|
250
|
+
recordTypes.add('TotalCaloriesBurned' as RecordType);
|
|
251
|
+
|
|
252
|
+
const permissions = Array.from(recordTypes).map((rt) => ({
|
|
253
|
+
accessType: 'read' as const,
|
|
254
|
+
recordType: rt,
|
|
255
|
+
}));
|
|
256
|
+
|
|
257
|
+
// When BodyMassIndex is requested, also request Height and Weight permissions
|
|
258
|
+
if (dataTypes.includes(HealthDataType.BodyMassIndex)) {
|
|
259
|
+
permissions.push(
|
|
260
|
+
{ accessType: 'read', recordType: 'Height' },
|
|
261
|
+
{ accessType: 'read', recordType: 'Weight' },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const granted = await requestPermission(permissions);
|
|
266
|
+
return granted.length > 0;
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
} finally {
|
|
270
|
+
await setPermissionsRequested();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async getPermissionStatus(): Promise<PermissionStatus> {
|
|
275
|
+
try {
|
|
276
|
+
const wasRequested = await getPermissionsRequested();
|
|
277
|
+
if (!wasRequested) return PermissionStatus.UNKNOWN;
|
|
278
|
+
|
|
279
|
+
await this.ensureInitialized();
|
|
280
|
+
const granted = await getGrantedPermissions();
|
|
281
|
+
return granted.length > 0 ? PermissionStatus.GRANTED : PermissionStatus.DECLINED;
|
|
282
|
+
} catch {
|
|
283
|
+
return PermissionStatus.UNKNOWN;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async querySamples(
|
|
288
|
+
type: HealthDataType,
|
|
289
|
+
startDate: Date,
|
|
290
|
+
endDate: Date,
|
|
291
|
+
): Promise<HealthSample[]> {
|
|
292
|
+
await this.ensureInitialized();
|
|
293
|
+
|
|
294
|
+
// BMI has no native Health Connect record type — derive from Height + Weight
|
|
295
|
+
if (type === HealthDataType.BodyMassIndex) {
|
|
296
|
+
return this.deriveBMISamples(startDate, endDate);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const mapping = TYPE_MAP[type];
|
|
300
|
+
if (!mapping) return [];
|
|
301
|
+
|
|
302
|
+
const timeRangeFilter = {
|
|
303
|
+
operator: 'between' as const,
|
|
304
|
+
startTime: startDate.toISOString(),
|
|
305
|
+
endTime: endDate.toISOString(),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const { records } = await readRecords(mapping.recordType, { timeRangeFilter });
|
|
309
|
+
|
|
310
|
+
// HeartRate: flatten nested samples
|
|
311
|
+
if (mapping.recordType === 'HeartRate') {
|
|
312
|
+
const samples: HealthSample[] = [];
|
|
313
|
+
for (const record of records as HeartRateRecord[]) {
|
|
314
|
+
for (const s of record.samples) {
|
|
315
|
+
samples.push({
|
|
316
|
+
type,
|
|
317
|
+
value: s.beatsPerMinute,
|
|
318
|
+
unit: mapping.unit,
|
|
319
|
+
startDate: new Date(s.time),
|
|
320
|
+
endDate: new Date(s.time),
|
|
321
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return samples;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// BloodPressure: extract systolic or diastolic field
|
|
329
|
+
if (mapping.recordType === 'BloodPressure') {
|
|
330
|
+
const field = mapping.field as 'systolic' | 'diastolic';
|
|
331
|
+
return (records as BloodPressureRecord[]).map((record) => ({
|
|
332
|
+
type,
|
|
333
|
+
value: record[field].value,
|
|
334
|
+
unit: mapping.unit,
|
|
335
|
+
startDate: new Date(record.time),
|
|
336
|
+
endDate: new Date(record.time),
|
|
337
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// SleepSession: calculate duration in hours, with per-stage samples when available
|
|
342
|
+
if (mapping.recordType === 'SleepSession') {
|
|
343
|
+
const sleepSamples: SleepStageSample[] = [];
|
|
344
|
+
|
|
345
|
+
for (const record of records as SleepSessionRecord[]) {
|
|
346
|
+
const stages = (record as any).stages;
|
|
347
|
+
|
|
348
|
+
if (stages && stages.length > 0) {
|
|
349
|
+
for (const stage of stages) {
|
|
350
|
+
const start = new Date(stage.startTime);
|
|
351
|
+
const end = new Date(stage.endTime);
|
|
352
|
+
const durationHours =
|
|
353
|
+
(end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
354
|
+
|
|
355
|
+
sleepSamples.push({
|
|
356
|
+
type,
|
|
357
|
+
value: durationHours,
|
|
358
|
+
unit: mapping.unit,
|
|
359
|
+
startDate: start,
|
|
360
|
+
endDate: end,
|
|
361
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
362
|
+
stage: HC_SLEEP_STAGE_MAP[stage.type] ?? SleepStage.Unknown,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
const start = new Date(record.startTime);
|
|
367
|
+
const end = new Date(record.endTime);
|
|
368
|
+
const durationHours =
|
|
369
|
+
(end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
370
|
+
|
|
371
|
+
sleepSamples.push({
|
|
372
|
+
type,
|
|
373
|
+
value: durationHours,
|
|
374
|
+
unit: mapping.unit,
|
|
375
|
+
startDate: start,
|
|
376
|
+
endDate: end,
|
|
377
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
378
|
+
stage: SleepStage.Unknown,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return sleepSamples;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// BasalMetabolicRate: rate stored as kcal/day
|
|
387
|
+
if (mapping.recordType === 'BasalMetabolicRate') {
|
|
388
|
+
return records.map((record: any) => ({
|
|
389
|
+
type,
|
|
390
|
+
value: record.basalMetabolicRate?.inKilocaloriesPerDay ?? 0,
|
|
391
|
+
unit: mapping.unit,
|
|
392
|
+
startDate: new Date(record.time),
|
|
393
|
+
endDate: new Date(record.time),
|
|
394
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// BodyFat: percentage value
|
|
399
|
+
if (mapping.recordType === 'BodyFat') {
|
|
400
|
+
return records.map((record: any) => ({
|
|
401
|
+
type,
|
|
402
|
+
value: record.percentage ?? 0,
|
|
403
|
+
unit: mapping.unit,
|
|
404
|
+
startDate: new Date(record.time),
|
|
405
|
+
endDate: new Date(record.time),
|
|
406
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Hydration: volume stored as volume.inMilliliters
|
|
411
|
+
if (mapping.recordType === 'Hydration') {
|
|
412
|
+
return records.map((record: any) => ({
|
|
413
|
+
type,
|
|
414
|
+
value: record.volume?.inMilliliters ?? 0,
|
|
415
|
+
unit: mapping.unit,
|
|
416
|
+
startDate: new Date(record.startTime),
|
|
417
|
+
endDate: new Date(record.endTime),
|
|
418
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Default: single-value records with known shapes
|
|
423
|
+
return records.map((record) => {
|
|
424
|
+
let value = 0;
|
|
425
|
+
switch (mapping.recordType) {
|
|
426
|
+
case 'Steps':
|
|
427
|
+
value = (record as StepsRecord).count;
|
|
428
|
+
break;
|
|
429
|
+
case 'Distance':
|
|
430
|
+
value = (record as any).distance.inMeters;
|
|
431
|
+
break;
|
|
432
|
+
case 'ActiveCaloriesBurned':
|
|
433
|
+
value = (record as any).energy.inKilocalories;
|
|
434
|
+
break;
|
|
435
|
+
case 'FloorsClimbed':
|
|
436
|
+
value = (record as FloorsClimbedRecord).floors;
|
|
437
|
+
break;
|
|
438
|
+
case 'RestingHeartRate':
|
|
439
|
+
value = (record as RestingHeartRateRecord).beatsPerMinute;
|
|
440
|
+
break;
|
|
441
|
+
case 'OxygenSaturation':
|
|
442
|
+
value = (record as OxygenSaturationRecord).percentage;
|
|
443
|
+
break;
|
|
444
|
+
case 'RespiratoryRate':
|
|
445
|
+
value = (record as RespiratoryRateRecord).rate;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
const isInterval = 'startTime' in record;
|
|
449
|
+
const startTime = isInterval ? (record as StepsRecord).startTime : (record as RestingHeartRateRecord).time;
|
|
450
|
+
const endTime = isInterval ? (record as StepsRecord).endTime : (record as RestingHeartRateRecord).time;
|
|
451
|
+
return {
|
|
452
|
+
type,
|
|
453
|
+
value,
|
|
454
|
+
unit: mapping.unit,
|
|
455
|
+
startDate: new Date(startTime),
|
|
456
|
+
endDate: new Date(endTime),
|
|
457
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async queryAggregated(
|
|
463
|
+
type: HealthDataType,
|
|
464
|
+
startDate: Date,
|
|
465
|
+
endDate: Date,
|
|
466
|
+
): Promise<HealthAggregate> {
|
|
467
|
+
await this.ensureInitialized();
|
|
468
|
+
const mapping = TYPE_MAP[type];
|
|
469
|
+
if (!mapping) {
|
|
470
|
+
return { type, unit: '', startDate, endDate };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Manual aggregation path for types that don't support aggregateRecord
|
|
474
|
+
if (!mapping.supportsAggregation) {
|
|
475
|
+
return this.manualAggregate(type, startDate, endDate);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const timeRangeFilter = {
|
|
479
|
+
operator: 'between' as const,
|
|
480
|
+
startTime: startDate.toISOString(),
|
|
481
|
+
endTime: endDate.toISOString(),
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
let sum: number | undefined;
|
|
485
|
+
let avg: number | undefined;
|
|
486
|
+
let min: number | undefined;
|
|
487
|
+
let max: number | undefined;
|
|
488
|
+
|
|
489
|
+
switch (mapping.recordType) {
|
|
490
|
+
case 'Steps': {
|
|
491
|
+
const result = await aggregateRecord({ recordType: 'Steps', timeRangeFilter });
|
|
492
|
+
sum = result.COUNT_TOTAL;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'FloorsClimbed': {
|
|
496
|
+
const result = await aggregateRecord({ recordType: 'FloorsClimbed', timeRangeFilter });
|
|
497
|
+
sum = result.FLOORS_CLIMBED_TOTAL;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case 'Distance': {
|
|
501
|
+
const result = await aggregateRecord({ recordType: 'Distance', timeRangeFilter });
|
|
502
|
+
sum = result.DISTANCE.inMeters;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case 'ActiveCaloriesBurned': {
|
|
506
|
+
const result = await aggregateRecord({ recordType: 'ActiveCaloriesBurned', timeRangeFilter });
|
|
507
|
+
sum = result.ACTIVE_CALORIES_TOTAL.inKilocalories;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case 'HeartRate': {
|
|
511
|
+
const result = await aggregateRecord({ recordType: 'HeartRate', timeRangeFilter });
|
|
512
|
+
avg = result.BPM_AVG;
|
|
513
|
+
min = result.BPM_MIN;
|
|
514
|
+
max = result.BPM_MAX;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return { type, sum, avg, min, max, unit: mapping.unit, startDate, endDate };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async querySleepSessions(
|
|
523
|
+
startDate: Date,
|
|
524
|
+
endDate: Date,
|
|
525
|
+
): Promise<SleepSession[]> {
|
|
526
|
+
await this.ensureInitialized();
|
|
527
|
+
|
|
528
|
+
const timeRangeFilter = {
|
|
529
|
+
operator: 'between' as const,
|
|
530
|
+
startTime: startDate.toISOString(),
|
|
531
|
+
endTime: endDate.toISOString(),
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const { records } = await readRecords('SleepSession', { timeRangeFilter });
|
|
535
|
+
|
|
536
|
+
return (records as SleepSessionRecord[]).map((record) => {
|
|
537
|
+
const stages = {
|
|
538
|
+
awake: 0,
|
|
539
|
+
light: 0,
|
|
540
|
+
deep: 0,
|
|
541
|
+
rem: 0,
|
|
542
|
+
unknown: 0,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const recordStages = (record as any).stages;
|
|
546
|
+
if (recordStages) {
|
|
547
|
+
for (const stage of recordStages) {
|
|
548
|
+
const start = new Date(stage.startTime);
|
|
549
|
+
const end = new Date(stage.endTime);
|
|
550
|
+
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
551
|
+
const mapped = HC_SLEEP_STAGE_MAP[stage.type] ?? SleepStage.Unknown;
|
|
552
|
+
|
|
553
|
+
switch (mapped) {
|
|
554
|
+
case SleepStage.Awake:
|
|
555
|
+
stages.awake += hours;
|
|
556
|
+
break;
|
|
557
|
+
case SleepStage.Light:
|
|
558
|
+
stages.light += hours;
|
|
559
|
+
break;
|
|
560
|
+
case SleepStage.Deep:
|
|
561
|
+
stages.deep += hours;
|
|
562
|
+
break;
|
|
563
|
+
case SleepStage.REM:
|
|
564
|
+
stages.rem += hours;
|
|
565
|
+
break;
|
|
566
|
+
default:
|
|
567
|
+
stages.unknown += hours;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
const start = new Date(record.startTime);
|
|
573
|
+
const end = new Date(record.endTime);
|
|
574
|
+
stages.unknown =
|
|
575
|
+
(end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const totalDuration =
|
|
579
|
+
stages.light + stages.deep + stages.rem + stages.unknown;
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
startDate: new Date(record.startTime),
|
|
583
|
+
endDate: new Date(record.endTime),
|
|
584
|
+
totalDuration,
|
|
585
|
+
stages,
|
|
586
|
+
sourceName: record.metadata?.dataOrigin ?? 'Unknown',
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async queryWorkouts(startDate: Date, endDate: Date): Promise<Workout[]> {
|
|
592
|
+
await this.ensureInitialized();
|
|
593
|
+
|
|
594
|
+
const timeRangeFilter = {
|
|
595
|
+
operator: 'between' as const,
|
|
596
|
+
startTime: startDate.toISOString(),
|
|
597
|
+
endTime: endDate.toISOString(),
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const { records } = await readRecords('ExerciseSession', { timeRangeFilter });
|
|
601
|
+
|
|
602
|
+
const workouts = await Promise.all(
|
|
603
|
+
(records as ExerciseSessionRecord[]).map(async (session) => {
|
|
604
|
+
const workoutType =
|
|
605
|
+
EXERCISE_TYPE_MAP[session.exerciseType] ?? WorkoutActivityType.other;
|
|
606
|
+
const sessionStart = new Date(session.startTime);
|
|
607
|
+
const sessionEnd = new Date(session.endTime);
|
|
608
|
+
const duration = (sessionEnd.getTime() - sessionStart.getTime()) / 1000;
|
|
609
|
+
|
|
610
|
+
const sessionTimeRange = {
|
|
611
|
+
operator: 'between' as const,
|
|
612
|
+
startTime: session.startTime,
|
|
613
|
+
endTime: session.endTime,
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// Scope enrichment to the same app that created the session
|
|
617
|
+
const dataOriginFilter = session.metadata?.dataOrigin
|
|
618
|
+
? [session.metadata.dataOrigin]
|
|
619
|
+
: undefined;
|
|
620
|
+
|
|
621
|
+
// Parallel enrichment queries using aggregation
|
|
622
|
+
const [caloriesAgg, distanceAgg, hrAgg] = await Promise.all([
|
|
623
|
+
aggregateRecord({ recordType: 'TotalCaloriesBurned', timeRangeFilter: sessionTimeRange, dataOriginFilter }).catch(() => null),
|
|
624
|
+
aggregateRecord({ recordType: 'Distance', timeRangeFilter: sessionTimeRange, dataOriginFilter }).catch(() => null),
|
|
625
|
+
aggregateRecord({ recordType: 'HeartRate', timeRangeFilter: sessionTimeRange, dataOriginFilter }).catch(() => null),
|
|
626
|
+
]);
|
|
627
|
+
|
|
628
|
+
// Extract aggregated calories
|
|
629
|
+
const energyBurned = caloriesAgg?.ENERGY_TOTAL?.inKilocalories ?? null;
|
|
630
|
+
|
|
631
|
+
// Extract aggregated distance
|
|
632
|
+
const distance = distanceAgg?.DISTANCE?.inMeters ?? null;
|
|
633
|
+
|
|
634
|
+
// Extract HR avg/max from aggregation
|
|
635
|
+
const heartRateAvg = hrAgg?.BPM_AVG ?? null;
|
|
636
|
+
const heartRateMax = hrAgg?.BPM_MAX ?? null;
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
workoutType,
|
|
640
|
+
duration,
|
|
641
|
+
energyBurned,
|
|
642
|
+
distance,
|
|
643
|
+
heartRateAvg,
|
|
644
|
+
heartRateMax,
|
|
645
|
+
startDate: sessionStart,
|
|
646
|
+
endDate: sessionEnd,
|
|
647
|
+
sourceName: session.metadata?.dataOrigin ?? 'Unknown',
|
|
648
|
+
};
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// Sort by start time descending (most recent first)
|
|
653
|
+
workouts.sort((a, b) => b.startDate.getTime() - a.startDate.getTime());
|
|
654
|
+
return workouts;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private async deriveBMISamples(
|
|
658
|
+
startDate: Date,
|
|
659
|
+
endDate: Date,
|
|
660
|
+
): Promise<HealthSample[]> {
|
|
661
|
+
const timeRangeFilter = {
|
|
662
|
+
operator: 'between' as const,
|
|
663
|
+
startTime: startDate.toISOString(),
|
|
664
|
+
endTime: endDate.toISOString(),
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const { records: heightRecords } = await readRecords('Height', {
|
|
668
|
+
timeRangeFilter,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
if (heightRecords.length === 0) return [];
|
|
672
|
+
|
|
673
|
+
const latestHeight = (heightRecords as any[]).sort(
|
|
674
|
+
(a, b) => new Date(b.time).getTime() - new Date(a.time).getTime(),
|
|
675
|
+
)[0];
|
|
676
|
+
const heightMeters = latestHeight.height?.inMeters ?? 0;
|
|
677
|
+
|
|
678
|
+
if (heightMeters === 0) return [];
|
|
679
|
+
|
|
680
|
+
const { records: weightRecords } = await readRecords('Weight', {
|
|
681
|
+
timeRangeFilter,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
return (weightRecords as any[]).map((record) => {
|
|
685
|
+
const weightKg = record.weight?.inKilograms ?? 0;
|
|
686
|
+
const bmi = weightKg / (heightMeters * heightMeters);
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
type: HealthDataType.BodyMassIndex,
|
|
690
|
+
value: Math.round(bmi * 10) / 10,
|
|
691
|
+
unit: 'kg/m²',
|
|
692
|
+
startDate: new Date(record.time),
|
|
693
|
+
endDate: new Date(record.time),
|
|
694
|
+
sourceName: record.metadata?.dataOrigin ?? undefined,
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private async manualAggregate(
|
|
700
|
+
type: HealthDataType,
|
|
701
|
+
startDate: Date,
|
|
702
|
+
endDate: Date,
|
|
703
|
+
): Promise<HealthAggregate> {
|
|
704
|
+
const mapping = TYPE_MAP[type];
|
|
705
|
+
const samples = await this.querySamples(type, startDate, endDate);
|
|
706
|
+
const values = samples.map((s) => s.value);
|
|
707
|
+
const unit = mapping?.unit ?? '';
|
|
708
|
+
|
|
709
|
+
if (values.length === 0) {
|
|
710
|
+
return { type, unit, startDate, endDate };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
714
|
+
return {
|
|
715
|
+
type,
|
|
716
|
+
sum,
|
|
717
|
+
avg: sum / values.length,
|
|
718
|
+
min: values.reduce((a, b) => a < b ? a : b),
|
|
719
|
+
max: values.reduce((a, b) => a > b ? a : b),
|
|
720
|
+
unit,
|
|
721
|
+
startDate,
|
|
722
|
+
endDate,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
}
|