@clipin/convex-wearables 0.0.2 → 0.1.0

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 (104) hide show
  1. package/README.md +395 -0
  2. package/dist/client/index.d.ts +47 -6
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +30 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts +83 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +50 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -0
  11. package/dist/component/_generated/component.js +11 -0
  12. package/dist/component/_generated/component.js.map +1 -0
  13. package/dist/component/backfillJobs.d.ts +11 -11
  14. package/dist/component/connections.d.ts +9 -9
  15. package/dist/component/connections.d.ts.map +1 -1
  16. package/dist/component/connections.js +2 -0
  17. package/dist/component/connections.js.map +1 -1
  18. package/dist/component/dataPoints.d.ts +153 -39
  19. package/dist/component/dataPoints.d.ts.map +1 -1
  20. package/dist/component/dataPoints.js +1048 -139
  21. package/dist/component/dataPoints.js.map +1 -1
  22. package/dist/component/events.d.ts +13 -13
  23. package/dist/component/garminBackfill.d.ts +2 -2
  24. package/dist/component/garminWebhooks.d.ts +2 -2
  25. package/dist/component/garminWebhooks.d.ts.map +1 -1
  26. package/dist/component/garminWebhooks.js +2 -0
  27. package/dist/component/garminWebhooks.js.map +1 -1
  28. package/dist/component/lifecycle.d.ts +1 -1
  29. package/dist/component/lifecycle.d.ts.map +1 -1
  30. package/dist/component/lifecycle.js +39 -1
  31. package/dist/component/lifecycle.js.map +1 -1
  32. package/dist/component/oauthStates.d.ts +3 -3
  33. package/dist/component/schema.d.ts +192 -28
  34. package/dist/component/schema.d.ts.map +1 -1
  35. package/dist/component/schema.js +89 -0
  36. package/dist/component/schema.js.map +1 -1
  37. package/dist/component/sdkPush.d.ts +11 -11
  38. package/dist/component/summaries.d.ts +4 -4
  39. package/dist/component/syncJobs.d.ts +23 -23
  40. package/dist/component/syncWorkflow.d.ts +2 -2
  41. package/dist/component/timeSeriesPolicyUtils.d.ts +97 -0
  42. package/dist/component/timeSeriesPolicyUtils.d.ts.map +1 -0
  43. package/dist/component/timeSeriesPolicyUtils.js +163 -0
  44. package/dist/component/timeSeriesPolicyUtils.js.map +1 -0
  45. package/dist/test.d.ts +581 -0
  46. package/dist/test.d.ts.map +1 -0
  47. package/dist/test.js +17 -0
  48. package/dist/test.js.map +1 -0
  49. package/package.json +12 -2
  50. package/src/client/_generated/_ignore.ts +2 -0
  51. package/src/client/index.test.ts +149 -0
  52. package/src/client/index.ts +859 -0
  53. package/src/client/types.ts +632 -0
  54. package/src/component/_generated/_ignore.ts +2 -0
  55. package/src/component/_generated/api.ts +16 -0
  56. package/src/component/_generated/component.ts +74 -0
  57. package/src/component/_generated/dataModel.ts +40 -0
  58. package/src/component/_generated/server.ts +48 -0
  59. package/src/component/backfillJobs.test.ts +47 -0
  60. package/src/component/backfillJobs.ts +245 -0
  61. package/src/component/connections.test.ts +297 -0
  62. package/src/component/connections.ts +329 -0
  63. package/src/component/convex.config.ts +7 -0
  64. package/src/component/dataPoints.test.ts +827 -0
  65. package/src/component/dataPoints.ts +1676 -0
  66. package/src/component/dataSources.test.ts +247 -0
  67. package/src/component/dataSources.ts +109 -0
  68. package/src/component/events.test.ts +380 -0
  69. package/src/component/events.ts +288 -0
  70. package/src/component/garminBackfill.ts +343 -0
  71. package/src/component/garminWebhooks.test.ts +609 -0
  72. package/src/component/garminWebhooks.ts +656 -0
  73. package/src/component/httpHandlers.ts +153 -0
  74. package/src/component/lifecycle.test.ts +179 -0
  75. package/src/component/lifecycle.ts +128 -0
  76. package/src/component/menstrualCycles.ts +124 -0
  77. package/src/component/oauthActions.ts +261 -0
  78. package/src/component/oauthStates.test.ts +170 -0
  79. package/src/component/oauthStates.ts +85 -0
  80. package/src/component/providerSettings.ts +66 -0
  81. package/src/component/providers/additionalProviders.test.ts +401 -0
  82. package/src/component/providers/garmin.ts +1169 -0
  83. package/src/component/providers/oauth.test.ts +174 -0
  84. package/src/component/providers/oauth.ts +246 -0
  85. package/src/component/providers/polar.ts +220 -0
  86. package/src/component/providers/registry.ts +37 -0
  87. package/src/component/providers/strava.test.ts +195 -0
  88. package/src/component/providers/strava.ts +253 -0
  89. package/src/component/providers/suunto.ts +592 -0
  90. package/src/component/providers/types.ts +189 -0
  91. package/src/component/providers/whoop.ts +600 -0
  92. package/src/component/schema.ts +445 -0
  93. package/src/component/sdkPush.test.ts +367 -0
  94. package/src/component/sdkPush.ts +440 -0
  95. package/src/component/summaries.test.ts +201 -0
  96. package/src/component/summaries.ts +143 -0
  97. package/src/component/syncJobs.test.ts +254 -0
  98. package/src/component/syncJobs.ts +140 -0
  99. package/src/component/syncWorkflow.test.ts +87 -0
  100. package/src/component/syncWorkflow.ts +739 -0
  101. package/src/component/test.setup.ts +6 -0
  102. package/src/component/timeSeriesPolicyUtils.ts +243 -0
  103. package/src/component/workflowManager.ts +19 -0
  104. package/src/test.ts +25 -0
