@clipin/convex-wearables 0.0.2 → 0.0.3

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.
Files changed (88) hide show
  1. package/dist/client/index.d.ts +9 -4
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/component/_generated/component.d.ts +50 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -0
  6. package/dist/component/_generated/component.js +11 -0
  7. package/dist/component/_generated/component.js.map +1 -0
  8. package/dist/component/backfillJobs.d.ts +11 -11
  9. package/dist/component/connections.d.ts +9 -9
  10. package/dist/component/connections.d.ts.map +1 -1
  11. package/dist/component/connections.js +2 -0
  12. package/dist/component/connections.js.map +1 -1
  13. package/dist/component/dataPoints.d.ts +5 -5
  14. package/dist/component/events.d.ts +13 -13
  15. package/dist/component/garminBackfill.d.ts +2 -2
  16. package/dist/component/garminWebhooks.d.ts +2 -2
  17. package/dist/component/garminWebhooks.d.ts.map +1 -1
  18. package/dist/component/garminWebhooks.js +2 -0
  19. package/dist/component/garminWebhooks.js.map +1 -1
  20. package/dist/component/lifecycle.d.ts +1 -1
  21. package/dist/component/lifecycle.d.ts.map +1 -1
  22. package/dist/component/lifecycle.js +2 -0
  23. package/dist/component/lifecycle.js.map +1 -1
  24. package/dist/component/oauthStates.d.ts +3 -3
  25. package/dist/component/schema.d.ts +26 -26
  26. package/dist/component/sdkPush.d.ts +11 -11
  27. package/dist/component/summaries.d.ts +4 -4
  28. package/dist/component/syncJobs.d.ts +23 -23
  29. package/dist/component/syncWorkflow.d.ts +2 -2
  30. package/dist/test.d.ts +421 -0
  31. package/dist/test.d.ts.map +1 -0
  32. package/dist/test.js +17 -0
  33. package/dist/test.js.map +1 -0
  34. package/package.json +12 -2
  35. package/src/client/_generated/_ignore.ts +2 -0
  36. package/src/client/index.test.ts +52 -0
  37. package/src/client/index.ts +784 -0
  38. package/src/client/types.ts +533 -0
  39. package/src/component/_generated/_ignore.ts +2 -0
  40. package/src/component/_generated/api.ts +16 -0
  41. package/src/component/_generated/component.ts +74 -0
  42. package/src/component/_generated/dataModel.ts +40 -0
  43. package/src/component/_generated/server.ts +48 -0
  44. package/src/component/backfillJobs.test.ts +47 -0
  45. package/src/component/backfillJobs.ts +245 -0
  46. package/src/component/connections.test.ts +297 -0
  47. package/src/component/connections.ts +329 -0
  48. package/src/component/convex.config.ts +7 -0
  49. package/src/component/dataPoints.test.ts +282 -0
  50. package/src/component/dataPoints.ts +305 -0
  51. package/src/component/dataSources.test.ts +247 -0
  52. package/src/component/dataSources.ts +109 -0
  53. package/src/component/events.test.ts +380 -0
  54. package/src/component/events.ts +288 -0
  55. package/src/component/garminBackfill.ts +343 -0
  56. package/src/component/garminWebhooks.test.ts +609 -0
  57. package/src/component/garminWebhooks.ts +656 -0
  58. package/src/component/httpHandlers.ts +153 -0
  59. package/src/component/lifecycle.test.ts +179 -0
  60. package/src/component/lifecycle.ts +87 -0
  61. package/src/component/menstrualCycles.ts +124 -0
  62. package/src/component/oauthActions.ts +261 -0
  63. package/src/component/oauthStates.test.ts +170 -0
  64. package/src/component/oauthStates.ts +85 -0
  65. package/src/component/providerSettings.ts +66 -0
  66. package/src/component/providers/additionalProviders.test.ts +401 -0
  67. package/src/component/providers/garmin.ts +1169 -0
  68. package/src/component/providers/oauth.test.ts +174 -0
  69. package/src/component/providers/oauth.ts +246 -0
  70. package/src/component/providers/polar.ts +220 -0
  71. package/src/component/providers/registry.ts +37 -0
  72. package/src/component/providers/strava.test.ts +195 -0
  73. package/src/component/providers/strava.ts +253 -0
  74. package/src/component/providers/suunto.ts +592 -0
  75. package/src/component/providers/types.ts +189 -0
  76. package/src/component/providers/whoop.ts +600 -0
  77. package/src/component/schema.ts +339 -0
  78. package/src/component/sdkPush.test.ts +367 -0
  79. package/src/component/sdkPush.ts +440 -0
  80. package/src/component/summaries.test.ts +201 -0
  81. package/src/component/summaries.ts +143 -0
  82. package/src/component/syncJobs.test.ts +254 -0
  83. package/src/component/syncJobs.ts +140 -0
  84. package/src/component/syncWorkflow.test.ts +87 -0
  85. package/src/component/syncWorkflow.ts +739 -0
  86. package/src/component/test.setup.ts +6 -0
  87. package/src/component/workflowManager.ts +19 -0
  88. package/src/test.ts +25 -0
