@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,401 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { polarProvider } from "./polar";
3
+ import { suuntoOAuthConfig, suuntoProvider } from "./suunto";
4
+ import { whoopProvider } from "./whoop";
5
+
6
+ function jsonResponse(body: unknown, status = 200): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ status,
9
+ headers: { "Content-Type": "application/json" },
10
+ });
11
+ }
12
+
13
+ function mockFetchSequence(responses: Response[]) {
14
+ const fetchMock = vi.fn();
15
+ for (const response of responses) {
16
+ fetchMock.mockResolvedValueOnce(response);
17
+ }
18
+ vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
19
+ return fetchMock;
20
+ }
21
+
22
+ function makeJwt(payload: Record<string, unknown>): string {
23
+ const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
24
+ return `header.${encoded}.signature`;
25
+ }
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ describe("polarProvider", () => {
33
+ it("builds OAuth config and derives the provider user id from the token response", async () => {
34
+ const config = polarProvider.oauthConfig({
35
+ clientId: "polar-client",
36
+ clientSecret: "polar-secret",
37
+ });
38
+
39
+ expect(config.endpoints.authorizeUrl).toBe("https://flow.polar.com/oauth2/authorization");
40
+ expect(config.authMethod).toBe("basic");
41
+ expect(config.defaultScope).toBe("accesslink.read_all");
42
+
43
+ const user = await polarProvider.getUserInfo("token", {
44
+ access_token: "token",
45
+ x_user_id: 42,
46
+ });
47
+ expect(user).toEqual({
48
+ providerUserId: "42",
49
+ username: null,
50
+ });
51
+ });
52
+
53
+ it("fetches and normalizes Polar workout events", async () => {
54
+ const fetchMock = mockFetchSequence([
55
+ jsonResponse([
56
+ {
57
+ id: "exercise-1",
58
+ device: "Polar Vantage",
59
+ sport: "RUNNING",
60
+ detailed_sport_info: "RUNNING_TRAIL",
61
+ start_time: "2026-03-15T10:00:00Z",
62
+ start_time_utc_offset: 60,
63
+ duration: "PT45M",
64
+ calories: 450,
65
+ distance: 9500,
66
+ heart_rate: {
67
+ average: 148,
68
+ maximum: 176,
69
+ },
70
+ },
71
+ ]),
72
+ ]);
73
+
74
+ const events = await polarProvider.fetchEvents!(
75
+ "polar-token",
76
+ Date.parse("2026-03-15T00:00:00Z"),
77
+ Date.parse("2026-03-16T00:00:00Z"),
78
+ );
79
+
80
+ expect(events).toHaveLength(1);
81
+ expect(events[0]).toMatchObject({
82
+ category: "workout",
83
+ type: "trail_running",
84
+ sourceName: "Polar Vantage",
85
+ externalId: "polar-exercise-1",
86
+ durationSeconds: 2700,
87
+ energyBurned: 450,
88
+ distance: 9500,
89
+ heartRateAvg: 148,
90
+ heartRateMax: 176,
91
+ });
92
+
93
+ const requestUrl = new URL(String(fetchMock.mock.calls[0][0]));
94
+ expect(requestUrl.pathname).toBe("/v3/exercises");
95
+ expect(requestUrl.search).toBe("");
96
+ });
97
+
98
+ it("registers the Polar member after connect", async () => {
99
+ const fetchMock = mockFetchSequence([jsonResponse({})]);
100
+
101
+ await polarProvider.postConnect!("polar-token", { access_token: "polar-token" }, "app-user-1");
102
+
103
+ const [url, init] = fetchMock.mock.calls[0] ?? [];
104
+ expect(String(url)).toBe("https://www.polaraccesslink.com/v3/users");
105
+ expect(init?.method).toBe("POST");
106
+ expect(init?.body).toBe(JSON.stringify({ "member-id": "app-user-1" }));
107
+ });
108
+ });
109
+
110
+ describe("whoopProvider", () => {
111
+ it("fetches and normalizes workout and sleep events", async () => {
112
+ mockFetchSequence([
113
+ jsonResponse({
114
+ records: [
115
+ {
116
+ id: "workout-1",
117
+ start: "2026-03-15T10:00:00Z",
118
+ end: "2026-03-15T11:00:00Z",
119
+ sport_name: "running",
120
+ score_state: "SCORED",
121
+ score: {
122
+ average_heart_rate: 142,
123
+ max_heart_rate: 173,
124
+ kilojoule: 1000,
125
+ distance_meter: 10200,
126
+ altitude_gain_meter: 240,
127
+ },
128
+ },
129
+ ],
130
+ }),
131
+ jsonResponse({
132
+ records: [
133
+ {
134
+ id: "sleep-1",
135
+ start: "2026-03-15T23:00:00Z",
136
+ end: "2026-03-16T07:00:00Z",
137
+ score_state: "SCORED",
138
+ nap: false,
139
+ score: {
140
+ sleep_efficiency_percentage: 91,
141
+ stage_summary: {
142
+ total_in_bed_time_milli: 8 * 60 * 60 * 1000,
143
+ total_awake_time_milli: 30 * 60 * 1000,
144
+ total_light_sleep_time_milli: 4 * 60 * 60 * 1000,
145
+ total_slow_wave_sleep_time_milli: 2 * 60 * 60 * 1000,
146
+ total_rem_sleep_time_milli: 90 * 60 * 1000,
147
+ },
148
+ },
149
+ },
150
+ ],
151
+ }),
152
+ ]);
153
+
154
+ const events = await whoopProvider.fetchEvents!(
155
+ "whoop-token",
156
+ Date.parse("2026-03-15T00:00:00Z"),
157
+ Date.parse("2026-03-17T00:00:00Z"),
158
+ );
159
+
160
+ expect(events).toHaveLength(2);
161
+ expect(events[0]).toMatchObject({
162
+ category: "workout",
163
+ type: "running",
164
+ source: "whoop",
165
+ externalId: "whoop-workout-workout-1",
166
+ movingTimeSeconds: 3600,
167
+ });
168
+ expect(events[1]).toMatchObject({
169
+ category: "sleep",
170
+ source: "whoop",
171
+ externalId: "whoop-sleep-sleep-1",
172
+ sleepTotalDurationMinutes: 450,
173
+ sleepTimeInBedMinutes: 480,
174
+ sleepAwakeMinutes: 30,
175
+ sleepDeepMinutes: 120,
176
+ sleepLightMinutes: 240,
177
+ sleepRemMinutes: 90,
178
+ });
179
+ });
180
+
181
+ it("fetches recovery and body-measurement data points", async () => {
182
+ mockFetchSequence([
183
+ jsonResponse({
184
+ records: [
185
+ {
186
+ created_at: "2026-03-16T08:00:00Z",
187
+ score_state: "SCORED",
188
+ score: {
189
+ recovery_score: 77,
190
+ resting_heart_rate: 51,
191
+ hrv_rmssd_milli: 64,
192
+ spo2_percentage: 98,
193
+ skin_temp_celsius: 36.7,
194
+ },
195
+ },
196
+ ],
197
+ }),
198
+ jsonResponse({
199
+ height_meter: 1.82,
200
+ weight_kilogram: 75,
201
+ }),
202
+ ]);
203
+
204
+ const points = await whoopProvider.fetchDataPoints!(
205
+ "whoop-token",
206
+ Date.parse("2026-03-15T00:00:00Z"),
207
+ Date.parse("2026-03-17T00:00:00Z"),
208
+ );
209
+
210
+ expect(points.map((point) => point.seriesType)).toEqual([
211
+ "recovery_score",
212
+ "resting_heart_rate",
213
+ "heart_rate_variability_rmssd",
214
+ "oxygen_saturation",
215
+ "skin_temperature",
216
+ "height",
217
+ "weight",
218
+ ]);
219
+ expect(points[5]).toMatchObject({
220
+ seriesType: "height",
221
+ value: 182,
222
+ });
223
+ expect(points[6]).toMatchObject({
224
+ seriesType: "weight",
225
+ value: 75,
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("suuntoProvider", () => {
231
+ it("builds OAuth config and decodes user info from the JWT access token", async () => {
232
+ const config = suuntoOAuthConfig({
233
+ clientId: "suunto-client",
234
+ clientSecret: "suunto-secret",
235
+ subscriptionKey: "sub-key",
236
+ });
237
+
238
+ expect(config.endpoints.authorizeUrl).toBe("https://cloudapi-oauth.suunto.com/oauth/authorize");
239
+ expect(config.defaultHeaders).toEqual({
240
+ "Ocp-Apim-Subscription-Key": "sub-key",
241
+ });
242
+
243
+ const user = await suuntoProvider.getUserInfo(
244
+ makeJwt({
245
+ sub: "suunto-user",
246
+ user: "denis",
247
+ }),
248
+ );
249
+ expect(user).toEqual({
250
+ providerUserId: "suunto-user",
251
+ username: "denis",
252
+ });
253
+ });
254
+
255
+ it("fetches Suunto events and data points with the subscription key header", async () => {
256
+ const fetchMock = mockFetchSequence([
257
+ jsonResponse({
258
+ payload: [
259
+ {
260
+ workoutId: 123,
261
+ activityId: 1,
262
+ startTime: Date.parse("2026-03-15T09:00:00Z"),
263
+ stopTime: Date.parse("2026-03-15T10:00:00Z"),
264
+ totalTime: 3600,
265
+ totalDistance: 10000,
266
+ stepCount: 1200,
267
+ energyConsumption: 500,
268
+ avgSpeed: 3,
269
+ maxSpeed: 4,
270
+ totalAscent: 150,
271
+ maxAltitude: 800,
272
+ minAltitude: 650,
273
+ avgPower: 210,
274
+ maxPower: 420,
275
+ gear: {
276
+ displayName: "Suunto Race S",
277
+ name: "Race S",
278
+ swVersion: "2.39.44",
279
+ },
280
+ hrdata: {
281
+ avg: 149,
282
+ hrmax: 181,
283
+ min: 92,
284
+ },
285
+ },
286
+ ],
287
+ }),
288
+ jsonResponse([
289
+ {
290
+ timestamp: "2026-03-16T07:00:00Z",
291
+ entryData: {
292
+ BedtimeStart: "2026-03-15T23:00:00Z",
293
+ BedtimeEnd: "2026-03-16T07:00:00Z",
294
+ Duration: 28800,
295
+ DeepSleepDuration: 7200,
296
+ LightSleepDuration: 14400,
297
+ REMSleepDuration: 5400,
298
+ SleepQualityScore: 84,
299
+ HRAvg: 48,
300
+ HRMin: 41,
301
+ SleepId: 55,
302
+ },
303
+ },
304
+ ]),
305
+ jsonResponse([
306
+ {
307
+ timestamp: "2026-03-16T08:00:00Z",
308
+ entryData: { Balance: 0.82 },
309
+ },
310
+ ]),
311
+ jsonResponse([
312
+ {
313
+ timestamp: "2026-03-16T08:00:00Z",
314
+ entryData: {
315
+ HR: 65,
316
+ StepCount: 120,
317
+ SpO2: 0.98,
318
+ EnergyConsumption: 4184,
319
+ HRV: 42,
320
+ },
321
+ },
322
+ ]),
323
+ jsonResponse([
324
+ {
325
+ Name: "stepcount",
326
+ Sources: [
327
+ {
328
+ Samples: [{ TimeISO8601: "2026-03-16T00:00:00Z", Value: 10000 }],
329
+ },
330
+ ],
331
+ },
332
+ {
333
+ Name: "energyconsumption",
334
+ Sources: [
335
+ {
336
+ Samples: [{ TimeISO8601: "2026-03-16T00:00:00Z", Value: 8368 }],
337
+ },
338
+ ],
339
+ },
340
+ ]),
341
+ ]);
342
+
343
+ const credentials = {
344
+ clientId: "suunto-client",
345
+ clientSecret: "suunto-secret",
346
+ subscriptionKey: "sub-key",
347
+ };
348
+ const startDate = Date.parse("2026-03-15T00:00:00Z");
349
+ const endDate = Date.parse("2026-03-17T00:00:00Z");
350
+
351
+ const events = await suuntoProvider.fetchEvents!(
352
+ "suunto-token",
353
+ startDate,
354
+ endDate,
355
+ credentials,
356
+ );
357
+ const points = await suuntoProvider.fetchDataPoints!(
358
+ "suunto-token",
359
+ startDate,
360
+ endDate,
361
+ credentials,
362
+ );
363
+
364
+ expect(events).toHaveLength(2);
365
+ expect(events[0]).toMatchObject({
366
+ category: "workout",
367
+ type: "running",
368
+ source: "suunto",
369
+ externalId: "suunto-workout-123",
370
+ softwareVersion: "2.39.44",
371
+ });
372
+ expect(events[1]).toMatchObject({
373
+ category: "sleep",
374
+ source: "suunto",
375
+ externalId: "suunto-sleep-55",
376
+ sleepTotalDurationMinutes: 450,
377
+ sleepTimeInBedMinutes: 480,
378
+ sleepAwakeMinutes: 30,
379
+ heartRateAvg: 48,
380
+ heartRateMin: 41,
381
+ });
382
+
383
+ expect(points.map((point) => point.seriesType)).toEqual([
384
+ "recovery_score",
385
+ "heart_rate",
386
+ "steps",
387
+ "oxygen_saturation",
388
+ "energy",
389
+ "heart_rate_variability_rmssd",
390
+ "steps",
391
+ "energy",
392
+ ]);
393
+
394
+ const firstHeaders = fetchMock.mock.calls[0]?.[1]?.headers as
395
+ | Record<string, string>
396
+ | undefined;
397
+ expect(firstHeaders).toMatchObject({
398
+ "Ocp-Apim-Subscription-Key": "sub-key",
399
+ });
400
+ });
401
+ });