@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,600 @@
1
+ import { makeAuthenticatedRequest } from "./oauth";
2
+ import type {
3
+ NormalizedDailySummary,
4
+ NormalizedDataPoint,
5
+ NormalizedEvent,
6
+ OAuthProviderConfig,
7
+ ProviderAdapter,
8
+ ProviderCredentials,
9
+ ProviderUserInfo,
10
+ } from "./types";
11
+
12
+ const API_BASE = "https://api.prod.whoop.com";
13
+ const WHOOP_PER_PAGE = 25;
14
+ const WHOOP_SCOPE =
15
+ "offline read:workout read:sleep read:recovery read:body_measurement read:profile";
16
+
17
+ interface WhoopWorkoutScore {
18
+ average_heart_rate?: number;
19
+ max_heart_rate?: number;
20
+ kilojoule?: number;
21
+ distance_meter?: number;
22
+ altitude_gain_meter?: number;
23
+ }
24
+
25
+ interface WhoopWorkout {
26
+ id: string;
27
+ start: string;
28
+ end: string;
29
+ sport_name?: string;
30
+ score_state?: string;
31
+ score?: WhoopWorkoutScore;
32
+ }
33
+
34
+ interface WhoopWorkoutCollection {
35
+ records?: WhoopWorkout[];
36
+ next_token?: string;
37
+ nextToken?: string;
38
+ }
39
+
40
+ interface WhoopSleepStageSummary {
41
+ total_in_bed_time_milli?: number;
42
+ total_awake_time_milli?: number;
43
+ total_light_sleep_time_milli?: number;
44
+ total_slow_wave_sleep_time_milli?: number;
45
+ total_rem_sleep_time_milli?: number;
46
+ }
47
+
48
+ interface WhoopSleep {
49
+ id: string;
50
+ start: string;
51
+ end: string;
52
+ nap?: boolean;
53
+ score_state?: string;
54
+ score?: {
55
+ sleep_efficiency_percentage?: number;
56
+ stage_summary?: WhoopSleepStageSummary;
57
+ };
58
+ }
59
+
60
+ interface WhoopSleepCollection {
61
+ records?: WhoopSleep[];
62
+ next_token?: string;
63
+ nextToken?: string;
64
+ }
65
+
66
+ interface WhoopRecoveryRecord {
67
+ id?: string;
68
+ created_at?: string;
69
+ score_state?: string;
70
+ score?: {
71
+ recovery_score?: number;
72
+ resting_heart_rate?: number;
73
+ hrv_rmssd_milli?: number;
74
+ spo2_percentage?: number;
75
+ skin_temp_celsius?: number;
76
+ };
77
+ }
78
+
79
+ interface WhoopRecoveryCollection {
80
+ records?: WhoopRecoveryRecord[];
81
+ next_token?: string;
82
+ nextToken?: string;
83
+ }
84
+
85
+ interface WhoopBodyMeasurement {
86
+ height_meter?: number;
87
+ weight_kilogram?: number;
88
+ }
89
+
90
+ // Workout type mappings translated from the upstream Python list.
91
+ const WHOOP_TYPE_MAP: Record<string, string> = {
92
+ running: "running",
93
+ walking: "walking",
94
+ "hiking/rucking": "hiking",
95
+ "track & field": "running",
96
+ "stroller walking": "walking",
97
+ "stroller jogging": "running",
98
+ "dog walking": "walking",
99
+ caddying: "walking",
100
+ toddlerwearing: "walking",
101
+ babywearing: "walking",
102
+ cycling: "cycling",
103
+ "mountain biking": "mountain_biking",
104
+ spin: "indoor_cycling",
105
+ "assault bike": "indoor_cycling",
106
+ swimming: "swimming",
107
+ "water polo": "water_polo",
108
+ rowing: "rowing",
109
+ kayaking: "kayaking",
110
+ paddleboarding: "stand_up_paddleboarding",
111
+ surfing: "surfing",
112
+ sailing: "sailing",
113
+ diving: "diving",
114
+ "water skiing": "surfing",
115
+ wakeboarding: "surfing",
116
+ "kite boarding": "kitesurfing",
117
+ "operations - water": "other",
118
+ weightlifting: "strength_training",
119
+ powerlifting: "strength_training",
120
+ "strength trainer": "strength_training",
121
+ "functional fitness": "cardio_training",
122
+ elliptical: "elliptical",
123
+ stairmaster: "stair_climbing",
124
+ climber: "stair_climbing",
125
+ "stadium steps": "stair_climbing",
126
+ hiit: "cardio_training",
127
+ "jumping rope": "cardio_training",
128
+ "obstacle course racing": "cardio_training",
129
+ parkour: "cardio_training",
130
+ yoga: "yoga",
131
+ "hot yoga": "yoga",
132
+ pilates: "pilates",
133
+ stretching: "stretching",
134
+ meditation: "meditation",
135
+ barre: "group_exercise",
136
+ barre3: "group_exercise",
137
+ skiing: "alpine_skiing",
138
+ "cross country skiing": "cross_country_skiing",
139
+ snowboarding: "snowboarding",
140
+ "ice skating": "ice_skating",
141
+ soccer: "soccer",
142
+ basketball: "basketball",
143
+ football: "american_football",
144
+ "australian football": "football",
145
+ "gaelic football": "football",
146
+ baseball: "baseball",
147
+ softball: "baseball",
148
+ volleyball: "volleyball",
149
+ rugby: "rugby",
150
+ lacrosse: "lacrosse",
151
+ cricket: "cricket",
152
+ netball: "sport",
153
+ ultimate: "sport",
154
+ spikeball: "sport",
155
+ "hurling/camogie": "sport",
156
+ "ice hockey": "hockey",
157
+ "field hockey": "hockey",
158
+ tennis: "tennis",
159
+ squash: "squash",
160
+ badminton: "badminton",
161
+ "table tennis": "table_tennis",
162
+ padel: "padel",
163
+ pickleball: "pickleball",
164
+ "paddle tennis": "padel",
165
+ boxing: "boxing",
166
+ kickboxing: "boxing",
167
+ "box fitness": "boxing",
168
+ "martial arts": "martial_arts",
169
+ "jiu jitsu": "martial_arts",
170
+ wrestling: "wrestling",
171
+ fencing: "martial_arts",
172
+ "rock climbing": "rock_climbing",
173
+ golf: "golf",
174
+ "disc golf": "golf",
175
+ "inline skating": "inline_skating",
176
+ skateboarding: "skateboarding",
177
+ "horseback riding": "horseback_riding",
178
+ polo: "horseback_riding",
179
+ triathlon: "triathlon",
180
+ duathlon: "multisport",
181
+ motocross: "motorcycling",
182
+ "motor racing": "motor_sports",
183
+ dance: "dance",
184
+ "circus arts": "dance",
185
+ "stage performance": "dance",
186
+ "f45 training": "group_exercise",
187
+ "barry's": "group_exercise",
188
+ gymnastics: "gymnastics",
189
+ handball: "handball",
190
+ "ice bath": "other",
191
+ sauna: "other",
192
+ "massage therapy": "other",
193
+ "air compression": "other",
194
+ "percussive massage": "other",
195
+ "operations - tactical": "other",
196
+ "operations - medical": "other",
197
+ "operations - flying": "other",
198
+ "manual labor": "other",
199
+ "high stress work": "other",
200
+ coaching: "other",
201
+ "watching sports": "other",
202
+ commuting: "other",
203
+ gaming: "other",
204
+ "yard work": "other",
205
+ cooking: "other",
206
+ cleaning: "other",
207
+ "public speaking": "other",
208
+ "musical performance": "other",
209
+ "dedicated parenting": "other",
210
+ "wheelchair pushing": "walking",
211
+ paintball: "sport",
212
+ other: "other",
213
+ };
214
+
215
+ function toDateTime(ms: number): string {
216
+ return new Date(ms).toISOString();
217
+ }
218
+
219
+ function parseTimestamp(value?: string): number | undefined {
220
+ if (!value) return undefined;
221
+ const parsed = Date.parse(value);
222
+ return Number.isNaN(parsed) ? undefined : parsed;
223
+ }
224
+
225
+ function buildUrlParams(params: Record<string, string | undefined>): Record<string, string> {
226
+ const safe: Record<string, string> = {};
227
+ Object.entries(params).forEach(([key, value]) => {
228
+ if (value !== undefined) safe[key] = value;
229
+ });
230
+ return safe;
231
+ }
232
+
233
+ function normalizeWorkout(workout: WhoopWorkout): NormalizedEvent {
234
+ const start = parseTimestamp(workout.start) ?? 0;
235
+ const end = parseTimestamp(workout.end) ?? start;
236
+ const score = workout.score;
237
+ const durationSeconds = Math.max(Math.floor((end - start) / 1000), 0);
238
+
239
+ const energy = score?.kilojoule !== undefined ? score.kilojoule * 0.239 : undefined;
240
+
241
+ const type = workout.sport_name
242
+ ? (WHOOP_TYPE_MAP[workout.sport_name.toLowerCase()] ?? "other")
243
+ : "other";
244
+
245
+ return {
246
+ category: "workout",
247
+ type,
248
+ sourceName: "Whoop",
249
+ source: "whoop",
250
+ durationSeconds,
251
+ startDatetime: start,
252
+ endDatetime: end || start + durationSeconds * 1000,
253
+ externalId: `whoop-workout-${workout.id}`,
254
+ heartRateAvg: score?.average_heart_rate,
255
+ heartRateMax: score?.max_heart_rate,
256
+ energyBurned: energy,
257
+ distance: score?.distance_meter,
258
+ totalElevationGain: score?.altitude_gain_meter,
259
+ movingTimeSeconds: durationSeconds,
260
+ averageSpeed: undefined,
261
+ };
262
+ }
263
+
264
+ function normalizeSleep(record: WhoopSleep): NormalizedEvent {
265
+ const start = parseTimestamp(record.start) ?? 0;
266
+ const end = parseTimestamp(record.end) ?? start;
267
+ const stage = record.score?.stage_summary;
268
+ const timeInBedMinutes = Math.round((stage?.total_in_bed_time_milli ?? end - start) / 60000);
269
+ const awakeMinutes = Math.round((stage?.total_awake_time_milli ?? 0) / 60000);
270
+ const deepMinutes = Math.round((stage?.total_slow_wave_sleep_time_milli ?? 0) / 60000);
271
+ const remMinutes = Math.round((stage?.total_rem_sleep_time_milli ?? 0) / 60000);
272
+ const lightMinutes = Math.round((stage?.total_light_sleep_time_milli ?? 0) / 60000);
273
+ const totalSleepMinutes =
274
+ deepMinutes + remMinutes + lightMinutes || Math.max(timeInBedMinutes - awakeMinutes, 0);
275
+
276
+ return {
277
+ category: "sleep",
278
+ type: "sleep_session",
279
+ sourceName: "Whoop",
280
+ source: "whoop",
281
+ durationSeconds: Math.max(Math.floor((end - start) / 1000), 0),
282
+ startDatetime: start,
283
+ endDatetime: end,
284
+ externalId: `whoop-sleep-${record.id}`,
285
+ sleepTotalDurationMinutes: totalSleepMinutes,
286
+ sleepTimeInBedMinutes: timeInBedMinutes,
287
+ sleepEfficiencyScore: record.score?.sleep_efficiency_percentage,
288
+ sleepDeepMinutes: deepMinutes || undefined,
289
+ sleepRemMinutes: remMinutes || undefined,
290
+ sleepLightMinutes: lightMinutes || undefined,
291
+ sleepAwakeMinutes: awakeMinutes || undefined,
292
+ isNap: record.nap,
293
+ };
294
+ }
295
+
296
+ function normalizeRecovery(record: WhoopRecoveryRecord): NormalizedDataPoint[] {
297
+ if (record.score_state && record.score_state !== "SCORED") {
298
+ return [];
299
+ }
300
+
301
+ const createdAt = parseTimestamp(record.created_at) ?? Date.now();
302
+ const score = record.score;
303
+ if (!score) return [];
304
+
305
+ const points: NormalizedDataPoint[] = [];
306
+
307
+ if (score.recovery_score !== undefined) {
308
+ points.push({
309
+ seriesType: "recovery_score",
310
+ recordedAt: createdAt,
311
+ value: score.recovery_score,
312
+ });
313
+ }
314
+
315
+ if (score.resting_heart_rate !== undefined) {
316
+ points.push({
317
+ seriesType: "resting_heart_rate",
318
+ recordedAt: createdAt,
319
+ value: score.resting_heart_rate,
320
+ });
321
+ }
322
+
323
+ if (score.hrv_rmssd_milli !== undefined) {
324
+ points.push({
325
+ seriesType: "heart_rate_variability_rmssd",
326
+ recordedAt: createdAt,
327
+ value: score.hrv_rmssd_milli,
328
+ });
329
+ }
330
+
331
+ if (score.spo2_percentage !== undefined) {
332
+ points.push({
333
+ seriesType: "oxygen_saturation",
334
+ recordedAt: createdAt,
335
+ value: score.spo2_percentage,
336
+ });
337
+ }
338
+
339
+ if (score.skin_temp_celsius !== undefined) {
340
+ points.push({
341
+ seriesType: "skin_temperature",
342
+ recordedAt: createdAt,
343
+ value: score.skin_temp_celsius,
344
+ });
345
+ }
346
+
347
+ return points;
348
+ }
349
+
350
+ function normalizeBodyMeasurement(body: WhoopBodyMeasurement): NormalizedDataPoint[] {
351
+ const now = Date.now();
352
+ const points: NormalizedDataPoint[] = [];
353
+
354
+ if (body.height_meter !== undefined) {
355
+ points.push({
356
+ seriesType: "height",
357
+ recordedAt: now,
358
+ value: body.height_meter * 100,
359
+ });
360
+ }
361
+
362
+ if (body.weight_kilogram !== undefined) {
363
+ points.push({
364
+ seriesType: "weight",
365
+ recordedAt: now,
366
+ value: body.weight_kilogram,
367
+ });
368
+ }
369
+
370
+ return points;
371
+ }
372
+
373
+ async function fetchPaged<T extends { records?: unknown; next_token?: string; nextToken?: string }>(
374
+ accessToken: string,
375
+ endpoint: string,
376
+ params: Record<string, string>,
377
+ ): Promise<T[]> {
378
+ const all: T[] = [];
379
+ let nextToken: string | undefined;
380
+
381
+ while (true) {
382
+ const response = await makeAuthenticatedRequest<T>(API_BASE, endpoint, accessToken, {
383
+ params: buildUrlParams({
384
+ ...params,
385
+ limit: String(WHOOP_PER_PAGE),
386
+ nextToken,
387
+ }),
388
+ });
389
+
390
+ all.push(response);
391
+ nextToken = response.next_token ?? response.nextToken;
392
+ if (!nextToken) break;
393
+ }
394
+
395
+ return all;
396
+ }
397
+
398
+ async function fetchWhoopWorkouts(
399
+ accessToken: string,
400
+ startDate: number,
401
+ endDate: number,
402
+ ): Promise<NormalizedEvent[]> {
403
+ const records: NormalizedEvent[] = [];
404
+ const startIso = toDateTime(startDate);
405
+ const endIso = toDateTime(endDate);
406
+
407
+ const responses = await fetchPaged<WhoopWorkoutCollection>(accessToken, "/v2/activity/workout", {
408
+ start: startIso,
409
+ end: endIso,
410
+ });
411
+
412
+ for (const res of responses) {
413
+ for (const workout of res.records ?? []) {
414
+ if (workout.score_state === "SCORED" || !workout.score_state) {
415
+ records.push(normalizeWorkout(workout));
416
+ }
417
+ }
418
+ }
419
+
420
+ return records;
421
+ }
422
+
423
+ async function fetchWhoopSleep(
424
+ accessToken: string,
425
+ startDate: number,
426
+ endDate: number,
427
+ ): Promise<NormalizedEvent[]> {
428
+ const records: NormalizedEvent[] = [];
429
+ const startIso = toDateTime(startDate);
430
+ const endIso = toDateTime(endDate);
431
+
432
+ const responses = await fetchPaged<WhoopSleepCollection>(accessToken, "/v2/activity/sleep", {
433
+ start: startIso,
434
+ end: endIso,
435
+ });
436
+
437
+ for (const res of responses) {
438
+ for (const sleep of res.records ?? []) {
439
+ if (sleep.score_state === "SCORED" || !sleep.score_state) {
440
+ records.push(normalizeSleep(sleep));
441
+ }
442
+ }
443
+ }
444
+
445
+ return records;
446
+ }
447
+
448
+ async function fetchWhoopRecovery(
449
+ accessToken: string,
450
+ startDate: number,
451
+ endDate: number,
452
+ ): Promise<NormalizedDataPoint[]> {
453
+ const points: NormalizedDataPoint[] = [];
454
+ const startIso = toDateTime(startDate);
455
+ const endIso = toDateTime(endDate);
456
+
457
+ const responses = await fetchPaged<WhoopRecoveryCollection>(accessToken, "/v2/recovery", {
458
+ start: startIso,
459
+ end: endIso,
460
+ });
461
+
462
+ for (const res of responses) {
463
+ for (const record of res.records ?? []) {
464
+ points.push(...normalizeRecovery(record));
465
+ }
466
+ }
467
+
468
+ return points;
469
+ }
470
+
471
+ async function fetchWhoopBodyMeasurement(accessToken: string): Promise<NormalizedDataPoint[]> {
472
+ try {
473
+ const measurement = await makeAuthenticatedRequest<WhoopBodyMeasurement>(
474
+ API_BASE,
475
+ "/v2/user/measurement/body",
476
+ accessToken,
477
+ );
478
+ return normalizeBodyMeasurement(measurement);
479
+ } catch {
480
+ return [];
481
+ }
482
+ }
483
+
484
+ function aggregateDailySummaries(
485
+ events: NormalizedEvent[],
486
+ dataPoints: NormalizedDataPoint[],
487
+ ): NormalizedDailySummary[] {
488
+ const sleepByDate = new Map<string, NormalizedDailySummary>();
489
+ const recoveryByDate = new Map<string, { counts: Record<string, number[]> }>();
490
+ const bodyByDate = new Map<string, NormalizedDailySummary>();
491
+
492
+ const toDate = (ms: number) => new Date(ms).toISOString().slice(0, 10);
493
+
494
+ for (const event of events) {
495
+ if (event.category !== "sleep" || !event.startDatetime) continue;
496
+ const date = toDate(event.startDatetime);
497
+ const summary = sleepByDate.get(date) ?? {
498
+ date,
499
+ category: "sleep",
500
+ };
501
+
502
+ summary.sleepDurationMinutes = Math.max(
503
+ summary.sleepDurationMinutes ?? 0,
504
+ event.sleepTotalDurationMinutes ?? 0,
505
+ );
506
+ summary.sleepEfficiency = event.sleepEfficiencyScore ?? summary.sleepEfficiency;
507
+ summary.deepSleepMinutes = event.sleepDeepMinutes ?? summary.deepSleepMinutes;
508
+ summary.remSleepMinutes = event.sleepRemMinutes ?? summary.remSleepMinutes;
509
+ summary.lightSleepMinutes = event.sleepLightMinutes ?? summary.lightSleepMinutes;
510
+ summary.awakeDuringMinutes = event.sleepAwakeMinutes ?? summary.awakeDuringMinutes;
511
+ summary.timeInBedMinutes = event.sleepTimeInBedMinutes ?? summary.timeInBedMinutes;
512
+
513
+ sleepByDate.set(date, summary);
514
+ }
515
+
516
+ for (const point of dataPoints) {
517
+ const date = toDate(point.recordedAt);
518
+ const data = recoveryByDate.get(date) ?? { counts: {} };
519
+ const bucket = data.counts[point.seriesType] ?? [];
520
+ bucket.push(point.value);
521
+ data.counts[point.seriesType] = bucket;
522
+ recoveryByDate.set(date, data);
523
+
524
+ if (point.seriesType === "weight") {
525
+ const existing = bodyByDate.get(date) ?? {
526
+ date,
527
+ category: "body",
528
+ };
529
+ existing.weight = point.value;
530
+ bodyByDate.set(date, existing);
531
+ }
532
+ }
533
+
534
+ const recoverySummaries: NormalizedDailySummary[] = [];
535
+ for (const [date, data] of recoveryByDate.entries()) {
536
+ const summary: NormalizedDailySummary = { date, category: "recovery" };
537
+ const average = (list: number[]) =>
538
+ list.length ? list.reduce((a, b) => a + b, 0) / list.length : undefined;
539
+
540
+ summary.recoveryScore = average(data.counts.recovery_score ?? []);
541
+ summary.restingHeartRate = average(data.counts.resting_heart_rate ?? []);
542
+ summary.hrvRmssd = average(data.counts.heart_rate_variability_rmssd ?? []);
543
+ summary.spo2Avg = average(data.counts.oxygen_saturation ?? []);
544
+ recoverySummaries.push(summary);
545
+ }
546
+
547
+ return [...sleepByDate.values(), ...recoverySummaries, ...bodyByDate.values()];
548
+ }
549
+
550
+ export function whoopOAuthConfig(credentials: ProviderCredentials): OAuthProviderConfig {
551
+ return {
552
+ endpoints: {
553
+ authorizeUrl: "https://api.prod.whoop.com/oauth/oauth2/auth",
554
+ tokenUrl: "https://api.prod.whoop.com/oauth/oauth2/token",
555
+ apiBaseUrl: API_BASE,
556
+ },
557
+ clientId: credentials.clientId,
558
+ clientSecret: credentials.clientSecret,
559
+ defaultScope: WHOOP_SCOPE,
560
+ usePkce: false,
561
+ authMethod: "body",
562
+ };
563
+ }
564
+
565
+ async function fetchWhoopUserInfo(accessToken: string): Promise<ProviderUserInfo> {
566
+ try {
567
+ const profile = await makeAuthenticatedRequest<{ user_id?: string | number; email?: string }>(
568
+ API_BASE,
569
+ "/v2/user/profile/basic",
570
+ accessToken,
571
+ );
572
+ return {
573
+ providerUserId: profile.user_id !== undefined ? String(profile.user_id) : null,
574
+ username: profile.email ?? null,
575
+ };
576
+ } catch {
577
+ return { providerUserId: null, username: null };
578
+ }
579
+ }
580
+
581
+ export const whoopProvider: ProviderAdapter = {
582
+ name: "whoop",
583
+ oauthConfig: whoopOAuthConfig,
584
+ getUserInfo: async (accessToken) => fetchWhoopUserInfo(accessToken),
585
+ fetchEvents: async (accessToken, startDate, endDate) => {
586
+ const workouts = await fetchWhoopWorkouts(accessToken, startDate, endDate);
587
+ const sleeps = await fetchWhoopSleep(accessToken, startDate, endDate);
588
+ return [...workouts, ...sleeps];
589
+ },
590
+ fetchDataPoints: async (accessToken, startDate, endDate) => {
591
+ const recovery = await fetchWhoopRecovery(accessToken, startDate, endDate);
592
+ const body = await fetchWhoopBodyMeasurement(accessToken);
593
+ return [...recovery, ...body];
594
+ },
595
+ fetchDailySummaries: async (accessToken, startDate, endDate) => {
596
+ const events = (await whoopProvider.fetchEvents?.(accessToken, startDate, endDate)) ?? [];
597
+ const points = (await whoopProvider.fetchDataPoints?.(accessToken, startDate, endDate)) ?? [];
598
+ return aggregateDailySummaries(events, points);
599
+ },
600
+ };