@@ -0,0 +1,367 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it } from "vitest";
3
+ import { api } from "./_generated/api";
4
+ import schema from "./schema";
5
+ import { modules } from "./test.setup";
6
+
7
+ describe("sdkPush", () => {
8
+ it("ingests normalized Google Health Connect data into connections, sources, events, points, and summaries", async () => {
9
+ const t = convexTest(schema, modules);
10
+
11
+ const result = await t.action(api.sdkPush.ingestNormalizedPayload, {
12
+ userId: "user-1",
13
+ provider: "google",
14
+ providerUserId: "hc-user-1",
15
+ providerUsername: "denis@example.com",
16
+ sourceMetadata: {
17
+ deviceModel: "Pixel Watch 3",
18
+ source: "health-connect",
19
+ },
20
+ events: [
21
+ {
22
+ category: "sleep",
23
+ type: "sleep_session",
24
+ startDatetime: Date.parse("2026-03-17T22:30:00Z"),
25
+ endDatetime: Date.parse("2026-03-18T06:30:00Z"),
26
+ durationSeconds: 8 * 60 * 60,
27
+ externalId: "hc-sleep-1",
28
+ sleepTotalDurationMinutes: 440,
29
+ sleepTimeInBedMinutes: 480,
30
+ sleepDeepMinutes: 90,
31
+ sleepLightMinutes: 240,
32
+ sleepRemMinutes: 110,
33
+ sleepAwakeMinutes: 40,
34
+ sleepEfficiencyScore: 91,
35
+ },
36
+ ],
37
+ dataPoints: [
38
+ {
39
+ seriesType: "heart_rate",
40
+ recordedAt: Date.parse("2026-03-18T07:00:00Z"),
41
+ value: 58,
42
+ externalId: "hc-hr-1",
43
+ },
44
+ {
45
+ seriesType: "steps",
46
+ recordedAt: Date.parse("2026-03-18T12:00:00Z"),
47
+ value: 4200,
48
+ externalId: "hc-steps-1",
49
+ },
50
+ {
51
+ seriesType: "resting_heart_rate",
52
+ recordedAt: Date.parse("2026-03-18T07:00:00Z"),
53
+ value: 49,
54
+ externalId: "hc-rhr-1",
55
+ },
56
+ ],
57
+ summaries: [
58
+ {
59
+ date: "2026-03-18",
60
+ category: "activity",
61
+ totalSteps: 10000,
62
+ totalCalories: 650,
63
+ },
64
+ {
65
+ date: "2026-03-18",
66
+ category: "recovery",
67
+ restingHeartRate: 49,
68
+ },
69
+ ],
70
+ });
71
+
72
+ expect(result.eventsStored).toBe(1);
73
+ expect(result.dataPointsStored).toBe(3);
74
+ expect(result.summariesStored).toBe(2);
75
+
76
+ const connection = await t.run(async (ctx) => {
77
+ return await ctx.db
78
+ .query("connections")
79
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1").eq("provider", "google"))
80
+ .first();
81
+ });
82
+ expect(connection).toMatchObject({
83
+ userId: "user-1",
84
+ provider: "google",
85
+ providerUserId: "hc-user-1",
86
+ providerUsername: "denis@example.com",
87
+ status: "active",
88
+ });
89
+ expect(connection?.lastSyncedAt).toBeTypeOf("number");
90
+
91
+ const dataSources = await t.run(async (ctx) => {
92
+ return await ctx.db
93
+ .query("dataSources")
94
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1").eq("provider", "google"))
95
+ .collect();
96
+ });
97
+ expect(dataSources).toHaveLength(1);
98
+ expect(dataSources[0]).toMatchObject({
99
+ deviceModel: "Pixel Watch 3",
100
+ source: "health-connect",
101
+ });
102
+
103
+ const events = await t.run(async (ctx) => {
104
+ return await ctx.db
105
+ .query("events")
106
+ .withIndex("by_user_category_time", (idx) =>
107
+ idx.eq("userId", "user-1").eq("category", "sleep"),
108
+ )
109
+ .collect();
110
+ });
111
+ expect(events).toHaveLength(1);
112
+ expect(events[0]).toMatchObject({
113
+ externalId: "hc-sleep-1",
114
+ sleepTotalDurationMinutes: 440,
115
+ sourceName: "Google Health Connect",
116
+ });
117
+
118
+ const dataPoints = await t.run(async (ctx) => {
119
+ return await ctx.db
120
+ .query("dataPoints")
121
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", dataSources[0]._id))
122
+ .collect();
123
+ });
124
+ expect(dataPoints).toHaveLength(3);
125
+
126
+ const summaries = await t.run(async (ctx) => {
127
+ return await ctx.db
128
+ .query("dailySummaries")
129
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-1").eq("date", "2026-03-18"))
130
+ .collect();
131
+ });
132
+ expect(summaries).toHaveLength(2);
133
+ });
134
+
135
+ it("deduplicates SDK pushes by external id and source-time keys", async () => {
136
+ const t = convexTest(schema, modules);
137
+
138
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
139
+ userId: "user-2",
140
+ provider: "apple",
141
+ sourceMetadata: {
142
+ deviceModel: "Apple Watch Ultra 2",
143
+ source: "healthkit",
144
+ },
145
+ events: [
146
+ {
147
+ category: "workout",
148
+ type: "running",
149
+ startDatetime: Date.parse("2026-03-18T10:00:00Z"),
150
+ endDatetime: Date.parse("2026-03-18T10:30:00Z"),
151
+ externalId: "apple-workout-1",
152
+ distance: 5000,
153
+ },
154
+ ],
155
+ dataPoints: [
156
+ {
157
+ seriesType: "heart_rate",
158
+ recordedAt: Date.parse("2026-03-18T10:15:00Z"),
159
+ value: 148,
160
+ },
161
+ ],
162
+ });
163
+
164
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
165
+ userId: "user-2",
166
+ provider: "apple",
167
+ sourceMetadata: {
168
+ deviceModel: "Apple Watch Ultra 2",
169
+ source: "healthkit",
170
+ },
171
+ events: [
172
+ {
173
+ category: "workout",
174
+ type: "running",
175
+ startDatetime: Date.parse("2026-03-18T10:00:00Z"),
176
+ endDatetime: Date.parse("2026-03-18T10:30:00Z"),
177
+ externalId: "apple-workout-1",
178
+ distance: 5200,
179
+ },
180
+ ],
181
+ dataPoints: [
182
+ {
183
+ seriesType: "heart_rate",
184
+ recordedAt: Date.parse("2026-03-18T10:15:00Z"),
185
+ value: 150,
186
+ },
187
+ ],
188
+ });
189
+
190
+ const sources = await t.run(async (ctx) => {
191
+ return await ctx.db
192
+ .query("dataSources")
193
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-2").eq("provider", "apple"))
194
+ .collect();
195
+ });
196
+
197
+ const events = await t.run(async (ctx) => {
198
+ return await ctx.db
199
+ .query("events")
200
+ .withIndex("by_user_category_time", (idx) =>
201
+ idx.eq("userId", "user-2").eq("category", "workout"),
202
+ )
203
+ .collect();
204
+ });
205
+ expect(events).toHaveLength(1);
206
+ expect(events[0].distance).toBe(5200);
207
+
208
+ const dataPoints = await t.run(async (ctx) => {
209
+ return await ctx.db
210
+ .query("dataPoints")
211
+ .withIndex("by_source_type_time", (idx) =>
212
+ idx
213
+ .eq("dataSourceId", sources[0]._id)
214
+ .eq("seriesType", "heart_rate")
215
+ .eq("recordedAt", Date.parse("2026-03-18T10:15:00Z")),
216
+ )
217
+ .collect();
218
+ });
219
+ expect(dataPoints).toHaveLength(1);
220
+ expect(dataPoints[0].value).toBe(150);
221
+ });
222
+
223
+ it("accepts plan-compatible payload aliases and normalizes series types", async () => {
224
+ const t = convexTest(schema, modules);
225
+
226
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
227
+ userId: "user-3",
228
+ provider: "google",
229
+ syncTimestamp: Date.parse("2026-03-18T18:00:00Z"),
230
+ device: {
231
+ model: "Pixel 9 Pro",
232
+ softwareVersion: "Android 16",
233
+ source: "health-connect",
234
+ },
235
+ dataPoints: [
236
+ {
237
+ seriesType: "hrv_rmssd",
238
+ recordedAt: Date.parse("2026-03-18T07:00:00Z"),
239
+ value: 42,
240
+ externalId: "hc-hrv-1",
241
+ },
242
+ {
243
+ seriesType: "floors_climbed",
244
+ recordedAt: Date.parse("2026-03-18T12:00:00Z"),
245
+ value: 12,
246
+ externalId: "hc-floors-1",
247
+ },
248
+ {
249
+ seriesType: "distance",
250
+ recordedAt: Date.parse("2026-03-18T12:01:00Z"),
251
+ value: 1500,
252
+ externalId: "hc-distance-1",
253
+ },
254
+ {
255
+ seriesType: "active_calories",
256
+ recordedAt: Date.parse("2026-03-18T12:02:00Z"),
257
+ value: 340,
258
+ externalId: "hc-active-calories-1",
259
+ },
260
+ ],
261
+ dailySummaries: [
262
+ {
263
+ date: "2026-03-18",
264
+ category: "activity",
265
+ totalSteps: 12345,
266
+ totalCalories: 780,
267
+ },
268
+ ],
269
+ });
270
+
271
+ const source = await t.run(async (ctx) => {
272
+ return await ctx.db
273
+ .query("dataSources")
274
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-3").eq("provider", "google"))
275
+ .first();
276
+ });
277
+
278
+ expect(source).toMatchObject({
279
+ deviceModel: "Pixel 9 Pro",
280
+ softwareVersion: "Android 16",
281
+ source: "health-connect",
282
+ });
283
+
284
+ const points = await t.run(async (ctx) => {
285
+ return await ctx.db
286
+ .query("dataPoints")
287
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source!._id))
288
+ .collect();
289
+ });
290
+
291
+ expect(points.map((point) => point.seriesType).sort()).toEqual([
292
+ "active_calories",
293
+ "distance",
294
+ "floors_climbed",
295
+ "heart_rate_variability_rmssd",
296
+ ]);
297
+
298
+ const summaries = await t.run(async (ctx) => {
299
+ return await ctx.db
300
+ .query("dailySummaries")
301
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-3").eq("date", "2026-03-18"))
302
+ .collect();
303
+ });
304
+
305
+ expect(summaries).toHaveLength(1);
306
+ expect(summaries[0]).toMatchObject({
307
+ category: "activity",
308
+ totalSteps: 12345,
309
+ totalCalories: 780,
310
+ });
311
+ });
312
+
313
+ it("batches large data-point payloads across multiple writes", async () => {
314
+ const t = convexTest(schema, modules);
315
+
316
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
317
+ userId: "user-4",
318
+ provider: "google",
319
+ sourceMetadata: {
320
+ deviceModel: "Pixel Watch 3",
321
+ source: "health-connect",
322
+ },
323
+ dataPoints: Array.from({ length: 205 }, (_, index) => ({
324
+ seriesType: "heart_rate",
325
+ recordedAt: Date.parse("2026-03-18T10:00:00Z") + index * 60_000,
326
+ value: 120 + (index % 5),
327
+ externalId: `hc-batch-${index}`,
328
+ })),
329
+ });
330
+
331
+ const source = await t.run(async (ctx) => {
332
+ return await ctx.db
333
+ .query("dataSources")
334
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-4").eq("provider", "google"))
335
+ .first();
336
+ });
337
+
338
+ const points = await t.run(async (ctx) => {
339
+ return await ctx.db
340
+ .query("dataPoints")
341
+ .withIndex("by_source_type_time", (idx) =>
342
+ idx.eq("dataSourceId", source!._id).eq("seriesType", "heart_rate"),
343
+ )
344
+ .collect();
345
+ });
346
+
347
+ expect(points).toHaveLength(205);
348
+ });
349
+
350
+ it("rejects unsupported series types", async () => {
351
+ const t = convexTest(schema, modules);
352
+
353
+ await expect(
354
+ t.action(api.sdkPush.ingestNormalizedPayload, {
355
+ userId: "user-5",
356
+ provider: "google",
357
+ dataPoints: [
358
+ {
359
+ seriesType: "totally_unknown_metric",
360
+ recordedAt: Date.parse("2026-03-18T10:00:00Z"),
361
+ value: 1,
362
+ },
363
+ ],
364
+ }),
365
+ ).rejects.toThrow('Unsupported series type "totally_unknown_metric"');
366
+ });
367
+ });