@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,592 @@
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://cloudapi.suunto.com";
13
+ const WORKOUTS_ENDPOINT = "/v3/workouts/";
14
+ const SLEEP_ENDPOINT = "/247samples/sleep";
15
+ const RECOVERY_ENDPOINT = "/247samples/recovery";
16
+ const ACTIVITY_ENDPOINT = "/247samples/activity";
17
+ const DAILY_STATS_ENDPOINT = "/247/daily-activity-statistics";
18
+ const SLEEP_CHUNK_MS = 20 * 24 * 60 * 60 * 1000;
19
+ const DAILY_STATS_CHUNK_MS = 14 * 24 * 60 * 60 * 1000;
20
+ const SUBSCRIPTION_HEADER = "Ocp-Apim-Subscription-Key";
21
+ const ENERGY_CONVERSION = 4184; // joules → kcal
22
+
23
+ const SUUNTO_ACTIVITY_TYPE_MAP: Record<number, string> = {
24
+ 0: "walking",
25
+ 1: "running",
26
+ 2: "cycling",
27
+ 3: "cross_country_skiing",
28
+ 4: "other",
29
+ 5: "other",
30
+ 6: "other",
31
+ 7: "other",
32
+ 8: "other",
33
+ 9: "other",
34
+ 10: "mountain_biking",
35
+ 11: "hiking",
36
+ 12: "inline_skating",
37
+ 13: "alpine_skiing",
38
+ 14: "paddling",
39
+ 15: "rowing",
40
+ 16: "golf",
41
+ 17: "fitness_equipment",
42
+ 18: "other",
43
+ 19: "other",
44
+ 20: "fitness_equipment",
45
+ 21: "swimming",
46
+ 22: "trail_running",
47
+ 23: "strength_training",
48
+ 24: "walking",
49
+ 25: "horseback_riding",
50
+ 26: "motorcycling",
51
+ 27: "skateboarding",
52
+ 28: "other",
53
+ 29: "rock_climbing",
54
+ 30: "snowboarding",
55
+ 31: "backcountry_skiing",
56
+ 32: "aerobics",
57
+ 33: "soccer",
58
+ 34: "tennis",
59
+ 35: "basketball",
60
+ 36: "badminton",
61
+ 37: "baseball",
62
+ 38: "volleyball",
63
+ 39: "american_football",
64
+ 40: "table_tennis",
65
+ 41: "other",
66
+ 42: "squash",
67
+ 43: "floorball",
68
+ 44: "handball",
69
+ 45: "baseball",
70
+ 46: "other",
71
+ 47: "other",
72
+ 48: "rugby",
73
+ 49: "ice_skating",
74
+ 50: "hockey",
75
+ 51: "yoga",
76
+ 52: "indoor_cycling",
77
+ 53: "treadmill",
78
+ 54: "strength_training",
79
+ 55: "elliptical",
80
+ 56: "cross_country_skiing",
81
+ 57: "rowing_machine",
82
+ 58: "stretching",
83
+ 59: "running",
84
+ 60: "orienteering",
85
+ 61: "stand_up_paddleboarding",
86
+ 62: "martial_arts",
87
+ 63: "strength_training",
88
+ 64: "dance",
89
+ 65: "snowshoeing",
90
+ 66: "other",
91
+ 67: "soccer",
92
+ 68: "multisport",
93
+ 69: "aerobics",
94
+ 70: "hiking",
95
+ 71: "sailing",
96
+ 72: "kayaking",
97
+ 73: "strength_training",
98
+ 74: "triathlon",
99
+ 75: "padel",
100
+ 76: "aerobics",
101
+ 77: "boxing",
102
+ 78: "diving",
103
+ 79: "diving",
104
+ 80: "multisport",
105
+ 81: "fitness_equipment",
106
+ 82: "canoeing",
107
+ 83: "mountaineering",
108
+ 84: "alpine_skiing",
109
+ 85: "open_water_swimming",
110
+ 86: "windsurfing",
111
+ 87: "kitesurfing",
112
+ 88: "other",
113
+ 90: "diving",
114
+ 91: "surfing",
115
+ 92: "multisport",
116
+ 93: "multisport",
117
+ 94: "multisport",
118
+ 95: "running",
119
+ 96: "other",
120
+ 97: "other",
121
+ 98: "transition",
122
+ 99: "cycling",
123
+ 100: "swimming",
124
+ 101: "diving",
125
+ 102: "cardio_training",
126
+ 103: "running",
127
+ 104: "strength_training",
128
+ 105: "e_biking",
129
+ 106: "e_biking",
130
+ 107: "backcountry_skiing",
131
+ 108: "other",
132
+ 109: "cycling",
133
+ 110: "snowboarding",
134
+ 111: "multisport",
135
+ 112: "stretching",
136
+ 113: "hockey",
137
+ 114: "cyclocross",
138
+ 115: "trail_running",
139
+ 116: "mountaineering",
140
+ 117: "cross_country_skiing",
141
+ 118: "cross_country_skiing",
142
+ 119: "other",
143
+ 120: "pilates",
144
+ 121: "yoga",
145
+ };
146
+
147
+ function toDateString(value: number | string): string | undefined {
148
+ const timestamp = typeof value === "number" ? value : Date.parse(value);
149
+ if (Number.isNaN(timestamp)) return undefined;
150
+ return new Date(timestamp).toISOString().split("T")[0];
151
+ }
152
+
153
+ function parseTimestamp(value: number | string | undefined | null): number | undefined {
154
+ if (typeof value === "number") return value;
155
+ if (!value) return undefined;
156
+ const parsed = Date.parse(value);
157
+ return Number.isNaN(parsed) ? undefined : parsed;
158
+ }
159
+
160
+ function buildHeaders(subscriptionKey?: string): Record<string, string> | undefined {
161
+ return subscriptionKey ? { [SUBSCRIPTION_HEADER]: subscriptionKey } : undefined;
162
+ }
163
+
164
+ function decodeJwt(token: string): Record<string, unknown> | null {
165
+ const parts = token.split(".");
166
+ if (parts.length < 2) return null;
167
+ const payload = parts[1];
168
+ const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, "=");
169
+ try {
170
+ const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
171
+ return JSON.parse(Buffer.from(normalized, "base64").toString("utf-8"));
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function mapActivityType(activityId?: number): string {
178
+ if (activityId == null) return "other";
179
+ return SUUNTO_ACTIVITY_TYPE_MAP[activityId] ?? "other";
180
+ }
181
+
182
+ async function paginatedWorkouts(
183
+ accessToken: string,
184
+ startDate: number,
185
+ endDate: number,
186
+ credentials?: ProviderCredentials,
187
+ ): Promise<any[]> {
188
+ const headers = buildHeaders(credentials?.subscriptionKey);
189
+ const perPage = 100;
190
+ const items: any[] = [];
191
+ let offset = 0;
192
+ while (true) {
193
+ const response = await makeAuthenticatedRequest<any>(API_BASE, WORKOUTS_ENDPOINT, accessToken, {
194
+ params: {
195
+ since: Math.floor(startDate / 1000),
196
+ limit: perPage,
197
+ offset,
198
+ },
199
+ headers,
200
+ });
201
+
202
+ const payload = Array.isArray(response?.payload) ? response.payload : [];
203
+ if (!payload.length) break;
204
+ items.push(
205
+ ...payload.filter((workout: any) => {
206
+ const start = parseTimestamp(workout?.startTime);
207
+ return start !== undefined && start >= startDate && start <= endDate;
208
+ }),
209
+ );
210
+ if (payload.length < perPage) break;
211
+ offset += payload.length;
212
+ }
213
+ return items;
214
+ }
215
+
216
+ function normalizeWorkout(raw: any): NormalizedEvent {
217
+ const start = typeof raw.startTime === "number" ? raw.startTime : undefined;
218
+ const end = typeof raw.stopTime === "number" ? raw.stopTime : undefined;
219
+ const duration = typeof raw.totalTime === "number" ? raw.totalTime : undefined;
220
+ const hrData = raw.hrdata ?? {};
221
+
222
+ return {
223
+ category: "workout",
224
+ type: mapActivityType(raw.activityId),
225
+ sourceName: raw.gear?.displayName ?? raw.gear?.name ?? "Suunto",
226
+ deviceModel: raw.gear?.displayName ?? raw.gear?.name,
227
+ softwareVersion: raw.gear?.swVersion,
228
+ source: "suunto",
229
+ originalSourceName: raw.gear?.name,
230
+ durationSeconds: duration,
231
+ startDatetime: start ?? Date.now(),
232
+ endDatetime: end,
233
+ externalId: raw.workoutId != null ? `suunto-workout-${raw.workoutId}` : undefined,
234
+ heartRateMin: hrData?.min,
235
+ heartRateMax: hrData?.hrmax ?? hrData?.max,
236
+ heartRateAvg: hrData?.avg ?? undefined,
237
+ energyBurned: raw.energyConsumption,
238
+ distance: raw.totalDistance,
239
+ stepsCount: raw.stepCount,
240
+ maxSpeed: typeof raw.maxSpeed === "number" ? raw.maxSpeed * 3.6 : undefined,
241
+ averageSpeed: typeof raw.avgSpeed === "number" ? raw.avgSpeed * 3.6 : undefined,
242
+ maxWatts: raw.maxPower,
243
+ averageWatts: raw.avgPower,
244
+ totalElevationGain: raw.totalAscent,
245
+ elevHigh: raw.maxAltitude,
246
+ elevLow: raw.minAltitude,
247
+ movingTimeSeconds: duration,
248
+ };
249
+ }
250
+
251
+ function normalizeSleep(raw: any): NormalizedEvent | null {
252
+ const entry = raw.entryData ?? {};
253
+ const startTime = parseTimestamp(entry.BedtimeStart);
254
+ const endTime = parseTimestamp(entry.BedtimeEnd);
255
+ const durationSeconds = Number(entry.Duration ?? 0);
256
+ if (!startTime || !endTime) return null;
257
+
258
+ const deepSeconds = Number(entry.DeepSleepDuration ?? 0);
259
+ const lightSeconds = Number(entry.LightSleepDuration ?? 0);
260
+ const remSeconds = Number(entry.REMSleepDuration ?? 0);
261
+ const awakeSeconds = Math.max(0, durationSeconds - deepSeconds - lightSeconds - remSeconds);
262
+ const totalSleepMinutes = Math.floor((deepSeconds + lightSeconds + remSeconds) / 60);
263
+ const timeInBedMinutes = Math.floor(durationSeconds / 60);
264
+
265
+ return {
266
+ category: "sleep",
267
+ type: "sleep_session",
268
+ sourceName: "Suunto",
269
+ source: "suunto",
270
+ durationSeconds,
271
+ startDatetime: startTime,
272
+ endDatetime: endTime,
273
+ externalId: entry.SleepId != null ? `suunto-sleep-${entry.SleepId}` : undefined,
274
+ sleepTotalDurationMinutes: totalSleepMinutes,
275
+ sleepTimeInBedMinutes: timeInBedMinutes,
276
+ sleepDeepMinutes: Math.floor(deepSeconds / 60),
277
+ sleepLightMinutes: Math.floor(lightSeconds / 60),
278
+ sleepRemMinutes: Math.floor(remSeconds / 60),
279
+ sleepAwakeMinutes: Math.floor(awakeSeconds / 60),
280
+ sleepEfficiencyScore: entry.SleepQualityScore,
281
+ heartRateAvg: Number.isFinite(entry.HRAvg) ? Number(entry.HRAvg) : undefined,
282
+ heartRateMin: Number.isFinite(entry.HRMin) ? Number(entry.HRMin) : undefined,
283
+ isNap: Boolean(entry.IsNap),
284
+ };
285
+ }
286
+
287
+ async function fetchSleepEvents(
288
+ accessToken: string,
289
+ startDate: number,
290
+ endDate: number,
291
+ credentials?: ProviderCredentials,
292
+ ): Promise<NormalizedEvent[]> {
293
+ const headers = buildHeaders(credentials?.subscriptionKey);
294
+ const events: NormalizedEvent[] = [];
295
+ let cursor = startDate;
296
+
297
+ while (cursor < endDate) {
298
+ const chunkEnd = Math.min(cursor + SLEEP_CHUNK_MS, endDate);
299
+ const response = await makeAuthenticatedRequest<any>(API_BASE, SLEEP_ENDPOINT, accessToken, {
300
+ params: {
301
+ from: cursor,
302
+ to: chunkEnd,
303
+ },
304
+ headers,
305
+ });
306
+
307
+ const entries = Array.isArray(response) ? response : [];
308
+ for (const entry of entries) {
309
+ const normalized = normalizeSleep(entry);
310
+ if (normalized) events.push(normalized);
311
+ }
312
+
313
+ if (chunkEnd === endDate) break;
314
+ cursor = chunkEnd;
315
+ }
316
+
317
+ return events;
318
+ }
319
+
320
+ function normalizeRecoverySample(raw: any): NormalizedDataPoint[] {
321
+ const timestamp = parseTimestamp(raw.timestamp);
322
+ if (!timestamp) return [];
323
+ const entry = raw.entryData ?? {};
324
+ const value = Number(entry.Balance ?? entry.balance ?? null);
325
+ if (!Number.isFinite(value)) return [];
326
+ return [
327
+ {
328
+ seriesType: "recovery_score",
329
+ recordedAt: timestamp,
330
+ value: value * 100,
331
+ source: "suunto",
332
+ },
333
+ ];
334
+ }
335
+
336
+ function normalizeActivitySamples(raw: any): NormalizedDataPoint[] {
337
+ const timestamp = parseTimestamp(raw.timestamp);
338
+ if (!timestamp) return [];
339
+ const entry = raw.entryData ?? {};
340
+ const points: NormalizedDataPoint[] = [];
341
+
342
+ if (Number.isFinite(entry.HR)) {
343
+ points.push({
344
+ seriesType: "heart_rate",
345
+ recordedAt: timestamp,
346
+ value: Number(entry.HR),
347
+ source: "suunto",
348
+ });
349
+ }
350
+ if (Number.isFinite(entry.StepCount)) {
351
+ points.push({
352
+ seriesType: "steps",
353
+ recordedAt: timestamp,
354
+ value: Number(entry.StepCount),
355
+ source: "suunto",
356
+ });
357
+ }
358
+ if (Number.isFinite(entry.SpO2)) {
359
+ const spo2 = Number(entry.SpO2);
360
+ points.push({
361
+ seriesType: "oxygen_saturation",
362
+ recordedAt: timestamp,
363
+ value: spo2 <= 1 ? spo2 * 100 : spo2,
364
+ source: "suunto",
365
+ });
366
+ }
367
+ if (Number.isFinite(entry.EnergyConsumption)) {
368
+ points.push({
369
+ seriesType: "energy",
370
+ recordedAt: timestamp,
371
+ value: Number(entry.EnergyConsumption) / ENERGY_CONVERSION,
372
+ source: "suunto",
373
+ });
374
+ }
375
+ if (Number.isFinite(entry.HRV)) {
376
+ points.push({
377
+ seriesType: "heart_rate_variability_rmssd",
378
+ recordedAt: timestamp,
379
+ value: Number(entry.HRV),
380
+ source: "suunto",
381
+ });
382
+ }
383
+
384
+ return points;
385
+ }
386
+
387
+ function normalizeDailyStatSample(
388
+ stat: any,
389
+ ): { date: string; type: string; recordedAt: number; value: number }[] {
390
+ const type = (stat.Name ?? stat.type ?? "").toLowerCase();
391
+ const samples = [] as { date: string; type: string; recordedAt: number; value: number }[];
392
+ const sources = Array.isArray(stat.Sources) ? stat.Sources : [];
393
+ for (const source of sources) {
394
+ const entries = Array.isArray(source.Samples) ? source.Samples : [];
395
+ for (const sample of entries) {
396
+ const timestamp = parseTimestamp(sample.TimeISO8601);
397
+ if (!timestamp || sample.Value == null) continue;
398
+ let value = Number(sample.Value);
399
+ if (!Number.isFinite(value)) continue;
400
+ if (type === "energyconsumption") {
401
+ value = value / ENERGY_CONVERSION;
402
+ }
403
+ const date = toDateString(timestamp);
404
+ if (!date) continue;
405
+ samples.push({ date, type, recordedAt: timestamp, value });
406
+ }
407
+ }
408
+ return samples;
409
+ }
410
+
411
+ async function fetchDailyStats(
412
+ accessToken: string,
413
+ startDate: number,
414
+ endDate: number,
415
+ credentials?: ProviderCredentials,
416
+ ): Promise<NormalizedDataPoint[]> {
417
+ const headers = buildHeaders(credentials?.subscriptionKey);
418
+ const points: NormalizedDataPoint[] = [];
419
+ let cursor = startDate;
420
+
421
+ while (cursor < endDate) {
422
+ const chunkEnd = Math.min(cursor + DAILY_STATS_CHUNK_MS, endDate);
423
+ const response = await makeAuthenticatedRequest<any>(
424
+ API_BASE,
425
+ DAILY_STATS_ENDPOINT,
426
+ accessToken,
427
+ {
428
+ params: {
429
+ startdate: new Date(cursor).toISOString().split(".")[0],
430
+ enddate: new Date(chunkEnd).toISOString().split(".")[0],
431
+ },
432
+ headers,
433
+ },
434
+ );
435
+ const stats = Array.isArray(response) ? response : [];
436
+
437
+ for (const stat of stats) {
438
+ const normalized = normalizeDailyStatSample(stat);
439
+ for (const sample of normalized) {
440
+ const seriesType =
441
+ sample.type === "stepcount"
442
+ ? "steps"
443
+ : sample.type === "energyconsumption"
444
+ ? "energy"
445
+ : undefined;
446
+ if (!seriesType) continue;
447
+ points.push({
448
+ seriesType,
449
+ recordedAt: sample.recordedAt,
450
+ value: sample.value,
451
+ source: "suunto",
452
+ });
453
+ }
454
+ }
455
+
456
+ if (chunkEnd === endDate) break;
457
+ cursor = chunkEnd;
458
+ }
459
+
460
+ return points;
461
+ }
462
+
463
+ async function fetchRecurrencePoints(
464
+ accessToken: string,
465
+ startDate: number,
466
+ endDate: number,
467
+ endpoint: string,
468
+ credentials?: ProviderCredentials,
469
+ ): Promise<any[]> {
470
+ const headers = buildHeaders(credentials?.subscriptionKey);
471
+ const results: any[] = [];
472
+ let cursor = startDate;
473
+
474
+ while (cursor < endDate) {
475
+ const chunkEnd = Math.min(cursor + SLEEP_CHUNK_MS, endDate);
476
+ const response = await makeAuthenticatedRequest<any>(API_BASE, endpoint, accessToken, {
477
+ params: { from: cursor, to: chunkEnd },
478
+ headers,
479
+ });
480
+ if (Array.isArray(response)) results.push(...response);
481
+ if (chunkEnd === endDate) break;
482
+ cursor = chunkEnd;
483
+ }
484
+
485
+ return results;
486
+ }
487
+
488
+ async function fetchSuuntoRecovery(
489
+ accessToken: string,
490
+ startDate: number,
491
+ endDate: number,
492
+ credentials?: ProviderCredentials,
493
+ ): Promise<NormalizedDataPoint[]> {
494
+ const raws = await fetchRecurrencePoints(
495
+ accessToken,
496
+ startDate,
497
+ endDate,
498
+ RECOVERY_ENDPOINT,
499
+ credentials,
500
+ );
501
+ return raws.flatMap(normalizeRecoverySample);
502
+ }
503
+
504
+ async function fetchSuuntoActivity(
505
+ accessToken: string,
506
+ startDate: number,
507
+ endDate: number,
508
+ credentials?: ProviderCredentials,
509
+ ): Promise<NormalizedDataPoint[]> {
510
+ const raws = await fetchRecurrencePoints(
511
+ accessToken,
512
+ startDate,
513
+ endDate,
514
+ ACTIVITY_ENDPOINT,
515
+ credentials,
516
+ );
517
+ return raws.flatMap(normalizeActivitySamples);
518
+ }
519
+
520
+ async function aggregateDailySummaries(
521
+ accessToken: string,
522
+ startDate: number,
523
+ endDate: number,
524
+ credentials?: ProviderCredentials,
525
+ ): Promise<NormalizedDailySummary[]> {
526
+ const dataPoints = await fetchDailyStats(accessToken, startDate, endDate, credentials);
527
+ const summaryMap: Record<string, NormalizedDailySummary> = {};
528
+
529
+ for (const point of dataPoints) {
530
+ const dateKey = new Date(point.recordedAt).toISOString().split("T")[0];
531
+ const bucket = summaryMap[dateKey] ?? { date: dateKey, category: "activity" };
532
+ if (point.seriesType === "steps") {
533
+ bucket.totalSteps = (bucket.totalSteps ?? 0) + point.value;
534
+ }
535
+ if (point.seriesType === "energy") {
536
+ bucket.totalCalories = (bucket.totalCalories ?? 0) + point.value;
537
+ }
538
+ summaryMap[dateKey] = bucket;
539
+ }
540
+
541
+ return Object.values(summaryMap);
542
+ }
543
+
544
+ export function suuntoOAuthConfig(credentials: ProviderCredentials): OAuthProviderConfig {
545
+ return {
546
+ endpoints: {
547
+ authorizeUrl: "https://cloudapi-oauth.suunto.com/oauth/authorize",
548
+ tokenUrl: "https://cloudapi-oauth.suunto.com/oauth/token",
549
+ apiBaseUrl: API_BASE,
550
+ },
551
+ clientId: credentials.clientId,
552
+ clientSecret: credentials.clientSecret,
553
+ defaultScope: "",
554
+ usePkce: false,
555
+ authMethod: "body",
556
+ defaultHeaders: buildHeaders(credentials.subscriptionKey),
557
+ };
558
+ }
559
+
560
+ export async function getSuuntoUserInfo(
561
+ accessToken: string,
562
+ _tokenResponse?: unknown,
563
+ _appUserId?: string,
564
+ _credentials?: ProviderCredentials,
565
+ ): Promise<ProviderUserInfo> {
566
+ const payload = decodeJwt(accessToken);
567
+ return {
568
+ providerUserId: payload?.sub ? String(payload.sub) : null,
569
+ username: typeof payload?.user === "string" ? payload.user : null,
570
+ };
571
+ }
572
+
573
+ export const suuntoProvider: ProviderAdapter = {
574
+ name: "suunto",
575
+ oauthConfig: suuntoOAuthConfig,
576
+ getUserInfo: getSuuntoUserInfo,
577
+ fetchEvents: async (accessToken, startDate, endDate, credentials) => {
578
+ const workouts = await paginatedWorkouts(accessToken, startDate, endDate, credentials);
579
+ const normalizedWorkouts = workouts.map(normalizeWorkout);
580
+ const sleep = await fetchSleepEvents(accessToken, startDate, endDate, credentials);
581
+ return [...normalizedWorkouts, ...sleep];
582
+ },
583
+ fetchDataPoints: async (accessToken, startDate, endDate, credentials) => {
584
+ const recovery = await fetchSuuntoRecovery(accessToken, startDate, endDate, credentials);
585
+ const activity = await fetchSuuntoActivity(accessToken, startDate, endDate, credentials);
586
+ const daily = await fetchDailyStats(accessToken, startDate, endDate, credentials);
587
+ return [...recovery, ...activity, ...daily];
588
+ },
589
+ fetchDailySummaries: async (accessToken, startDate, endDate, credentials) => {
590
+ return aggregateDailySummaries(accessToken, startDate, endDate, credentials);
591
+ },
592
+ };