@@ -0,0 +1,1169 @@
1
+ /**
2
+ * Garmin provider adapter.
3
+ *
4
+ * Garmin is a push-based provider: after OAuth 2.0 authorization (with PKCE),
5
+ * Garmin pushes data to registered webhook endpoints.
6
+ *
7
+ * This module handles:
8
+ * - OAuth config (OAuth 2.0 + PKCE)
9
+ * - Activity normalization (push webhook payloads)
10
+ * - Sleep normalization
11
+ * - Daily summary normalization
12
+ * - Epoch (15-min interval) normalization
13
+ * - Body composition normalization
14
+ */
15
+
16
+ import { makeAuthenticatedRequest } from "./oauth";
17
+ import type {
18
+ NormalizedDailySummary,
19
+ NormalizedDataPoint,
20
+ NormalizedEvent,
21
+ OAuthProviderConfig,
22
+ ProviderAdapter,
23
+ ProviderCredentials,
24
+ ProviderUserInfo,
25
+ } from "./types";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // OAuth config
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const API_BASE = "https://apis.garmin.com";
32
+
33
+ export function garminOAuthConfig(credentials: ProviderCredentials): OAuthProviderConfig {
34
+ return {
35
+ endpoints: {
36
+ authorizeUrl: "https://connect.garmin.com/oauth2Confirm",
37
+ tokenUrl: "https://connectapi.garmin.com/di-oauth2-service/oauth/token",
38
+ apiBaseUrl: API_BASE,
39
+ },
40
+ clientId: credentials.clientId,
41
+ clientSecret: credentials.clientSecret,
42
+ defaultScope: "", // Scope is managed at app creation in Garmin Developer Portal
43
+ usePkce: true,
44
+ authMethod: "body",
45
+ };
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Garmin API types (push webhook payloads)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface GarminActivitySummaryData {
53
+ activityId?: number | string;
54
+ summaryId?: string;
55
+ activityName?: string;
56
+ activityType?: string;
57
+ startTimeInSeconds?: number | string;
58
+ startTimeOffsetInSeconds?: number | string;
59
+ durationInSeconds?: number | string;
60
+ deviceName?: string;
61
+ distanceInMeters?: number;
62
+ steps?: number;
63
+ activeKilocalories?: number;
64
+ averageHeartRateInBeatsPerMinute?: number;
65
+ maxHeartRateInBeatsPerMinute?: number;
66
+ averageSpeedInMetersPerSecond?: number;
67
+ maxSpeedInMetersPerSecond?: number;
68
+ averagePowerInWatts?: number;
69
+ maxPowerInWatts?: number;
70
+ totalElevationGainInMeters?: number;
71
+ elapsedDurationInSeconds?: number;
72
+ movingDurationInSeconds?: number;
73
+ manual?: boolean;
74
+ }
75
+
76
+ export interface GarminActivity extends GarminActivitySummaryData {
77
+ userId: string;
78
+ activityId: number | string;
79
+ summary?: GarminActivitySummaryData;
80
+ }
81
+
82
+ export interface GarminSleep {
83
+ userId: string;
84
+ summaryId: string;
85
+ startTimeInSeconds: number;
86
+ startTimeOffsetInSeconds?: number;
87
+ durationInSeconds: number;
88
+ deepSleepDurationInSeconds?: number;
89
+ lightSleepDurationInSeconds?: number;
90
+ remSleepInSeconds?: number;
91
+ awakeDurationInSeconds?: number;
92
+ averageHeartRate?: number;
93
+ lowestHeartRate?: number;
94
+ avgOxygenSaturation?: number;
95
+ respirationAvg?: number;
96
+ overallSleepScore?: { value?: number };
97
+ validation?: string;
98
+ sleepLevelsMap?: Record<string, Array<{ startTimeInSeconds: number; endTimeInSeconds: number }>>;
99
+ }
100
+
101
+ export interface GarminDaily {
102
+ userId: string;
103
+ summaryId: string;
104
+ startTimeInSeconds: number;
105
+ durationInSeconds: number;
106
+ calendarDate?: string;
107
+ steps?: number;
108
+ distanceInMeters?: number;
109
+ activeKilocalories?: number;
110
+ bmrKilocalories?: number;
111
+ floorsClimbed?: number;
112
+ minHeartRateInBeatsPerMinute?: number;
113
+ maxHeartRateInBeatsPerMinute?: number;
114
+ averageHeartRateInBeatsPerMinute?: number;
115
+ restingHeartRateInBeatsPerMinute?: number;
116
+ averageStressLevel?: number;
117
+ bodyBatteryChargedValue?: number;
118
+ bodyBatteryDrainedValue?: number;
119
+ moderateIntensityDurationInSeconds?: number;
120
+ vigorousIntensityDurationInSeconds?: number;
121
+ timeOffsetHeartRateSamples?: Record<string, number>;
122
+ }
123
+
124
+ export interface GarminEpoch {
125
+ userId: string;
126
+ summaryId: string;
127
+ startTimeInSeconds: number;
128
+ durationInSeconds: number;
129
+ steps?: number;
130
+ distanceInMeters?: number;
131
+ activeKilocalories?: number;
132
+ meanHeartRateInBeatsPerMinute?: number;
133
+ maxHeartRateInBeatsPerMinute?: number;
134
+ intensity?: string;
135
+ }
136
+
137
+ export interface GarminBodyComp {
138
+ userId: string;
139
+ summaryId: string;
140
+ measurementTimeInSeconds: number;
141
+ weightInGrams?: number;
142
+ bodyFatInPercent?: number;
143
+ bodyMassIndex?: number;
144
+ muscleMassInGrams?: number;
145
+ }
146
+
147
+ export interface GarminHrv {
148
+ userId: string;
149
+ summaryId?: string;
150
+ startTimeInSeconds: number;
151
+ calendarDate?: string;
152
+ lastNightAvg?: number;
153
+ hrvValues?: Record<string, number>;
154
+ }
155
+
156
+ export interface GarminStressDetails {
157
+ userId: string;
158
+ summaryId?: string;
159
+ startTimeInSeconds: number;
160
+ stressLevelValues?: Record<string, number>;
161
+ bodyBatteryValues?: Record<string, number>;
162
+ }
163
+
164
+ export interface GarminRespiration {
165
+ userId: string;
166
+ summaryId?: string;
167
+ startTimeInSeconds: number;
168
+ calendarDate?: string;
169
+ avgWakingRespirationValue?: number;
170
+ timeOffsetRespirationRateValues?: Record<string, number>;
171
+ timeOffsetRespirationValues?: Record<string, number>;
172
+ }
173
+
174
+ export interface GarminPulseOx {
175
+ userId: string;
176
+ summaryId?: string;
177
+ startTimeInSeconds: number;
178
+ calendarDate?: string;
179
+ avgSpo2?: number;
180
+ timeOffsetSpo2Values?: Record<string, number>;
181
+ }
182
+
183
+ export interface GarminBloodPressure {
184
+ userId: string;
185
+ summaryId?: string;
186
+ measurementTimestampGMT?: number;
187
+ startTimeInSeconds?: number;
188
+ systolic?: number;
189
+ diastolic?: number;
190
+ }
191
+
192
+ export interface GarminUserMetrics {
193
+ userId: string;
194
+ summaryId?: string;
195
+ calendarDate?: string;
196
+ vo2Max?: number;
197
+ fitnessAge?: number;
198
+ }
199
+
200
+ export interface GarminSkinTemp {
201
+ userId: string;
202
+ summaryId?: string;
203
+ startTimeInSeconds: number;
204
+ skinTemperature?: number;
205
+ }
206
+
207
+ export interface GarminHealthSnapshot {
208
+ userId: string;
209
+ summaryId?: string;
210
+ startTimeInSeconds: number;
211
+ heartRate?: number;
212
+ hrv?: number;
213
+ stress?: number;
214
+ spo2?: number;
215
+ respiration?: number;
216
+ }
217
+
218
+ export interface GarminMoveIQ {
219
+ userId: string;
220
+ summaryId?: string;
221
+ startTimeInSeconds: number;
222
+ durationInSeconds?: number;
223
+ activityType?: string;
224
+ }
225
+
226
+ export interface GarminMCTSummary {
227
+ userId: string;
228
+ summaryId: string;
229
+ startTimeInSeconds?: number;
230
+ startTimeOffsetInSeconds?: number;
231
+ periodStartDateStr?: string; // "2026-03-01" ISO date
232
+ dayInCycle?: number;
233
+ cycleLength?: number;
234
+ predictedCycleLength?: number;
235
+ periodLength?: number;
236
+ currentPhase?: number; // numeric phase ID
237
+ currentPhaseType?: string; // "MENSTRUAL", "FOLLICULAR", "OVULATION", "LUTEAL", etc.
238
+ lengthOfCurrentPhase?: number;
239
+ daysUntilNextPhase?: number;
240
+ isPredictedCycle?: boolean;
241
+ fertileWindowStart?: number;
242
+ lengthOfFertileWindow?: number;
243
+ lastUpdatedAt?: number; // unix seconds
244
+ isPregnant?: boolean;
245
+ pregnancyDueDate?: string; // "2026-09-15" ISO date
246
+ pregnancyOriginalDueDate?: string;
247
+ pregnancyCycleStartDate?: string;
248
+ pregnancyTitle?: string;
249
+ numberOfBabies?: string; // "SINGLE", "TWINS", etc.
250
+ }
251
+
252
+ export interface GarminPushPayload {
253
+ activities?: GarminActivity[];
254
+ activityDetails?: GarminActivity[];
255
+ sleeps?: GarminSleep[];
256
+ dailies?: GarminDaily[];
257
+ epochs?: GarminEpoch[];
258
+ bodyComps?: GarminBodyComp[];
259
+ hrv?: GarminHrv[];
260
+ stressDetails?: GarminStressDetails[];
261
+ respiration?: GarminRespiration[];
262
+ pulseOx?: GarminPulseOx[];
263
+ bloodPressures?: GarminBloodPressure[];
264
+ userMetrics?: GarminUserMetrics[];
265
+ skinTemp?: GarminSkinTemp[];
266
+ healthSnapshot?: GarminHealthSnapshot[];
267
+ moveiq?: GarminMoveIQ[];
268
+ menstrualCycleTracking?: GarminMCTSummary[];
269
+ mct?: GarminMCTSummary[];
270
+ deregistrations?: Array<{ userId: string }>;
271
+ userPermissionsChange?: Array<{ userId: string; permissions: string[] }>;
272
+ }
273
+
274
+ function isoDateFromTimestamp(timestampMs: number): string {
275
+ return new Date(timestampMs).toISOString().split("T")[0] ?? "";
276
+ }
277
+
278
+ function isoDateFromCalendarDate(
279
+ calendarDate: string | undefined,
280
+ fallbackTimestampMs: number,
281
+ ): string {
282
+ return calendarDate ?? isoDateFromTimestamp(fallbackTimestampMs);
283
+ }
284
+
285
+ function calendarDateToMiddayTimestamp(calendarDate: string | undefined): number | null {
286
+ if (!calendarDate) {
287
+ return null;
288
+ }
289
+
290
+ const timestamp = Date.parse(`${calendarDate}T12:00:00Z`);
291
+ return Number.isFinite(timestamp) ? timestamp : null;
292
+ }
293
+
294
+ function buildOffsetDataPoints(
295
+ values: Record<string, number> | undefined,
296
+ startTimeInSeconds: number,
297
+ seriesType: string,
298
+ externalIdPrefix?: string,
299
+ ): NormalizedDataPoint[] {
300
+ if (!values) {
301
+ return [];
302
+ }
303
+
304
+ return Object.entries(values).reduce<NormalizedDataPoint[]>((points, [offsetStr, value]) => {
305
+ const offsetSeconds = Number(offsetStr);
306
+ if (!Number.isFinite(offsetSeconds) || !Number.isFinite(value)) {
307
+ return points;
308
+ }
309
+
310
+ points.push({
311
+ seriesType,
312
+ recordedAt: (startTimeInSeconds + offsetSeconds) * 1000,
313
+ value,
314
+ externalId: externalIdPrefix ? `${externalIdPrefix}:${offsetStr}` : undefined,
315
+ });
316
+
317
+ return points;
318
+ }, []);
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Workout type mapping (Garmin activityType → unified type)
323
+ // ---------------------------------------------------------------------------
324
+
325
+ const WORKOUT_TYPE_MAP: Record<string, string> = {
326
+ RUNNING: "running",
327
+ TRAIL_RUNNING: "trail_running",
328
+ TREADMILL_RUNNING: "treadmill",
329
+ VIRTUAL_RUN: "running",
330
+ INDOOR_RUNNING: "treadmill",
331
+ CYCLING: "cycling",
332
+ MOUNTAIN_BIKING: "mountain_biking",
333
+ GRAVEL_CYCLING: "cycling",
334
+ INDOOR_CYCLING: "indoor_cycling",
335
+ VIRTUAL_RIDE: "indoor_cycling",
336
+ ROAD_BIKING: "cycling",
337
+ BMX: "cycling",
338
+ RECUMBENT_CYCLING: "cycling",
339
+ E_BIKE_MOUNTAIN: "e_biking",
340
+ E_BIKE_FITNESS: "e_biking",
341
+ SWIMMING: "swimming",
342
+ LAP_SWIMMING: "swimming",
343
+ OPEN_WATER_SWIMMING: "open_water_swimming",
344
+ POOL_SWIM: "swimming",
345
+ HIKING: "hiking",
346
+ WALKING: "walking",
347
+ CASUAL_WALKING: "walking",
348
+ SPEED_WALKING: "walking",
349
+ YOGA: "yoga",
350
+ PILATES: "pilates",
351
+ STRENGTH_TRAINING: "strength_training",
352
+ CARDIO_TRAINING: "cardio_training",
353
+ ELLIPTICAL: "elliptical",
354
+ STAIR_CLIMBING: "stair_climbing",
355
+ INDOOR_ROWING: "rowing_machine",
356
+ ROWING: "rowing",
357
+ KAYAKING: "kayaking",
358
+ STAND_UP_PADDLEBOARDING: "stand_up_paddleboarding",
359
+ SURFING: "surfing",
360
+ KITEBOARDING: "kitesurfing",
361
+ WINDSURFING: "windsurfing",
362
+ SAILING: "sailing",
363
+ TENNIS: "tennis",
364
+ TABLE_TENNIS: "table_tennis",
365
+ PICKLEBALL: "pickleball",
366
+ BADMINTON: "badminton",
367
+ SQUASH: "squash",
368
+ RACQUETBALL: "squash",
369
+ PADEL: "padel",
370
+ SOCCER: "soccer",
371
+ BASKETBALL: "basketball",
372
+ VOLLEYBALL: "volleyball",
373
+ FOOTBALL: "football",
374
+ BASEBALL: "baseball",
375
+ SOFTBALL: "softball",
376
+ RUGBY: "rugby",
377
+ HOCKEY: "hockey",
378
+ LACROSSE: "lacrosse",
379
+ CRICKET: "cricket",
380
+ GOLF: "golf",
381
+ DISC_GOLF: "golf",
382
+ ROCK_CLIMBING: "rock_climbing",
383
+ BOULDERING: "rock_climbing",
384
+ INDOOR_CLIMBING: "rock_climbing",
385
+ SKATEBOARDING: "skateboarding",
386
+ INLINE_SKATING: "inline_skating",
387
+ ICE_SKATING: "ice_skating",
388
+ SKIING: "alpine_skiing",
389
+ RESORT_SKIING_SNOWBOARDING: "alpine_skiing",
390
+ BACKCOUNTRY_SKIING_SNOWBOARDING: "backcountry_skiing",
391
+ CROSS_COUNTRY_SKIING: "cross_country_skiing",
392
+ SNOWBOARDING: "snowboarding",
393
+ SNOWSHOEING: "snowshoeing",
394
+ BOXING: "boxing",
395
+ MARTIAL_ARTS: "martial_arts",
396
+ JUMP_ROPE: "jump_rope",
397
+ HIIT: "cardio_training",
398
+ FITNESS_EQUIPMENT: "cardio_training",
399
+ BREATHWORK: "breathwork",
400
+ MEDITATION: "meditation",
401
+ OTHER: "other",
402
+ };
403
+
404
+ function getUnifiedWorkoutType(activityType: string): string {
405
+ return WORKOUT_TYPE_MAP[activityType.trim().toUpperCase()] ?? "other";
406
+ }
407
+
408
+ function coerceFiniteNumber(value: number | string | undefined): number | null {
409
+ if (typeof value === "number") {
410
+ return Number.isFinite(value) ? value : null;
411
+ }
412
+
413
+ if (typeof value !== "string") {
414
+ return null;
415
+ }
416
+
417
+ const trimmed = value.trim();
418
+ if (!trimmed) {
419
+ return null;
420
+ }
421
+
422
+ const numericValue = Number(trimmed);
423
+ return Number.isFinite(numericValue) ? numericValue : null;
424
+ }
425
+
426
+ function parseGarminTimestampMs(value: number | string | undefined): number | null {
427
+ const numericValue = coerceFiniteNumber(value);
428
+ if (numericValue != null) {
429
+ return numericValue * 1000;
430
+ }
431
+
432
+ if (typeof value !== "string") {
433
+ return null;
434
+ }
435
+
436
+ const parsed = Date.parse(value);
437
+ return Number.isFinite(parsed) ? parsed : null;
438
+ }
439
+
440
+ function getActivitySummary(activity: GarminActivity): GarminActivitySummaryData {
441
+ return activity.summary ?? activity;
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Normalization — Activities
446
+ // ---------------------------------------------------------------------------
447
+
448
+ export function normalizeActivity(activity: GarminActivity): NormalizedEvent | null {
449
+ const summary = getActivitySummary(activity);
450
+ const startMs = parseGarminTimestampMs(summary.startTimeInSeconds);
451
+ const durationSeconds = coerceFiniteNumber(summary.durationInSeconds);
452
+
453
+ if (startMs == null || durationSeconds == null) {
454
+ return null;
455
+ }
456
+
457
+ const activityId = activity.activityId ?? summary.activityId;
458
+ const endMs = startMs + durationSeconds * 1000;
459
+
460
+ return {
461
+ category: "workout",
462
+ type: summary.activityType ? getUnifiedWorkoutType(summary.activityType) : "other",
463
+ sourceName: summary.deviceName ?? "Garmin",
464
+ deviceModel: summary.deviceName,
465
+ durationSeconds,
466
+ startDatetime: startMs,
467
+ endDatetime: endMs,
468
+ externalId: activityId != null ? `garmin-${activityId}` : undefined,
469
+
470
+ heartRateAvg: summary.averageHeartRateInBeatsPerMinute,
471
+ heartRateMax: summary.maxHeartRateInBeatsPerMinute,
472
+ energyBurned: summary.activeKilocalories,
473
+ distance: summary.distanceInMeters,
474
+ stepsCount: summary.steps,
475
+ averageSpeed: summary.averageSpeedInMetersPerSecond,
476
+ maxSpeed: summary.maxSpeedInMetersPerSecond,
477
+ averageWatts: summary.averagePowerInWatts,
478
+ maxWatts: summary.maxPowerInWatts,
479
+ totalElevationGain: summary.totalElevationGainInMeters,
480
+ movingTimeSeconds: summary.movingDurationInSeconds,
481
+ };
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // Normalization — Sleep
486
+ // ---------------------------------------------------------------------------
487
+
488
+ export function normalizeSleep(sleep: GarminSleep): NormalizedEvent {
489
+ const startMs = sleep.startTimeInSeconds * 1000;
490
+ const endMs = startMs + sleep.durationInSeconds * 1000;
491
+
492
+ // Build sleep stages from sleepLevelsMap if available
493
+ let sleepStages: { stage: string; startTime: number; endTime: number }[] | undefined;
494
+ if (sleep.sleepLevelsMap) {
495
+ sleepStages = [];
496
+ const stageMapping: Record<string, string> = {
497
+ deep: "deep",
498
+ light: "light",
499
+ rem: "rem",
500
+ awake: "awake",
501
+ };
502
+ for (const [key, intervals] of Object.entries(sleep.sleepLevelsMap)) {
503
+ const stage = stageMapping[key] ?? key;
504
+ for (const interval of intervals) {
505
+ sleepStages.push({
506
+ stage,
507
+ startTime: interval.startTimeInSeconds * 1000,
508
+ endTime: interval.endTimeInSeconds * 1000,
509
+ });
510
+ }
511
+ }
512
+ // Sort by start time
513
+ sleepStages.sort((a, b) => a.startTime - b.startTime);
514
+ }
515
+
516
+ return {
517
+ category: "sleep",
518
+ type: "sleep_session",
519
+ sourceName: "Garmin",
520
+ durationSeconds: sleep.durationInSeconds,
521
+ startDatetime: startMs,
522
+ endDatetime: endMs,
523
+ externalId: `garmin-sleep-${sleep.summaryId}`,
524
+
525
+ heartRateAvg: sleep.averageHeartRate,
526
+ heartRateMin: sleep.lowestHeartRate,
527
+ sleepTotalDurationMinutes: Math.floor(sleep.durationInSeconds / 60),
528
+ sleepDeepMinutes: sleep.deepSleepDurationInSeconds
529
+ ? Math.floor(sleep.deepSleepDurationInSeconds / 60)
530
+ : undefined,
531
+ sleepLightMinutes: sleep.lightSleepDurationInSeconds
532
+ ? Math.floor(sleep.lightSleepDurationInSeconds / 60)
533
+ : undefined,
534
+ sleepRemMinutes: sleep.remSleepInSeconds ? Math.floor(sleep.remSleepInSeconds / 60) : undefined,
535
+ sleepAwakeMinutes: sleep.awakeDurationInSeconds
536
+ ? Math.floor(sleep.awakeDurationInSeconds / 60)
537
+ : undefined,
538
+ sleepEfficiencyScore: sleep.overallSleepScore?.value,
539
+ sleepStages,
540
+ };
541
+ }
542
+
543
+ export function normalizeSleepSummary(sleep: GarminSleep): NormalizedDailySummary {
544
+ return {
545
+ date: isoDateFromTimestamp(sleep.startTimeInSeconds * 1000),
546
+ category: "sleep",
547
+ sleepDurationMinutes: Math.floor(sleep.durationInSeconds / 60),
548
+ sleepEfficiency: sleep.overallSleepScore?.value,
549
+ deepSleepMinutes: sleep.deepSleepDurationInSeconds
550
+ ? Math.floor(sleep.deepSleepDurationInSeconds / 60)
551
+ : undefined,
552
+ lightSleepMinutes: sleep.lightSleepDurationInSeconds
553
+ ? Math.floor(sleep.lightSleepDurationInSeconds / 60)
554
+ : undefined,
555
+ remSleepMinutes: sleep.remSleepInSeconds ? Math.floor(sleep.remSleepInSeconds / 60) : undefined,
556
+ awakeDuringMinutes: sleep.awakeDurationInSeconds
557
+ ? Math.floor(sleep.awakeDurationInSeconds / 60)
558
+ : undefined,
559
+ };
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Normalization — Daily Summary
564
+ // ---------------------------------------------------------------------------
565
+
566
+ export interface GarminDailySummaryNormalized {
567
+ userId: string;
568
+ date: string;
569
+ totalSteps?: number;
570
+ totalCalories?: number;
571
+ activeCalories?: number;
572
+ totalDistance?: number;
573
+ floorsClimbed?: number;
574
+ avgHeartRate?: number;
575
+ maxHeartRate?: number;
576
+ minHeartRate?: number;
577
+ restingHeartRate?: number;
578
+ avgStressLevel?: number;
579
+ bodyBattery?: number;
580
+ activeMinutes?: number;
581
+ /** Heart rate samples: { timestampMs: bpm } */
582
+ heartRateSamples?: { timestamp: number; value: number }[];
583
+ }
584
+
585
+ export function normalizeDaily(daily: GarminDaily): GarminDailySummaryNormalized {
586
+ const activeMinutes =
587
+ ((daily.moderateIntensityDurationInSeconds ?? 0) +
588
+ (daily.vigorousIntensityDurationInSeconds ?? 0)) /
589
+ 60 || undefined;
590
+
591
+ // Parse heart rate time-offset samples into absolute timestamps
592
+ let heartRateSamples: { timestamp: number; value: number }[] | undefined;
593
+ if (daily.timeOffsetHeartRateSamples) {
594
+ const baseMs = daily.startTimeInSeconds * 1000;
595
+ heartRateSamples = Object.entries(daily.timeOffsetHeartRateSamples).map(([offsetStr, bpm]) => ({
596
+ timestamp: baseMs + Number(offsetStr) * 1000,
597
+ value: bpm,
598
+ }));
599
+ }
600
+
601
+ const totalCalories =
602
+ daily.activeKilocalories != null && daily.bmrKilocalories != null
603
+ ? daily.activeKilocalories + daily.bmrKilocalories
604
+ : daily.activeKilocalories;
605
+
606
+ return {
607
+ userId: daily.userId,
608
+ date:
609
+ daily.calendarDate ?? new Date(daily.startTimeInSeconds * 1000).toISOString().split("T")[0],
610
+ totalSteps: daily.steps,
611
+ totalCalories,
612
+ activeCalories: daily.activeKilocalories,
613
+ totalDistance: daily.distanceInMeters,
614
+ floorsClimbed: daily.floorsClimbed,
615
+ avgHeartRate: daily.averageHeartRateInBeatsPerMinute,
616
+ maxHeartRate: daily.maxHeartRateInBeatsPerMinute,
617
+ minHeartRate: daily.minHeartRateInBeatsPerMinute,
618
+ restingHeartRate: daily.restingHeartRateInBeatsPerMinute,
619
+ avgStressLevel: daily.averageStressLevel,
620
+ bodyBattery:
621
+ daily.bodyBatteryChargedValue != null && daily.bodyBatteryDrainedValue != null
622
+ ? daily.bodyBatteryChargedValue - daily.bodyBatteryDrainedValue
623
+ : undefined,
624
+ activeMinutes: activeMinutes ? Math.round(activeMinutes) : undefined,
625
+ heartRateSamples,
626
+ };
627
+ }
628
+
629
+ export function normalizeDailyRecoverySummary(daily: GarminDaily): NormalizedDailySummary {
630
+ const normalized = normalizeDaily(daily);
631
+ return {
632
+ date: normalized.date,
633
+ category: "recovery",
634
+ restingHeartRate: normalized.restingHeartRate,
635
+ avgStressLevel: normalized.avgStressLevel,
636
+ bodyBattery: normalized.bodyBattery,
637
+ };
638
+ }
639
+
640
+ export function normalizeEpochDataPoints(epoch: GarminEpoch): NormalizedDataPoint[] {
641
+ const recordedAt = epoch.startTimeInSeconds * 1000;
642
+ const points: NormalizedDataPoint[] = [];
643
+
644
+ if (epoch.meanHeartRateInBeatsPerMinute != null) {
645
+ points.push({
646
+ seriesType: "heart_rate",
647
+ recordedAt,
648
+ value: epoch.meanHeartRateInBeatsPerMinute,
649
+ externalId: epoch.summaryId ? `${epoch.summaryId}:heart_rate` : undefined,
650
+ });
651
+ }
652
+
653
+ if (epoch.steps != null) {
654
+ points.push({
655
+ seriesType: "steps",
656
+ recordedAt,
657
+ value: epoch.steps,
658
+ externalId: epoch.summaryId ? `${epoch.summaryId}:steps` : undefined,
659
+ });
660
+ }
661
+
662
+ if (epoch.activeKilocalories != null) {
663
+ points.push({
664
+ seriesType: "energy",
665
+ recordedAt,
666
+ value: epoch.activeKilocalories,
667
+ externalId: epoch.summaryId ? `${epoch.summaryId}:energy` : undefined,
668
+ });
669
+ }
670
+
671
+ return points;
672
+ }
673
+
674
+ export function normalizeBodyCompositionDataPoints(
675
+ bodyComp: GarminBodyComp,
676
+ ): NormalizedDataPoint[] {
677
+ const recordedAt = bodyComp.measurementTimeInSeconds * 1000;
678
+ const points: NormalizedDataPoint[] = [];
679
+
680
+ if (bodyComp.weightInGrams != null) {
681
+ points.push({
682
+ seriesType: "weight",
683
+ recordedAt,
684
+ value: bodyComp.weightInGrams / 1000,
685
+ externalId: bodyComp.summaryId ? `${bodyComp.summaryId}:weight` : undefined,
686
+ });
687
+ }
688
+
689
+ if (bodyComp.bodyFatInPercent != null) {
690
+ points.push({
691
+ seriesType: "body_fat_percentage",
692
+ recordedAt,
693
+ value: bodyComp.bodyFatInPercent,
694
+ externalId: bodyComp.summaryId ? `${bodyComp.summaryId}:body_fat_percentage` : undefined,
695
+ });
696
+ }
697
+
698
+ if (bodyComp.bodyMassIndex != null) {
699
+ points.push({
700
+ seriesType: "body_mass_index",
701
+ recordedAt,
702
+ value: bodyComp.bodyMassIndex,
703
+ externalId: bodyComp.summaryId ? `${bodyComp.summaryId}:body_mass_index` : undefined,
704
+ });
705
+ }
706
+
707
+ if (bodyComp.muscleMassInGrams != null) {
708
+ points.push({
709
+ seriesType: "skeletal_muscle_mass",
710
+ recordedAt,
711
+ value: bodyComp.muscleMassInGrams / 1000,
712
+ externalId: bodyComp.summaryId ? `${bodyComp.summaryId}:skeletal_muscle_mass` : undefined,
713
+ });
714
+ }
715
+
716
+ return points;
717
+ }
718
+
719
+ export function normalizeBodyCompositionSummary(bodyComp: GarminBodyComp): NormalizedDailySummary {
720
+ return {
721
+ date: isoDateFromTimestamp(bodyComp.measurementTimeInSeconds * 1000),
722
+ category: "body",
723
+ weight: bodyComp.weightInGrams != null ? bodyComp.weightInGrams / 1000 : undefined,
724
+ bodyFatPercentage: bodyComp.bodyFatInPercent,
725
+ bodyMassIndex: bodyComp.bodyMassIndex,
726
+ };
727
+ }
728
+
729
+ export function normalizeHrvDataPoints(hrv: GarminHrv): NormalizedDataPoint[] {
730
+ if (!hrv.startTimeInSeconds) {
731
+ return [];
732
+ }
733
+
734
+ const points: NormalizedDataPoint[] = [];
735
+ if (hrv.lastNightAvg != null) {
736
+ points.push({
737
+ seriesType: "heart_rate_variability_sdnn",
738
+ recordedAt: hrv.startTimeInSeconds * 1000,
739
+ value: hrv.lastNightAvg,
740
+ externalId: hrv.summaryId,
741
+ });
742
+ }
743
+
744
+ points.push(
745
+ ...buildOffsetDataPoints(
746
+ hrv.hrvValues,
747
+ hrv.startTimeInSeconds,
748
+ "heart_rate_variability_sdnn",
749
+ hrv.summaryId,
750
+ ),
751
+ );
752
+
753
+ return points;
754
+ }
755
+
756
+ export function normalizeHrvSummary(hrv: GarminHrv): NormalizedDailySummary | null {
757
+ if (hrv.lastNightAvg == null || !hrv.startTimeInSeconds) {
758
+ return null;
759
+ }
760
+
761
+ return {
762
+ date: isoDateFromCalendarDate(hrv.calendarDate, hrv.startTimeInSeconds * 1000),
763
+ category: "recovery",
764
+ hrvAvg: hrv.lastNightAvg,
765
+ };
766
+ }
767
+
768
+ export function normalizeStressDataPoints(stress: GarminStressDetails): NormalizedDataPoint[] {
769
+ if (!stress.startTimeInSeconds) {
770
+ return [];
771
+ }
772
+
773
+ return [
774
+ ...buildOffsetDataPoints(
775
+ stress.stressLevelValues,
776
+ stress.startTimeInSeconds,
777
+ "garmin_stress_level",
778
+ stress.summaryId ? `${stress.summaryId}:stress` : undefined,
779
+ ),
780
+ ...buildOffsetDataPoints(
781
+ stress.bodyBatteryValues,
782
+ stress.startTimeInSeconds,
783
+ "garmin_body_battery",
784
+ stress.summaryId ? `${stress.summaryId}:body_battery` : undefined,
785
+ ),
786
+ ];
787
+ }
788
+
789
+ export function normalizeRespirationDataPoints(
790
+ respiration: GarminRespiration,
791
+ ): NormalizedDataPoint[] {
792
+ if (!respiration.startTimeInSeconds) {
793
+ return [];
794
+ }
795
+
796
+ const points: NormalizedDataPoint[] = [];
797
+ if (respiration.avgWakingRespirationValue != null) {
798
+ points.push({
799
+ seriesType: "respiratory_rate",
800
+ recordedAt: respiration.startTimeInSeconds * 1000,
801
+ value: respiration.avgWakingRespirationValue,
802
+ externalId: respiration.summaryId,
803
+ });
804
+ }
805
+
806
+ points.push(
807
+ ...buildOffsetDataPoints(
808
+ respiration.timeOffsetRespirationRateValues ?? respiration.timeOffsetRespirationValues,
809
+ respiration.startTimeInSeconds,
810
+ "respiratory_rate",
811
+ respiration.summaryId,
812
+ ),
813
+ );
814
+
815
+ return points;
816
+ }
817
+
818
+ export function normalizePulseOxDataPoints(pulseOx: GarminPulseOx): NormalizedDataPoint[] {
819
+ if (!pulseOx.startTimeInSeconds) {
820
+ return [];
821
+ }
822
+
823
+ const points: NormalizedDataPoint[] = [];
824
+ if (pulseOx.avgSpo2 != null) {
825
+ points.push({
826
+ seriesType: "oxygen_saturation",
827
+ recordedAt: pulseOx.startTimeInSeconds * 1000,
828
+ value: pulseOx.avgSpo2,
829
+ externalId: pulseOx.summaryId,
830
+ });
831
+ }
832
+
833
+ points.push(
834
+ ...buildOffsetDataPoints(
835
+ pulseOx.timeOffsetSpo2Values,
836
+ pulseOx.startTimeInSeconds,
837
+ "oxygen_saturation",
838
+ pulseOx.summaryId,
839
+ ),
840
+ );
841
+
842
+ return points;
843
+ }
844
+
845
+ export function normalizePulseOxSummary(pulseOx: GarminPulseOx): NormalizedDailySummary | null {
846
+ if (pulseOx.avgSpo2 == null || !pulseOx.startTimeInSeconds) {
847
+ return null;
848
+ }
849
+
850
+ return {
851
+ date: isoDateFromCalendarDate(pulseOx.calendarDate, pulseOx.startTimeInSeconds * 1000),
852
+ category: "recovery",
853
+ spo2Avg: pulseOx.avgSpo2,
854
+ };
855
+ }
856
+
857
+ export function normalizeBloodPressureDataPoints(
858
+ bloodPressure: GarminBloodPressure,
859
+ ): NormalizedDataPoint[] {
860
+ const measurementSeconds =
861
+ bloodPressure.measurementTimestampGMT ?? bloodPressure.startTimeInSeconds;
862
+ if (!measurementSeconds) {
863
+ return [];
864
+ }
865
+
866
+ const recordedAt = measurementSeconds * 1000;
867
+ const points: NormalizedDataPoint[] = [];
868
+
869
+ if (bloodPressure.systolic != null) {
870
+ points.push({
871
+ seriesType: "blood_pressure_systolic",
872
+ recordedAt,
873
+ value: bloodPressure.systolic,
874
+ externalId: bloodPressure.summaryId ? `${bloodPressure.summaryId}:systolic` : undefined,
875
+ });
876
+ }
877
+
878
+ if (bloodPressure.diastolic != null) {
879
+ points.push({
880
+ seriesType: "blood_pressure_diastolic",
881
+ recordedAt,
882
+ value: bloodPressure.diastolic,
883
+ externalId: bloodPressure.summaryId ? `${bloodPressure.summaryId}:diastolic` : undefined,
884
+ });
885
+ }
886
+
887
+ return points;
888
+ }
889
+
890
+ export function normalizeUserMetricsDataPoints(
891
+ userMetrics: GarminUserMetrics,
892
+ ): NormalizedDataPoint[] {
893
+ const recordedAt = calendarDateToMiddayTimestamp(userMetrics.calendarDate);
894
+ if (recordedAt === null) {
895
+ return [];
896
+ }
897
+
898
+ const points: NormalizedDataPoint[] = [];
899
+ if (userMetrics.vo2Max != null) {
900
+ points.push({
901
+ seriesType: "vo2_max",
902
+ recordedAt,
903
+ value: userMetrics.vo2Max,
904
+ externalId: userMetrics.summaryId ? `${userMetrics.summaryId}:vo2_max` : undefined,
905
+ });
906
+ }
907
+
908
+ if (userMetrics.fitnessAge != null) {
909
+ points.push({
910
+ seriesType: "garmin_fitness_age",
911
+ recordedAt,
912
+ value: userMetrics.fitnessAge,
913
+ externalId: userMetrics.summaryId ? `${userMetrics.summaryId}:fitness_age` : undefined,
914
+ });
915
+ }
916
+
917
+ return points;
918
+ }
919
+
920
+ export function normalizeSkinTemperatureDataPoints(
921
+ skinTemp: GarminSkinTemp,
922
+ ): NormalizedDataPoint[] {
923
+ if (!skinTemp.startTimeInSeconds || skinTemp.skinTemperature == null) {
924
+ return [];
925
+ }
926
+
927
+ return [
928
+ {
929
+ seriesType: "skin_temperature",
930
+ recordedAt: skinTemp.startTimeInSeconds * 1000,
931
+ value: skinTemp.skinTemperature,
932
+ externalId: skinTemp.summaryId,
933
+ },
934
+ ];
935
+ }
936
+
937
+ export function normalizeHealthSnapshotDataPoints(
938
+ snapshot: GarminHealthSnapshot,
939
+ ): NormalizedDataPoint[] {
940
+ if (!snapshot.startTimeInSeconds) {
941
+ return [];
942
+ }
943
+
944
+ const recordedAt = snapshot.startTimeInSeconds * 1000;
945
+ const points: NormalizedDataPoint[] = [];
946
+
947
+ if (snapshot.heartRate != null) {
948
+ points.push({
949
+ seriesType: "heart_rate",
950
+ recordedAt,
951
+ value: snapshot.heartRate,
952
+ externalId: snapshot.summaryId ? `${snapshot.summaryId}:heart_rate` : undefined,
953
+ });
954
+ }
955
+
956
+ if (snapshot.hrv != null) {
957
+ points.push({
958
+ seriesType: "heart_rate_variability_sdnn",
959
+ recordedAt,
960
+ value: snapshot.hrv,
961
+ externalId: snapshot.summaryId ? `${snapshot.summaryId}:hrv` : undefined,
962
+ });
963
+ }
964
+
965
+ if (snapshot.stress != null) {
966
+ points.push({
967
+ seriesType: "garmin_stress_level",
968
+ recordedAt,
969
+ value: snapshot.stress,
970
+ externalId: snapshot.summaryId ? `${snapshot.summaryId}:stress` : undefined,
971
+ });
972
+ }
973
+
974
+ if (snapshot.spo2 != null) {
975
+ points.push({
976
+ seriesType: "oxygen_saturation",
977
+ recordedAt,
978
+ value: snapshot.spo2,
979
+ externalId: snapshot.summaryId ? `${snapshot.summaryId}:spo2` : undefined,
980
+ });
981
+ }
982
+
983
+ if (snapshot.respiration != null) {
984
+ points.push({
985
+ seriesType: "respiratory_rate",
986
+ recordedAt,
987
+ value: snapshot.respiration,
988
+ externalId: snapshot.summaryId ? `${snapshot.summaryId}:respiration` : undefined,
989
+ });
990
+ }
991
+
992
+ return points;
993
+ }
994
+
995
+ export function normalizeMoveIQ(moveIQ: GarminMoveIQ): NormalizedEvent {
996
+ const startMs = moveIQ.startTimeInSeconds * 1000;
997
+ const durationSeconds = moveIQ.durationInSeconds ?? 0;
998
+ const endMs = startMs + durationSeconds * 1000;
999
+ const type = moveIQ.activityType ? `moveiq_${moveIQ.activityType.toLowerCase()}` : "moveiq";
1000
+
1001
+ return {
1002
+ category: "workout",
1003
+ type,
1004
+ sourceName: "Garmin",
1005
+ durationSeconds,
1006
+ startDatetime: startMs,
1007
+ endDatetime: endMs,
1008
+ externalId: moveIQ.summaryId
1009
+ ? `garmin-moveiq-${moveIQ.summaryId}`
1010
+ : `garmin-moveiq-${moveIQ.startTimeInSeconds}-${type}`,
1011
+ };
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // Normalization — Menstrual Cycle Tracking (Women's Health)
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ export interface NormalizedMCT {
1019
+ externalId: string;
1020
+ periodStartDate: string;
1021
+ dayInCycle?: number;
1022
+ cycleLength?: number;
1023
+ predictedCycleLength?: number;
1024
+ periodLength?: number;
1025
+ currentPhase?: number;
1026
+ currentPhaseType?: string;
1027
+ lengthOfCurrentPhase?: number;
1028
+ daysUntilNextPhase?: number;
1029
+ isPredictedCycle?: boolean;
1030
+ fertileWindowStart?: number;
1031
+ lengthOfFertileWindow?: number;
1032
+ lastUpdatedAt?: number;
1033
+ isPregnant?: boolean;
1034
+ pregnancyDueDate?: string;
1035
+ pregnancyOriginalDueDate?: string;
1036
+ pregnancyCycleStartDate?: string;
1037
+ pregnancyTitle?: string;
1038
+ numberOfBabies?: string;
1039
+ }
1040
+
1041
+ export function normalizeMCT(mct: GarminMCTSummary): NormalizedMCT {
1042
+ const periodStartDate =
1043
+ mct.periodStartDateStr ??
1044
+ (mct.startTimeInSeconds
1045
+ ? new Date(mct.startTimeInSeconds * 1000).toISOString().split("T")[0]
1046
+ : new Date().toISOString().split("T")[0]);
1047
+
1048
+ return {
1049
+ externalId: `garmin-mct-${mct.summaryId}`,
1050
+ periodStartDate,
1051
+ dayInCycle: mct.dayInCycle,
1052
+ cycleLength: mct.cycleLength,
1053
+ predictedCycleLength: mct.predictedCycleLength,
1054
+ periodLength: mct.periodLength,
1055
+ currentPhase: mct.currentPhase,
1056
+ currentPhaseType: mct.currentPhaseType,
1057
+ lengthOfCurrentPhase: mct.lengthOfCurrentPhase,
1058
+ daysUntilNextPhase: mct.daysUntilNextPhase,
1059
+ isPredictedCycle: mct.isPredictedCycle,
1060
+ fertileWindowStart: mct.fertileWindowStart,
1061
+ lengthOfFertileWindow: mct.lengthOfFertileWindow,
1062
+ lastUpdatedAt: mct.lastUpdatedAt ? mct.lastUpdatedAt * 1000 : undefined,
1063
+ isPregnant: mct.isPregnant,
1064
+ pregnancyDueDate: mct.pregnancyDueDate,
1065
+ pregnancyOriginalDueDate: mct.pregnancyOriginalDueDate,
1066
+ pregnancyCycleStartDate: mct.pregnancyCycleStartDate,
1067
+ pregnancyTitle: mct.pregnancyTitle,
1068
+ numberOfBabies: mct.numberOfBabies,
1069
+ };
1070
+ }
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // User info
1074
+ // ---------------------------------------------------------------------------
1075
+
1076
+ export async function getGarminUserInfo(
1077
+ accessToken: string,
1078
+ _tokenResponse?: unknown,
1079
+ _appUserId?: string,
1080
+ _credentials?: ProviderCredentials,
1081
+ ): Promise<ProviderUserInfo> {
1082
+ try {
1083
+ const data = await makeAuthenticatedRequest<{ userId: string }>(
1084
+ API_BASE,
1085
+ "/wellness-api/rest/user/id",
1086
+ accessToken,
1087
+ );
1088
+ return {
1089
+ providerUserId: data.userId ?? null,
1090
+ username: null,
1091
+ };
1092
+ } catch {
1093
+ return { providerUserId: null, username: null };
1094
+ }
1095
+ }
1096
+
1097
+ export const garminProvider: ProviderAdapter = {
1098
+ name: "garmin",
1099
+ oauthConfig: garminOAuthConfig,
1100
+ getUserInfo: getGarminUserInfo,
1101
+ fetchEvents: fetchGarminWorkouts,
1102
+ };
1103
+
1104
+ // ---------------------------------------------------------------------------
1105
+ // Garmin is push-based — no fetchWorkouts (data comes via webhooks)
1106
+ // ---------------------------------------------------------------------------
1107
+
1108
+ /**
1109
+ * Garmin does not support pull-based data fetching.
1110
+ * Data is pushed via webhooks. This function exists to satisfy the
1111
+ * ProviderDefinition interface but returns an empty array.
1112
+ *
1113
+ * Use the backfill API to trigger historical data push.
1114
+ */
1115
+ export async function fetchGarminWorkouts(
1116
+ _accessToken: string,
1117
+ _startDate: number,
1118
+ _endDate: number,
1119
+ _credentials?: ProviderCredentials,
1120
+ ): Promise<NormalizedEvent[]> {
1121
+ // Garmin is push-only — data comes via webhooks, not pull requests.
1122
+ // To get historical data, call the backfill API which triggers Garmin
1123
+ // to push data to your webhook endpoints.
1124
+ return [];
1125
+ }
1126
+
1127
+ // ---------------------------------------------------------------------------
1128
+ // Backfill trigger
1129
+ // ---------------------------------------------------------------------------
1130
+
1131
+ /**
1132
+ * Trigger a Garmin backfill request. Garmin will asynchronously push
1133
+ * historical data to your webhook endpoints.
1134
+ */
1135
+ export async function triggerBackfill(
1136
+ accessToken: string,
1137
+ dataType: string,
1138
+ startTimeSeconds: number,
1139
+ endTimeSeconds: number,
1140
+ ): Promise<void> {
1141
+ const validTypes = [
1142
+ "activities",
1143
+ "activityDetails",
1144
+ "dailies",
1145
+ "epochs",
1146
+ "sleeps",
1147
+ "bodyComps",
1148
+ "hrv",
1149
+ "stressDetails",
1150
+ "respiration",
1151
+ "pulseOx",
1152
+ "bloodPressures",
1153
+ "userMetrics",
1154
+ "skinTemp",
1155
+ "healthSnapshot",
1156
+ "moveiq",
1157
+ "mct",
1158
+ ];
1159
+ if (!validTypes.includes(dataType)) {
1160
+ throw new Error(`Invalid backfill data type: ${dataType}`);
1161
+ }
1162
+
1163
+ await makeAuthenticatedRequest(API_BASE, `/wellness-api/rest/backfill/${dataType}`, accessToken, {
1164
+ params: {
1165
+ summaryStartTimeInSeconds: String(startTimeSeconds),
1166
+ summaryEndTimeInSeconds: String(endTimeSeconds),
1167
+ },
1168
+ });
1169
+ }