@clipin/convex-wearables 0.1.3 → 0.2.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 (41) hide show
  1. package/README.md +11 -2
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +3 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/types.d.ts +6 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/garminWebhooks.js +2 -0
  10. package/dist/component/garminWebhooks.js.map +1 -1
  11. package/dist/component/providers/types.d.ts +2 -0
  12. package/dist/component/providers/types.d.ts.map +1 -1
  13. package/dist/component/schema.d.ts +11 -1
  14. package/dist/component/schema.d.ts.map +1 -1
  15. package/dist/component/schema.js +6 -0
  16. package/dist/component/schema.js.map +1 -1
  17. package/dist/component/sdkPush.d.ts +4 -0
  18. package/dist/component/sdkPush.d.ts.map +1 -1
  19. package/dist/component/sdkPush.js +5 -0
  20. package/dist/component/sdkPush.js.map +1 -1
  21. package/dist/component/summaries.d.ts +16 -1
  22. package/dist/component/summaries.d.ts.map +1 -1
  23. package/dist/component/summaries.js +72 -38
  24. package/dist/component/summaries.js.map +1 -1
  25. package/dist/component/syncWorkflow.d.ts.map +1 -1
  26. package/dist/component/syncWorkflow.js +2 -0
  27. package/dist/component/syncWorkflow.js.map +1 -1
  28. package/dist/test.d.ts +11 -1
  29. package/dist/test.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/client/index.ts +4 -0
  32. package/src/client/types.ts +6 -0
  33. package/src/component/garminWebhooks.test.ts +2 -0
  34. package/src/component/garminWebhooks.ts +2 -0
  35. package/src/component/providers/types.ts +2 -0
  36. package/src/component/schema.ts +6 -0
  37. package/src/component/sdkPush.test.ts +83 -0
  38. package/src/component/sdkPush.ts +5 -0
  39. package/src/component/summaries.test.ts +99 -0
  40. package/src/component/summaries.ts +89 -39
  41. package/src/component/syncWorkflow.ts +2 -0
