@clipin/convex-wearables 0.1.3 → 0.2.1

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 (47) 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.d.ts +44 -0
  10. package/dist/component/garminWebhooks.d.ts.map +1 -1
  11. package/dist/component/garminWebhooks.js +224 -1
  12. package/dist/component/garminWebhooks.js.map +1 -1
  13. package/dist/component/oauthActions.d.ts.map +1 -1
  14. package/dist/component/oauthActions.js +5 -0
  15. package/dist/component/oauthActions.js.map +1 -1
  16. package/dist/component/providers/types.d.ts +2 -0
  17. package/dist/component/providers/types.d.ts.map +1 -1
  18. package/dist/component/schema.d.ts +38 -1
  19. package/dist/component/schema.d.ts.map +1 -1
  20. package/dist/component/schema.js +25 -0
  21. package/dist/component/schema.js.map +1 -1
  22. package/dist/component/sdkPush.d.ts +4 -0
  23. package/dist/component/sdkPush.d.ts.map +1 -1
  24. package/dist/component/sdkPush.js +5 -0
  25. package/dist/component/sdkPush.js.map +1 -1
  26. package/dist/component/summaries.d.ts +16 -1
  27. package/dist/component/summaries.d.ts.map +1 -1
  28. package/dist/component/summaries.js +72 -38
  29. package/dist/component/summaries.js.map +1 -1
  30. package/dist/component/syncWorkflow.d.ts.map +1 -1
  31. package/dist/component/syncWorkflow.js +2 -0
  32. package/dist/component/syncWorkflow.js.map +1 -1
  33. package/dist/test.d.ts +38 -1
  34. package/dist/test.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/client/index.ts +4 -0
  37. package/src/client/types.ts +6 -0
  38. package/src/component/garminWebhooks.test.ts +90 -2
  39. package/src/component/garminWebhooks.ts +249 -1
  40. package/src/component/oauthActions.ts +6 -0
  41. package/src/component/providers/types.ts +2 -0
  42. package/src/component/schema.ts +31 -0
  43. package/src/component/sdkPush.test.ts +83 -0
  44. package/src/component/sdkPush.ts +5 -0
  45. package/src/component/summaries.test.ts +99 -0
  46. package/src/component/summaries.ts +89 -39
  47. package/src/component/syncWorkflow.ts +2 -0
@@ -8,7 +8,13 @@
8
8
  import { v } from "convex/values";
9
9
  import { api, internal } from "./_generated/api";
10
10
  import type { Doc, Id } from "./_generated/dataModel";