@@ -200,6 +200,10 @@ export default defineSchema({
200
200
  // -------------------------------------------------------------------------
201
201
  dailySummaries: defineTable({
202
202
  userId: v.string(),
203
+ provider: v.optional(providerName),
204
+ dataSourceId: v.optional(v.id("dataSources")),
205
+ source: v.optional(v.string()),
206
+ originalSourceName: v.optional(v.string()),
203
207
  date: v.string(), // "2026-03-15" (ISO date string)
204
208
  category: v.string(), // "activity" | "sleep" | "recovery" | "body"
205
209
 
@@ -241,6 +245,8 @@ export default defineSchema({
241
245
  bodyBattery: v.optional(v.number()),
242
246
  spo2Avg: v.optional(v.number()),
243
247
  })
248
+ .index("by_user_provider_category_date", ["userId", "provider", "category", "date"])
249
+ .index("by_user_provider_date", ["userId", "provider", "date"])
244
250
  .index("by_user_category_date", ["userId", "category", "date"])
245
251
  .index("by_user_date", ["userId", "date"]),
246
252
 
@@ -130,6 +130,20 @@ describe("sdkPush", () => {
130
130
  .collect();
131
131
  });
132
132
  expect(summaries).toHaveLength(2);
133
+ expect(summaries).toEqual(
134
+ expect.arrayContaining([
135
+ expect.objectContaining({
136
+ provider: "google",
137
+ source: "health-connect",
138
+ category: "activity",
139
+ }),
140
+ expect.objectContaining({
141
+ provider: "google",
142
+ source: "health-connect",
143
+ category: "recovery",
144
+ }),
145
+ ]),
146
+ );
133
147
  });
134
148
 
135
149
  it("deduplicates SDK pushes by external id and source-time keys", async () => {
@@ -304,12 +318,81 @@ describe("sdkPush", () => {
304
318
 
305
319
  expect(summaries).toHaveLength(1);
306
320
  expect(summaries[0]).toMatchObject({
321
+ provider: "google",
322
+ source: "health-connect",
307
323
  category: "activity",
308
324
  totalSteps: 12345,
309
325
  totalCalories: 780,
310
326
  });
311
327
  });
312
328
 
329
+ it("keeps Apple and Google daily summaries separate for the same user date and category", async () => {
330
+ const t = convexTest(schema, modules);
331
+
332
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
333
+ userId: "user-mixed",
334
+ provider: "apple",
335
+ sourceMetadata: {
336
+ source: "healthkit",
337
+ originalSourceName: "Apple Watch",
338
+ },
339
+ summaries: [
340
+ {
341
+ date: "2026-03-18",
342
+ category: "activity",
343
+ totalSteps: 9000,
344
+ activeCalories: 450,
345
+ },
346
+ ],
347
+ });
348
+
349
+ await t.action(api.sdkPush.ingestNormalizedPayload, {
350
+ userId: "user-mixed",
351
+ provider: "google",
352
+ sourceMetadata: {
353
+ source: "health-connect",
354
+ originalSourceName: "com.google.android.apps.fitness",
355
+ },
356
+ summaries: [
357
+ {
358
+ date: "2026-03-18",
359
+ category: "activity",
360
+ totalSteps: 7200,
361
+ activeCalories: 330,
362
+ },
363
+ ],
364
+ });
365
+
366
+ const summaries = await t.run(async (ctx) => {
367
+ return await ctx.db
368
+ .query("dailySummaries")
369
+ .withIndex("by_user_category_date", (idx) =>
370
+ idx.eq("userId", "user-mixed").eq("category", "activity").eq("date", "2026-03-18"),
371
+ )
372
+ .collect();
373
+ });
374
+
375
+ expect(summaries).toHaveLength(2);
376
+ expect(summaries).toEqual(
377
+ expect.arrayContaining([
378
+ expect.objectContaining({
379
+ provider: "apple",
380
+ source: "healthkit",
381
+ originalSourceName: "Apple Watch",
382
+ totalSteps: 9000,
383
+ activeCalories: 450,
384
+ }),
385
+ expect.objectContaining({
386
+ provider: "google",
387
+ source: "health-connect",
388
+ originalSourceName: "com.google.android.apps.fitness",
389
+ totalSteps: 7200,
390
+ activeCalories: 330,
391
+ }),
392
+ ]),
393
+ );
394
+ });
395
+
313
396
  it("batches large data-point payloads across multiple writes", async () => {
314
397
  const t = convexTest(schema, modules);
315
398
 
@@ -93,6 +93,8 @@ const sdkDataPointValidator = v.object({
93
93
  const sdkSummaryValidator = v.object({
94
94
  date: v.string(),
95
95
  category: v.string(),
96
+ source: v.optional(v.string()),
97
+ originalSourceName: v.optional(v.string()),
96
98
  totalSteps: v.optional(v.number()),
97
99
  totalCalories: v.optional(v.number()),
98
100
  activeCalories: v.optional(v.number()),
@@ -360,7 +362,10 @@ export const ingestNormalizedPayload = action({
360
362
  for (const summary of summaries) {
361
363
  await ctx.runMutation(internal.summaries.upsert, {
362
364
  userId: args.userId,
365
+ provider: args.provider,
363
366
  ...summary,
367
+ source: summary.source ?? defaultMetadata.source,
368
+ originalSourceName: summary.originalSourceName ?? defaultMetadata.originalSourceName,
364
369
  });
365
370
  }
366
371
 
@@ -1,5 +1,6 @@
1
1
  import { convexTest } from "convex-test";
2
2
  import { describe, expect, it } from "vitest";
3
+ import { api, internal } from "./_generated/api";
3
4
  import schema from "./schema";
4
5
  import { modules } from "./test.setup";
5
6
 
@@ -81,6 +82,62 @@ describe("summaries", () => {
81
82
  expect(summaries[0].totalSteps).toBe(10000);
82
83
  expect(summaries[0].activeMinutes).toBe(45);
83
84
  });
85
+
86
+ it("upserts summaries by user provider category and date", async () => {
87
+ const t = convexTest(schema, modules);
88
+
89
+ await t.mutation(internal.summaries.upsert, {
90
+ userId: "user-1",
91
+ provider: "garmin",
92
+ date: "2026-03-15",
93
+ category: "activity",
94
+ totalSteps: 10000,
95
+ activeCalories: 600,
96
+ });
97
+
98
+ await t.mutation(internal.summaries.upsert, {
99
+ userId: "user-1",
100
+ provider: "apple",
101
+ date: "2026-03-15",
102
+ category: "activity",
103
+ source: "healthkit",
104
+ originalSourceName: "Apple Watch",
105
+ totalSteps: 8500,
106
+ activeCalories: 430,
107
+ });
108
+
109
+ await t.mutation(internal.summaries.upsert, {
110
+ userId: "user-1",
111
+ provider: "garmin",
112
+ date: "2026-03-15",
113
+ category: "activity",
114
+ totalSteps: 11000,
115
+ });
116
+
117
+ const summaries = await t.run(async (ctx) => {
118
+ return await ctx.db
119
+ .query("dailySummaries")
120
+ .withIndex("by_user_category_date", (idx) =>
121
+ idx.eq("userId", "user-1").eq("category", "activity").eq("date", "2026-03-15"),
122
+ )
123
+ .collect();
124
+ });
125
+
126
+ expect(summaries).toHaveLength(2);
127
+ const garmin = summaries.find((summary) => summary.provider === "garmin");
128
+ const apple = summaries.find((summary) => summary.provider === "apple");
129
+
130
+ expect(garmin).toMatchObject({
131
+ totalSteps: 11000,
132
+ activeCalories: 600,
133
+ });
134
+ expect(apple).toMatchObject({
135
+ source: "healthkit",
136
+ originalSourceName: "Apple Watch",
137
+ totalSteps: 8500,
138
+ activeCalories: 430,
139
+ });
140
+ });
84
141
  });
85
142
 
86
143
  describe("getDailySummaries", () => {
@@ -156,6 +213,48 @@ describe("summaries", () => {
156
213
  expect(sleep).toHaveLength(1);
157
214
  expect(sleep[0].sleepDurationMinutes).toBe(480);
158
215
  });
216
+
217
+ it("filters summaries by provider when requested", async () => {
218
+ const t = convexTest(schema, modules);
219
+
220
+ await t.mutation(internal.summaries.upsert, {
221
+ userId: "user-1",
222
+ provider: "garmin",
223
+ date: "2026-03-15",
224
+ category: "activity",
225
+ totalSteps: 10000,
226
+ });
227
+ await t.mutation(internal.summaries.upsert, {
228
+ userId: "user-1",
229
+ provider: "google",
230
+ date: "2026-03-15",
231
+ category: "activity",
232
+ source: "health-connect",
233
+ totalSteps: 9200,
234
+ });
235
+
236
+ const google = await t.query(api.summaries.getDailySummaries, {
237
+ userId: "user-1",
238
+ provider: "google",
239
+ category: "activity",
240
+ startDate: "2026-03-15",
241
+ endDate: "2026-03-15",
242
+ });
243
+ const mixed = await t.query(api.summaries.getDailySummaries, {
244
+ userId: "user-1",
245
+ category: "activity",
246
+ startDate: "2026-03-15",
247
+ endDate: "2026-03-15",
248
+ });
249
+
250
+ expect(google).toHaveLength(1);
251
+ expect(google[0]).toMatchObject({
252
+ provider: "google",
253
+ source: "health-connect",
254
+ totalSteps: 9200,
255
+ });
256
+ expect(mixed).toHaveLength(2);
257
+ });
159
258
  });
160
259
 
161
260
  describe("getByUserDate", () => {
@@ -1,5 +1,37 @@
1
1
  import { v } from "convex/values";
2
2
  import { internalMutation, internalQuery, query } from "./_generated/server";
3
+ import { providerName } from "./schema";
4
+
5
+ const summaryMetricsValidator = {
6
+ totalSteps: v.optional(v.number()),
7
+ totalCalories: v.optional(v.number()),
8
+ activeCalories: v.optional(v.number()),
9
+ activeMinutes: v.optional(v.number()),
10
+ totalDistance: v.optional(v.number()),
11
+ floorsClimbed: v.optional(v.number()),
12
+ avgHeartRate: v.optional(v.number()),
13
+ maxHeartRate: v.optional(v.number()),
14
+ minHeartRate: v.optional(v.number()),
15
+ sleepDurationMinutes: v.optional(v.number()),
16
+ sleepEfficiency: v.optional(v.number()),
17
+ deepSleepMinutes: v.optional(v.number()),
18
+ remSleepMinutes: v.optional(v.number()),
19
+ lightSleepMinutes: v.optional(v.number()),
20
+ awakeDuringMinutes: v.optional(v.number()),
21
+ timeInBedMinutes: v.optional(v.number()),
22
+ hrvAvg: v.optional(v.number()),
23
+ hrvRmssd: v.optional(v.number()),
24
+ restingHeartRate: v.optional(v.number()),
25
+ recoveryScore: v.optional(v.number()),
26
+ weight: v.optional(v.number()),
27
+ bodyFatPercentage: v.optional(v.number()),
28
+ bodyMassIndex: v.optional(v.number()),
29
+ leanBodyMass: v.optional(v.number()),
30
+ bodyTemperature: v.optional(v.number()),
31
+ avgStressLevel: v.optional(v.number()),
32
+ bodyBattery: v.optional(v.number()),
33
+ spo2Avg: v.optional(v.number()),
34
+ };
3
35
 
4
36
  // ---------------------------------------------------------------------------
5
37
  // Queries
@@ -7,17 +39,33 @@ import { internalMutation, internalQuery, query } from "./_generated/server";
7
39
 
8
40
  /**
9
41
  * Get daily summaries for a user by category and date range.
10
- * Returns one document per day very efficient (365 docs for a full year).
42
+ * When provider is omitted this returns provider-mixed storage rows and is not
43
+ * a canonical product view for multi-provider apps.
11
44
  */
12
45
  export const getDailySummaries = query({
13
46
  args: {
14
47
  userId: v.string(),
48
+ provider: v.optional(providerName),
15
49
  category: v.string(),
16
50
  startDate: v.string(), // "2026-03-01"
17
51
  endDate: v.string(), // "2026-03-15"
18
52
  },
19
53
  returns: v.array(v.any()),
20
54
  handler: async (ctx, args) => {
55
+ if (args.provider !== undefined) {
56
+ return await ctx.db
57
+ .query("dailySummaries")
58
+ .withIndex("by_user_provider_category_date", (idx) =>
59
+ idx
60
+ .eq("userId", args.userId)
61
+ .eq("provider", args.provider)
62
+ .eq("category", args.category)
63
+ .gte("date", args.startDate)
64
+ .lte("date", args.endDate),
65
+ )
66
+ .collect();
67
+ }
68
+
21
69
  return await ctx.db
22
70
  .query("dailySummaries")
23
71
  .withIndex("by_user_category_date", (idx) =>
@@ -37,10 +85,20 @@ export const getDailySummaries = query({
37
85
  export const getByUserDate = internalQuery({
38
86
  args: {
39
87
  userId: v.string(),
88
+ provider: v.optional(providerName),
40
89
  date: v.string(),
41
90
  },
42
91
  returns: v.array(v.any()),
43
92
  handler: async (ctx, args) => {
93
+ if (args.provider !== undefined) {
94
+ return await ctx.db
95
+ .query("dailySummaries")
96
+ .withIndex("by_user_provider_date", (idx) =>
97
+ idx.eq("userId", args.userId).eq("provider", args.provider).eq("date", args.date),
98
+ )
99
+ .collect();
100
+ }
101
+
44
102
  return await ctx.db
45
103
  .query("dailySummaries")
46
104
  .withIndex("by_user_date", (idx) => idx.eq("userId", args.userId).eq("date", args.date))
@@ -59,68 +117,60 @@ export const getByUserDate = internalQuery({
59
117
  export const upsert = internalMutation({
60
118
  args: {
61
119
  userId: v.string(),
120
+ provider: providerName,
121
+ dataSourceId: v.optional(v.id("dataSources")),
122
+ source: v.optional(v.string()),
123
+ originalSourceName: v.optional(v.string()),
62
124
  date: v.string(),
63
125
  category: v.string(),
64
126
  // All metric fields are optional — only provided fields are updated
65
- totalSteps: v.optional(v.number()),
66
- totalCalories: v.optional(v.number()),
67
- activeCalories: v.optional(v.number()),
68
- activeMinutes: v.optional(v.number()),
69
- totalDistance: v.optional(v.number()),
70
- floorsClimbed: v.optional(v.number()),
71
- avgHeartRate: v.optional(v.number()),
72
- maxHeartRate: v.optional(v.number()),
73
- minHeartRate: v.optional(v.number()),
74
- sleepDurationMinutes: v.optional(v.number()),
75
- sleepEfficiency: v.optional(v.number()),
76
- deepSleepMinutes: v.optional(v.number()),
77
- remSleepMinutes: v.optional(v.number()),
78
- lightSleepMinutes: v.optional(v.number()),
79
- awakeDuringMinutes: v.optional(v.number()),
80
- timeInBedMinutes: v.optional(v.number()),
81
- hrvAvg: v.optional(v.number()),
82
- hrvRmssd: v.optional(v.number()),
83
- restingHeartRate: v.optional(v.number()),
84
- recoveryScore: v.optional(v.number()),
85
- weight: v.optional(v.number()),
86
- bodyFatPercentage: v.optional(v.number()),
87
- bodyMassIndex: v.optional(v.number()),
88
- leanBodyMass: v.optional(v.number()),
89
- bodyTemperature: v.optional(v.number()),
90
- avgStressLevel: v.optional(v.number()),
91
- bodyBattery: v.optional(v.number()),
92
- spo2Avg: v.optional(v.number()),
127
+ ...summaryMetricsValidator,
93
128
  },
94
129
  returns: v.id("dailySummaries"),
95
130
  handler: async (ctx, args) => {
96
- const { userId, date, category, ...metrics } = args;
131
+ const {
132
+ userId,
133
+ provider,
134
+ dataSourceId,
135
+ source,
136
+ originalSourceName,
137
+ date,
138
+ category,
139
+ ...metrics
140
+ } = args;
97
141
 
98
- // Find existing summary for this user/date/category
142
+ // New writes are provider-scoped. Legacy rows without provider remain
143
+ // readable through unfiltered queries but are not canonical for new ingest.
99
144
  const existing = await ctx.db
100
145
  .query("dailySummaries")
101
- .withIndex("by_user_category_date", (idx) =>
102
- idx.eq("userId", userId).eq("category", category).eq("date", date),
146
+ .withIndex("by_user_provider_category_date", (idx) =>
147
+ idx.eq("userId", userId).eq("provider", provider).eq("category", category).eq("date", date),
103
148
  )
104
149
  .first();
105
150
 
106
- // Filter out undefined values from metrics
107
- const definedMetrics: Record<string, number> = {};
108
- for (const [key, value] of Object.entries(metrics)) {
151
+ const definedFields: Record<string, unknown> = {};
152
+ for (const [key, value] of Object.entries({
153
+ dataSourceId,
154
+ source,
155
+ originalSourceName,
156
+ ...metrics,
157
+ })) {
109
158
  if (value !== undefined) {
110
- definedMetrics[key] = value;
159
+ definedFields[key] = value;
111
160
  }
112
161
  }
113
162
 
114
163
  if (existing) {
115
- await ctx.db.patch(existing._id, definedMetrics);
164
+ await ctx.db.patch(existing._id, definedFields);
116
165
  return existing._id;
117
166
  }
118
167
 
119
168
  return await ctx.db.insert("dailySummaries", {
120
169
  userId,
170
+ provider,
121
171
  date,
122
172
  category,
123
- ...definedMetrics,
173
+ ...definedFields,
124
174
  });
125
175
  },
126
176
  });
@@ -501,7 +501,9 @@ export const runConnectionSync = durableWorkflow.define({
501
501
  for (const summary of batch.summaries as NormalizedDailySummary[]) {
502
502
  await step.runMutation(internal.summaries.upsert, {
503
503
  userId: job.userId,
504
+ provider: job.provider,
504
505
  ...summary,
506
+ source: summary.source ?? job.provider,
505
507
  });
506
508
  }
507
509
  processed += batch.summaries.length;