11
- import { type ActionCtx, action } from "./_generated/server";
11
+ import {
12
+ type ActionCtx,
13
+ action,
14
+ internalAction,
15
+ internalMutation,
16
+ internalQuery,
17
+ } from "./_generated/server";
12
18
  import {
13
19
  type GarminPushPayload,
14
20
  normalizeActivity,
@@ -34,6 +40,8 @@ import {
34
40
  } from "./providers/garmin";
35
41
 
36
42
  const DATA_POINT_BATCH_SIZE = 500;
43
+ const PENDING_GARMIN_PUSH_TTL_MS = 30 * 60 * 1000;
44
+ const PENDING_GARMIN_PUSH_REPLAY_LIMIT = 20;
37
45
 
38
46
  function decodePushPayload(args: { payload?: unknown; payloadJson?: string }): GarminPushPayload {
39
47
  if (args.payloadJson !== undefined) {
@@ -61,6 +69,8 @@ export const processPushPayload = action({
61
69
  const payload = decodePushPayload(args);
62
70
  const signalBuckets = new Map<string, Set<string>>();
63
71
 
72
+ await queuePendingPayloadForInactiveConnections(ctx, payload, args.garminClientId);
73
+
64
74
  await processActivityEntries(ctx, payload.activities, "activities", signalBuckets);
65
75
  await processActivityEntries(ctx, payload.activityDetails, "activityDetails", signalBuckets);
66
76
 
@@ -438,6 +448,145 @@ export const processPushPayload = action({
438
448
  },
439
449
  });
440
450
 
451
+ export const replayPendingForConnection = internalAction({
452
+ args: {
453
+ connectionId: v.id("connections"),
454
+ },
455
+ returns: v.object({
456
+ failed: v.number(),
457
+ replayed: v.number(),
458
+ skipped: v.number(),
459
+ }),
460
+ handler: async (ctx, args) => {
461
+ const skipped = await ctx.runMutation(internal.garminWebhooks.markExpiredPendingForConnection, {
462
+ connectionId: args.connectionId,
463
+ now: Date.now(),
464
+ });
465
+ const pendingPayloads = await ctx.runQuery(internal.garminWebhooks.getReplayablePending, {
466
+ connectionId: args.connectionId,
467
+ now: Date.now(),
468
+ });
469
+ let failed = 0;
470
+ let replayed = 0;
471
+
472
+ for (const pending of pendingPayloads) {
473
+ try {
474
+ await ctx.runAction(api.garminWebhooks.processPushPayload, {
475
+ garminClientId: pending.garminClientId,
476
+ payloadJson: pending.payloadJson,
477
+ });
478
+ await ctx.runMutation(internal.garminWebhooks.markPendingReplayed, {
479
+ pendingId: pending._id,
480
+ });
481
+ replayed += 1;
482
+ } catch (error) {
483
+ failed += 1;
484
+ await ctx.runMutation(internal.garminWebhooks.markPendingFailed, {
485
+ pendingId: pending._id,
486
+ error: error instanceof Error ? error.message : String(error),
487
+ });
488
+ }
489
+ }
490
+
491
+ return { failed, replayed, skipped };
492
+ },
493
+ });
494
+
495
+ export const getReplayablePending = internalQuery({
496
+ args: {
497
+ connectionId: v.id("connections"),
498
+ now: v.number(),
499
+ },
500
+ returns: v.array(v.any()),
501
+ handler: async (ctx, args) => {
502
+ const rows = await ctx.db
503
+ .query("pendingGarminPushPayloads")
504
+ .withIndex("by_connection_status", (idx) =>
505
+ idx.eq("connectionId", args.connectionId).eq("status", "pending"),
506
+ )
507
+ .order("asc")
508
+ .take(PENDING_GARMIN_PUSH_REPLAY_LIMIT);
509
+
510
+ return rows.filter((row) => row.expiresAt > args.now);
511
+ },
512
+ });
513
+
514
+ export const storePendingPayload = internalMutation({
515
+ args: {
516
+ connectionId: v.id("connections"),
517
+ userId: v.string(),
518
+ providerUserId: v.string(),
519
+ garminClientId: v.string(),
520
+ payloadJson: v.string(),
521
+ receivedAt: v.number(),
522
+ expiresAt: v.number(),
523
+ },
524
+ returns: v.id("pendingGarminPushPayloads"),
525
+ handler: async (ctx, args) => {
526
+ return await ctx.db.insert("pendingGarminPushPayloads", {
527
+ ...args,
528
+ status: "pending",
529
+ });
530
+ },
531
+ });
532
+
533
+ export const markPendingReplayed = internalMutation({
534
+ args: {
535
+ pendingId: v.id("pendingGarminPushPayloads"),
536
+ },
537
+ returns: v.null(),
538
+ handler: async (ctx, args) => {
539
+ await ctx.db.patch(args.pendingId, {
540
+ replayedAt: Date.now(),
541
+ status: "replayed",
542
+ error: undefined,
543
+ });
544
+ return null;
545
+ },
546
+ });
547
+
548
+ export const markPendingFailed = internalMutation({
549
+ args: {
550
+ pendingId: v.id("pendingGarminPushPayloads"),
551
+ error: v.string(),
552
+ },
553
+ returns: v.null(),
554
+ handler: async (ctx, args) => {
555
+ await ctx.db.patch(args.pendingId, {
556
+ error: args.error,
557
+ status: "failed",
558
+ });
559
+ return null;
560
+ },
561
+ });
562
+
563
+ export const markExpiredPendingForConnection = internalMutation({
564
+ args: {
565
+ connectionId: v.id("connections"),
566
+ now: v.number(),
567
+ },
568
+ returns: v.number(),
569
+ handler: async (ctx, args) => {
570
+ const expired = await ctx.db
571
+ .query("pendingGarminPushPayloads")
572
+ .withIndex("by_connection_status", (idx) =>
573
+ idx.eq("connectionId", args.connectionId).eq("status", "pending"),
574
+ )
575
+ .filter((q) => q.lte(q.field("expiresAt"), args.now))
576
+ .collect();
577
+
578
+ await Promise.all(
579
+ expired.map((row) =>
580
+ ctx.db.patch(row._id, {
581
+ status: "expired",
582
+ }),
583
+ ),
584
+ );
585
+
586
+ return expired.length;
587
+ },
588
+ });
589
+
441
590
  async function processActivityEntries(
442
591
  ctx: Pick<ActionCtx, "runQuery" | "runMutation">,
443
592
  activities: GarminPushPayload["activities"] | GarminPushPayload["activityDetails"],
@@ -559,9 +708,11 @@ async function upsertSummary<T extends { date: string; category: string }>(
559
708
 
560
709
  await ctx.runMutation(internal.summaries.upsert, {
561
710
  userId,
711
+ provider: "garmin",
562
712
  date,
563
713
  category,
564
714
  ...metrics,
715
+ source: "garmin",
565
716
  });
566
717
  }
567
718
 
@@ -644,6 +795,103 @@ async function resolveConnection(
644
795
  return connection;
645
796
  }
646
797
 
798
+ function collectGarminUserIds(payload: GarminPushPayload): string[] {
799
+ const userIds = new Set<string>();
800
+ const maybeAdd = (entry: { userId?: unknown } | null | undefined) => {
801
+ if (typeof entry?.userId === "string" && entry.userId.length > 0) {
802
+ userIds.add(entry.userId);
803
+ }
804
+ };
805
+
806
+ for (const entry of payload.activities ?? []) maybeAdd(entry);
807
+ for (const entry of payload.activityDetails ?? []) maybeAdd(entry);
808
+ for (const entry of payload.sleeps ?? []) maybeAdd(entry);
809
+ for (const entry of payload.dailies ?? []) maybeAdd(entry);
810
+ for (const entry of payload.epochs ?? []) maybeAdd(entry);
811
+ for (const entry of payload.bodyComps ?? []) maybeAdd(entry);
812
+ for (const entry of payload.hrv ?? []) maybeAdd(entry);
813
+ for (const entry of payload.stressDetails ?? []) maybeAdd(entry);
814
+ for (const entry of payload.respiration ?? []) maybeAdd(entry);
815
+ for (const entry of payload.pulseOx ?? []) maybeAdd(entry);
816
+ for (const entry of payload.bloodPressures ?? []) maybeAdd(entry);
817
+ for (const entry of payload.userMetrics ?? []) maybeAdd(entry);
818
+ for (const entry of payload.skinTemp ?? []) maybeAdd(entry);
819
+ for (const entry of payload.healthSnapshot ?? []) maybeAdd(entry);
820
+ for (const entry of payload.moveiq ?? []) maybeAdd(entry);
821
+ for (const entry of payload.menstrualCycleTracking ?? []) maybeAdd(entry);
822
+ for (const entry of payload.mct ?? []) maybeAdd(entry);
823
+ for (const entry of payload.userPermissionsChange ?? []) maybeAdd(entry);
824
+ for (const entry of payload.deregistrations ?? []) maybeAdd(entry);
825
+
826
+ return [...userIds];
827
+ }
828
+
829
+ function filterGarminPayloadByUserId(
830
+ payload: GarminPushPayload,
831
+ providerUserId: string,
832
+ ): GarminPushPayload {
833
+ const filterEntries = <T extends { userId?: unknown }>(entries: T[] | undefined): T[] =>
834
+ (entries ?? []).filter((entry) => entry.userId === providerUserId);
835
+
836
+ return {
837
+ activities: filterEntries(payload.activities),
838
+ activityDetails: filterEntries(payload.activityDetails),
839
+ sleeps: filterEntries(payload.sleeps),
840
+ dailies: filterEntries(payload.dailies),
841
+ epochs: filterEntries(payload.epochs),
842
+ bodyComps: filterEntries(payload.bodyComps),
843
+ hrv: filterEntries(payload.hrv),
844
+ stressDetails: filterEntries(payload.stressDetails),
845
+ respiration: filterEntries(payload.respiration),
846
+ pulseOx: filterEntries(payload.pulseOx),
847
+ bloodPressures: filterEntries(payload.bloodPressures),
848
+ userMetrics: filterEntries(payload.userMetrics),
849
+ skinTemp: filterEntries(payload.skinTemp),
850
+ healthSnapshot: filterEntries(payload.healthSnapshot),
851
+ moveiq: filterEntries(payload.moveiq),
852
+ menstrualCycleTracking: filterEntries(payload.menstrualCycleTracking),
853
+ mct: filterEntries(payload.mct),
854
+ userPermissionsChange: filterEntries(payload.userPermissionsChange),
855
+ deregistrations: filterEntries(payload.deregistrations),
856
+ };
857
+ }
858
+
859
+ async function queuePendingPayloadForInactiveConnections(
860
+ ctx: Pick<ActionCtx, "runMutation" | "runQuery">,
861
+ payload: GarminPushPayload,
862
+ garminClientId: string,
863
+ ) {
864
+ const userIds = collectGarminUserIds(payload);
865
+ if (userIds.length === 0) {
866
+ return;
867
+ }
868
+
869
+ const receivedAt = Date.now();
870
+
871
+ for (const providerUserId of userIds) {
872
+ const conn = (await ctx.runQuery(internal.connections.getByProviderUser, {
873
+ provider: "garmin",
874
+ providerUserId,
875
+ })) as Doc<"connections"> | null;
876
+
877
+ if (!conn || conn.status === "active") {
878
+ continue;
879
+ }
880
+
881
+ const payloadJson = JSON.stringify(filterGarminPayloadByUserId(payload, providerUserId));
882
+
883
+ await ctx.runMutation(internal.garminWebhooks.storePendingPayload, {
884
+ connectionId: conn._id,
885
+ userId: conn.userId,
886
+ providerUserId,
887
+ garminClientId,
888
+ payloadJson,
889
+ receivedAt,
890
+ expiresAt: receivedAt + PENDING_GARMIN_PUSH_TTL_MS,
891
+ });
892
+ }
893
+ }
894
+
647
895
  /**
648
896
  * Resolve a Garmin userId to a dataSource ID, creating one if needed.
649
897
  */
@@ -192,6 +192,12 @@ export const handleCallback = action({
192
192
  source: oauthState.provider,
193
193
  });
194
194
 
195
+ if (oauthState.provider === "garmin") {
196
+ await ctx.runAction(internal.garminWebhooks.replayPendingForConnection, {
197
+ connectionId,
198
+ });
199
+ }
200
+
195
201
  return {
196
202
  provider: oauthState.provider,
197
203
  userId: oauthState.userId,
@@ -113,6 +113,8 @@ export interface NormalizedDataPoint {
113
113
  export interface NormalizedDailySummary {
114
114
  date: string;
115
115
  category: string;
116
+ source?: string;
117
+ originalSourceName?: string;
116
118
  totalSteps?: number;
117
119
  totalCalories?: number;
118
120
  activeCalories?: number;
@@ -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
 
@@ -301,6 +307,31 @@ export default defineSchema({
301
307
  updatedAt: v.optional(v.number()),
302
308
  }).index("by_provider", ["provider"]),
303
309
 
310
+ // -------------------------------------------------------------------------
311
+ // Pending Garmin Push Payloads — short-lived replay queue for OAuth reconnect
312
+ // races where Garmin pushes data before the connection is active again.
313
+ // -------------------------------------------------------------------------
314
+ pendingGarminPushPayloads: defineTable({
315
+ connectionId: v.id("connections"),
316
+ userId: v.string(),
317
+ providerUserId: v.string(),
318
+ garminClientId: v.string(),
319
+ payloadJson: v.string(),
320
+ receivedAt: v.number(),
321
+ expiresAt: v.number(),
322
+ replayedAt: v.optional(v.number()),
323
+ status: v.union(
324
+ v.literal("pending"),
325
+ v.literal("replayed"),
326
+ v.literal("expired"),
327
+ v.literal("failed"),
328
+ ),
329
+ error: v.optional(v.string()),
330
+ })
331
+ .index("by_connection_status", ["connectionId", "status"])
332
+ .index("by_provider_user_status", ["providerUserId", "status"])
333
+ .index("by_expires", ["expiresAt"]),
334
+
304
335
  // -------------------------------------------------------------------------
305
336
  // Time-Series Policy Rules — default and preset-based storage rules
306
337
  // -------------------------------------------------------------------------
@@ -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", () => {