@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,440 @@
1
+ import { v } from "convex/values";
2
+ import { SERIES_TYPES } from "../client/types";
3
+ import { api, internal } from "./_generated/api";
4
+ import type { Id } from "./_generated/dataModel";
5
+ import type { ActionCtx } from "./_generated/server";
6
+ import { action } from "./_generated/server";
7
+
8
+ const sdkProviderName = v.union(v.literal("apple"), v.literal("google"), v.literal("samsung"));
9
+ const EVENT_BATCH_SIZE = 50;
10
+ const DATA_POINT_BATCH_SIZE = 200;
11
+ const MAX_EVENTS_PER_REQUEST = 500;
12
+ const MAX_DATA_POINTS_PER_REQUEST = 10000;
13
+ const MAX_SUMMARIES_PER_REQUEST = 1000;
14
+ const SERIES_TYPE_ALIASES = {
15
+ hrv_rmssd: "heart_rate_variability_rmssd",
16
+ } as const;
17
+ const validSeriesTypes = new Set(Object.keys(SERIES_TYPES));
18
+
19
+ const deviceMetadataValidator = v.object({
20
+ model: v.optional(v.string()),
21
+ softwareVersion: v.optional(v.string()),
22
+ source: v.optional(v.string()),
23
+ deviceType: v.optional(v.string()),
24
+ originalSourceName: v.optional(v.string()),
25
+ });
26
+
27
+ const sourceMetadataValidator = v.object({
28
+ deviceModel: v.optional(v.string()),
29
+ softwareVersion: v.optional(v.string()),
30
+ source: v.optional(v.string()),
31
+ deviceType: v.optional(v.string()),
32
+ originalSourceName: v.optional(v.string()),
33
+ });
34
+
35
+ const sdkEventValidator = v.object({
36
+ category: v.union(v.literal("workout"), v.literal("sleep")),
37
+ type: v.optional(v.string()),
38
+ sourceName: v.optional(v.string()),
39
+ durationSeconds: v.optional(v.number()),
40
+ startDatetime: v.number(),
41
+ endDatetime: v.optional(v.number()),
42
+ externalId: v.optional(v.string()),
43
+ heartRateMin: v.optional(v.number()),
44
+ heartRateMax: v.optional(v.number()),
45
+ heartRateAvg: v.optional(v.number()),
46
+ energyBurned: v.optional(v.number()),
47
+ distance: v.optional(v.number()),
48
+ stepsCount: v.optional(v.number()),
49
+ maxSpeed: v.optional(v.number()),
50
+ maxWatts: v.optional(v.number()),
51
+ movingTimeSeconds: v.optional(v.number()),
52
+ totalElevationGain: v.optional(v.number()),
53
+ averageSpeed: v.optional(v.number()),
54
+ averageWatts: v.optional(v.number()),
55
+ elevHigh: v.optional(v.number()),
56
+ elevLow: v.optional(v.number()),
57
+ sleepTotalDurationMinutes: v.optional(v.number()),
58
+ sleepTimeInBedMinutes: v.optional(v.number()),
59
+ sleepEfficiencyScore: v.optional(v.number()),
60
+ sleepDeepMinutes: v.optional(v.number()),
61
+ sleepRemMinutes: v.optional(v.number()),
62
+ sleepLightMinutes: v.optional(v.number()),
63
+ sleepAwakeMinutes: v.optional(v.number()),
64
+ isNap: v.optional(v.boolean()),
65
+ sleepStages: v.optional(
66
+ v.array(
67
+ v.object({
68
+ stage: v.string(),
69
+ startTime: v.number(),
70
+ endTime: v.number(),
71
+ }),
72
+ ),
73
+ ),
74
+ deviceModel: v.optional(v.string()),
75
+ softwareVersion: v.optional(v.string()),
76
+ source: v.optional(v.string()),
77
+ deviceType: v.optional(v.string()),
78
+ originalSourceName: v.optional(v.string()),
79
+ });
80
+
81
+ const sdkDataPointValidator = v.object({
82
+ seriesType: v.string(),
83
+ recordedAt: v.number(),
84
+ value: v.number(),
85
+ externalId: v.optional(v.string()),
86
+ deviceModel: v.optional(v.string()),
87
+ softwareVersion: v.optional(v.string()),
88
+ source: v.optional(v.string()),
89
+ deviceType: v.optional(v.string()),
90
+ originalSourceName: v.optional(v.string()),
91
+ });
92
+
93
+ const sdkSummaryValidator = v.object({
94
+ date: v.string(),
95
+ category: v.string(),
96
+ totalSteps: v.optional(v.number()),
97
+ totalCalories: v.optional(v.number()),
98
+ activeCalories: v.optional(v.number()),
99
+ activeMinutes: v.optional(v.number()),
100
+ totalDistance: v.optional(v.number()),
101
+ floorsClimbed: v.optional(v.number()),
102
+ avgHeartRate: v.optional(v.number()),
103
+ maxHeartRate: v.optional(v.number()),
104
+ minHeartRate: v.optional(v.number()),
105
+ sleepDurationMinutes: v.optional(v.number()),
106
+ sleepEfficiency: v.optional(v.number()),
107
+ deepSleepMinutes: v.optional(v.number()),
108
+ remSleepMinutes: v.optional(v.number()),
109
+ lightSleepMinutes: v.optional(v.number()),
110
+ awakeDuringMinutes: v.optional(v.number()),
111
+ timeInBedMinutes: v.optional(v.number()),
112
+ hrvAvg: v.optional(v.number()),
113
+ hrvRmssd: v.optional(v.number()),
114
+ restingHeartRate: v.optional(v.number()),
115
+ recoveryScore: v.optional(v.number()),
116
+ weight: v.optional(v.number()),
117
+ bodyFatPercentage: v.optional(v.number()),
118
+ bodyMassIndex: v.optional(v.number()),
119
+ leanBodyMass: v.optional(v.number()),
120
+ bodyTemperature: v.optional(v.number()),
121
+ avgStressLevel: v.optional(v.number()),
122
+ bodyBattery: v.optional(v.number()),
123
+ spo2Avg: v.optional(v.number()),
124
+ });
125
+
126
+ type SdkProvider = "apple" | "google" | "samsung";
127
+
128
+ type SourceMetadata = {
129
+ deviceModel?: string;
130
+ softwareVersion?: string;
131
+ source?: string;
132
+ deviceType?: string;
133
+ originalSourceName?: string;
134
+ };
135
+
136
+ type DataSourceCache = Map<string, Id<"dataSources">>;
137
+ type ActionMutationRunner = Pick<ActionCtx, "runMutation">;
138
+
139
+ function sourceCacheKey(provider: SdkProvider, metadata: SourceMetadata): string {
140
+ return [
141
+ provider,
142
+ metadata.deviceModel ?? "",
143
+ metadata.softwareVersion ?? "",
144
+ metadata.source ?? provider,
145
+ metadata.deviceType ?? "",
146
+ metadata.originalSourceName ?? "",
147
+ ].join("::");
148
+ }
149
+
150
+ function resolveSourceMetadata(
151
+ defaults: SourceMetadata | undefined,
152
+ item: SourceMetadata,
153
+ ): SourceMetadata {
154
+ return {
155
+ deviceModel: item.deviceModel ?? defaults?.deviceModel,
156
+ softwareVersion: item.softwareVersion ?? defaults?.softwareVersion,
157
+ source: item.source ?? defaults?.source,
158
+ deviceType: item.deviceType ?? defaults?.deviceType,
159
+ originalSourceName: item.originalSourceName ?? defaults?.originalSourceName,
160
+ };
161
+ }
162
+
163
+ function defaultSourceName(provider: SdkProvider): string {
164
+ if (provider === "apple") return "Apple Health";
165
+ if (provider === "google") return "Google Health Connect";
166
+ return "Samsung Health";
167
+ }
168
+
169
+ async function ensureDataSource(
170
+ ctx: ActionMutationRunner,
171
+ args: {
172
+ userId: string;
173
+ provider: SdkProvider;
174
+ connectionId: Id<"connections">;
175
+ },
176
+ cache: DataSourceCache,
177
+ metadata: SourceMetadata,
178
+ ): Promise<Id<"dataSources">> {
179
+ const key = sourceCacheKey(args.provider, metadata);
180
+ const cached = cache.get(key);
181
+ if (cached) return cached;
182
+
183
+ const dataSourceId = await ctx.runMutation(api.dataSources.getOrCreate, {
184
+ userId: args.userId,
185
+ provider: args.provider,
186
+ connectionId: args.connectionId,
187
+ deviceModel: metadata.deviceModel,
188
+ softwareVersion: metadata.softwareVersion,
189
+ source: metadata.source ?? args.provider,
190
+ deviceType: metadata.deviceType,
191
+ originalSourceName: metadata.originalSourceName,
192
+ });
193
+
194
+ cache.set(key, dataSourceId);
195
+ return dataSourceId;
196
+ }
197
+
198
+ export const ingestNormalizedPayload = action({
199
+ args: {
200
+ userId: v.string(),
201
+ provider: sdkProviderName,
202
+ providerUserId: v.optional(v.string()),
203
+ providerUsername: v.optional(v.string()),
204
+ syncTimestamp: v.optional(v.number()),
205
+ device: v.optional(deviceMetadataValidator),
206
+ sourceMetadata: v.optional(sourceMetadataValidator),
207
+ events: v.optional(v.array(sdkEventValidator)),
208
+ dataPoints: v.optional(v.array(sdkDataPointValidator)),
209
+ summaries: v.optional(v.array(sdkSummaryValidator)),
210
+ dailySummaries: v.optional(v.array(sdkSummaryValidator)),
211
+ },
212
+ returns: v.object({
213
+ connectionId: v.id("connections"),
214
+ eventsStored: v.number(),
215
+ dataPointsStored: v.number(),
216
+ summariesStored: v.number(),
217
+ }),
218
+ handler: async (ctx, args) => {
219
+ const connectionId = await ctx.runMutation(internal.connections.ensurePushConnection, {
220
+ userId: args.userId,
221
+ provider: args.provider,
222
+ providerUserId: args.providerUserId,
223
+ providerUsername: args.providerUsername,
224
+ });
225
+
226
+ const sourceCache: DataSourceCache = new Map();
227
+ const defaultMetadata = resolveSourceMetadata(
228
+ sourceMetadataFromDevice(args.device),
229
+ args.sourceMetadata ?? {},
230
+ );
231
+ const events = args.events ?? [];
232
+ const dataPoints = args.dataPoints ?? [];
233
+ const summaries = [...(args.summaries ?? []), ...(args.dailySummaries ?? [])];
234
+
235
+ assertPayloadWithinLimits({ events, dataPoints, summaries });
236
+
237
+ if (events.length > 0) {
238
+ const docs = [];
239
+ for (const event of events) {
240
+ const metadata = resolveSourceMetadata(defaultMetadata, {
241
+ deviceModel: event.deviceModel,
242
+ softwareVersion: event.softwareVersion,
243
+ source: event.source,
244
+ deviceType: event.deviceType,
245
+ originalSourceName: event.originalSourceName,
246
+ });
247
+ const dataSourceId = await ensureDataSource(
248
+ ctx,
249
+ {
250
+ userId: args.userId,
251
+ provider: args.provider,
252
+ connectionId,
253
+ },
254
+ sourceCache,
255
+ metadata,
256
+ );
257
+
258
+ docs.push({
259
+ dataSourceId,
260
+ userId: args.userId,
261
+ category: event.category,
262
+ type: event.type,
263
+ sourceName: event.sourceName ?? defaultSourceName(args.provider),
264
+ durationSeconds: event.durationSeconds,
265
+ startDatetime: event.startDatetime,
266
+ endDatetime: event.endDatetime,
267
+ externalId: event.externalId,
268
+ heartRateMin: event.heartRateMin,
269
+ heartRateMax: event.heartRateMax,
270
+ heartRateAvg: event.heartRateAvg,
271
+ energyBurned: event.energyBurned,
272
+ distance: event.distance,
273
+ stepsCount: event.stepsCount,
274
+ maxSpeed: event.maxSpeed,
275
+ maxWatts: event.maxWatts,
276
+ movingTimeSeconds: event.movingTimeSeconds,
277
+ totalElevationGain: event.totalElevationGain,
278
+ averageSpeed: event.averageSpeed,
279
+ averageWatts: event.averageWatts,
280
+ elevHigh: event.elevHigh,
281
+ elevLow: event.elevLow,
282
+ sleepTotalDurationMinutes: event.sleepTotalDurationMinutes,
283
+ sleepTimeInBedMinutes: event.sleepTimeInBedMinutes,
284
+ sleepEfficiencyScore: event.sleepEfficiencyScore,
285
+ sleepDeepMinutes: event.sleepDeepMinutes,
286
+ sleepRemMinutes: event.sleepRemMinutes,
287
+ sleepLightMinutes: event.sleepLightMinutes,
288
+ sleepAwakeMinutes: event.sleepAwakeMinutes,
289
+ isNap: event.isNap,
290
+ sleepStages: event.sleepStages,
291
+ });
292
+ }
293
+
294
+ for (const batch of chunk(docs, EVENT_BATCH_SIZE)) {
295
+ await ctx.runMutation(internal.events.storeEventBatch, {
296
+ events: batch,
297
+ });
298
+ }
299
+ }
300
+
301
+ if (dataPoints.length > 0) {
302
+ const grouped = new Map<
303
+ string,
304
+ {
305
+ dataSourceId: Id<"dataSources">;
306
+ seriesType: string;
307
+ points: Array<{
308
+ recordedAt: number;
309
+ value: number;
310
+ externalId?: string;
311
+ }>;
312
+ }
313
+ >();
314
+
315
+ for (const point of dataPoints) {
316
+ const seriesType = normalizeSeriesType(point.seriesType);
317
+ const metadata = resolveSourceMetadata(defaultMetadata, {
318
+ deviceModel: point.deviceModel,
319
+ softwareVersion: point.softwareVersion,
320
+ source: point.source,
321
+ deviceType: point.deviceType,
322
+ originalSourceName: point.originalSourceName,
323
+ });
324
+ const dataSourceId = await ensureDataSource(
325
+ ctx,
326
+ {
327
+ userId: args.userId,
328
+ provider: args.provider,
329
+ connectionId,
330
+ },
331
+ sourceCache,
332
+ metadata,
333
+ );
334
+
335
+ const key = `${dataSourceId}::${seriesType}`;
336
+ const group = grouped.get(key) ?? {
337
+ dataSourceId,
338
+ seriesType,
339
+ points: [],
340
+ };
341
+ group.points.push({
342
+ recordedAt: point.recordedAt,
343
+ value: point.value,
344
+ externalId: point.externalId,
345
+ });
346
+ grouped.set(key, group);
347
+ }
348
+
349
+ for (const group of grouped.values()) {
350
+ for (const batch of chunk(group.points, DATA_POINT_BATCH_SIZE)) {
351
+ await ctx.runMutation(internal.dataPoints.storeBatch, {
352
+ dataSourceId: group.dataSourceId,
353
+ seriesType: group.seriesType,
354
+ points: batch,
355
+ });
356
+ }
357
+ }
358
+ }
359
+
360
+ for (const summary of summaries) {
361
+ await ctx.runMutation(internal.summaries.upsert, {
362
+ userId: args.userId,
363
+ ...summary,
364
+ });
365
+ }
366
+
367
+ await ctx.runMutation(internal.connections.markSynced, {
368
+ connectionId,
369
+ });
370
+
371
+ return {
372
+ connectionId,
373
+ eventsStored: events.length,
374
+ dataPointsStored: dataPoints.length,
375
+ summariesStored: summaries.length,
376
+ };
377
+ },
378
+ });
379
+
380
+ function sourceMetadataFromDevice(
381
+ device:
382
+ | {
383
+ model?: string;
384
+ softwareVersion?: string;
385
+ source?: string;
386
+ deviceType?: string;
387
+ originalSourceName?: string;
388
+ }
389
+ | undefined,
390
+ ): SourceMetadata | undefined {
391
+ if (!device) return undefined;
392
+ return {
393
+ deviceModel: device.model,
394
+ softwareVersion: device.softwareVersion,
395
+ source: device.source,
396
+ deviceType: device.deviceType,
397
+ originalSourceName: device.originalSourceName,
398
+ };
399
+ }
400
+
401
+ function normalizeSeriesType(seriesType: string): string {
402
+ const normalized =
403
+ SERIES_TYPE_ALIASES[seriesType as keyof typeof SERIES_TYPE_ALIASES] ?? seriesType;
404
+ if (!validSeriesTypes.has(normalized)) {
405
+ throw new Error(`Unsupported series type "${seriesType}"`);
406
+ }
407
+ return normalized;
408
+ }
409
+
410
+ function assertPayloadWithinLimits(args: {
411
+ events: unknown[];
412
+ dataPoints: unknown[];
413
+ summaries: unknown[];
414
+ }) {
415
+ if (args.events.length > MAX_EVENTS_PER_REQUEST) {
416
+ throw new Error(
417
+ `SDK sync payload exceeds event limit (${args.events.length} > ${MAX_EVENTS_PER_REQUEST})`,
418
+ );
419
+ }
420
+
421
+ if (args.dataPoints.length > MAX_DATA_POINTS_PER_REQUEST) {
422
+ throw new Error(
423
+ `SDK sync payload exceeds data point limit (${args.dataPoints.length} > ${MAX_DATA_POINTS_PER_REQUEST})`,
424
+ );
425
+ }
426
+
427
+ if (args.summaries.length > MAX_SUMMARIES_PER_REQUEST) {
428
+ throw new Error(
429
+ `SDK sync payload exceeds summary limit (${args.summaries.length} > ${MAX_SUMMARIES_PER_REQUEST})`,
430
+ );
431
+ }
432
+ }
433
+
434
+ function chunk<T>(items: T[], size: number): T[][] {
435
+ const batches: T[][] = [];
436
+ for (let index = 0; index < items.length; index += size) {
437
+ batches.push(items.slice(index, index + size));
438
+ }
439
+ return batches;
440
+ }
@@ -0,0 +1,201 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it } from "vitest";
3
+ import schema from "./schema";
4
+ import { modules } from "./test.setup";
5
+
6
+ describe("summaries", () => {
7
+ describe("upsert", () => {
8
+ it("creates a new daily summary", async () => {
9
+ const t = convexTest(schema, modules);
10
+
11
+ await t.run(async (ctx) => {
12
+ await ctx.db.insert("dailySummaries", {
13
+ userId: "user-1",
14
+ date: "2026-03-15",
15
+ category: "activity",
16
+ totalSteps: 10000,
17
+ totalCalories: 2500,
18
+ activeMinutes: 45,
19
+ avgHeartRate: 72,
20
+ });
21
+ });
22
+
23
+ const summaries = await t.run(async (ctx) => {
24
+ return await ctx.db
25
+ .query("dailySummaries")
26
+ .withIndex("by_user_category_date", (idx) =>
27
+ idx
28
+ .eq("userId", "user-1")
29
+ .eq("category", "activity")
30
+ .gte("date", "2026-03-15")
31
+ .lte("date", "2026-03-15"),
32
+ )
33
+ .collect();
34
+ });
35
+
36
+ expect(summaries).toHaveLength(1);
37
+ expect(summaries[0]).toMatchObject({
38
+ totalSteps: 10000,
39
+ totalCalories: 2500,
40
+ activeMinutes: 45,
41
+ });
42
+ });
43
+
44
+ it("supports upsert pattern (find then patch or insert)", async () => {
45
+ const t = convexTest(schema, modules);
46
+
47
+ // First insert
48
+ await t.run(async (ctx) => {
49
+ await ctx.db.insert("dailySummaries", {
50
+ userId: "user-1",
51
+ date: "2026-03-15",
52
+ category: "activity",
53
+ totalSteps: 5000,
54
+ });
55
+ });
56
+
57
+ // Upsert: find existing, patch it
58
+ await t.run(async (ctx) => {
59
+ const existing = await ctx.db
60
+ .query("dailySummaries")
61
+ .withIndex("by_user_category_date", (idx) =>
62
+ idx.eq("userId", "user-1").eq("category", "activity").eq("date", "2026-03-15"),
63
+ )
64
+ .first();
65
+
66
+ if (existing) {
67
+ await ctx.db.patch(existing._id, { totalSteps: 10000, activeMinutes: 45 });
68
+ }
69
+ });
70
+
71
+ const summaries = await t.run(async (ctx) => {
72
+ return await ctx.db
73
+ .query("dailySummaries")
74
+ .withIndex("by_user_category_date", (idx) =>
75
+ idx.eq("userId", "user-1").eq("category", "activity"),
76
+ )
77
+ .collect();
78
+ });
79
+
80
+ expect(summaries).toHaveLength(1);
81
+ expect(summaries[0].totalSteps).toBe(10000);
82
+ expect(summaries[0].activeMinutes).toBe(45);
83
+ });
84
+ });
85
+
86
+ describe("getDailySummaries", () => {
87
+ it("returns summaries within date range", async () => {
88
+ const t = convexTest(schema, modules);
89
+
90
+ await t.run(async (ctx) => {
91
+ for (let day = 10; day <= 16; day++) {
92
+ await ctx.db.insert("dailySummaries", {
93
+ userId: "user-1",
94
+ date: `2026-03-${day}`,
95
+ category: "activity",
96
+ totalSteps: 8000 + day * 100,
97
+ });
98
+ }
99
+ });
100
+
101
+ const result = await t.run(async (ctx) => {
102
+ return await ctx.db
103
+ .query("dailySummaries")
104
+ .withIndex("by_user_category_date", (idx) =>
105
+ idx
106
+ .eq("userId", "user-1")
107
+ .eq("category", "activity")
108
+ .gte("date", "2026-03-12")
109
+ .lte("date", "2026-03-14"),
110
+ )
111
+ .collect();
112
+ });
113
+
114
+ expect(result).toHaveLength(3);
115
+ expect(result[0].date).toBe("2026-03-12");
116
+ expect(result[2].date).toBe("2026-03-14");
117
+ });
118
+
119
+ it("separates categories", async () => {
120
+ const t = convexTest(schema, modules);
121
+
122
+ await t.run(async (ctx) => {
123
+ await ctx.db.insert("dailySummaries", {
124
+ userId: "user-1",
125
+ date: "2026-03-15",
126
+ category: "activity",
127
+ totalSteps: 10000,
128
+ });
129
+ await ctx.db.insert("dailySummaries", {
130
+ userId: "user-1",
131
+ date: "2026-03-15",
132
+ category: "sleep",
133
+ sleepDurationMinutes: 480,
134
+ });
135
+ });
136
+
137
+ const activity = await t.run(async (ctx) => {
138
+ return await ctx.db
139
+ .query("dailySummaries")
140
+ .withIndex("by_user_category_date", (idx) =>
141
+ idx.eq("userId", "user-1").eq("category", "activity"),
142
+ )
143
+ .collect();
144
+ });
145
+ const sleep = await t.run(async (ctx) => {
146
+ return await ctx.db
147
+ .query("dailySummaries")
148
+ .withIndex("by_user_category_date", (idx) =>
149
+ idx.eq("userId", "user-1").eq("category", "sleep"),
150
+ )
151
+ .collect();
152
+ });
153
+
154
+ expect(activity).toHaveLength(1);
155
+ expect(activity[0].totalSteps).toBe(10000);
156
+ expect(sleep).toHaveLength(1);
157
+ expect(sleep[0].sleepDurationMinutes).toBe(480);
158
+ });
159
+ });
160
+
161
+ describe("getByUserDate", () => {
162
+ it("returns all categories for a date", async () => {
163
+ const t = convexTest(schema, modules);
164
+
165
+ await t.run(async (ctx) => {
166
+ await ctx.db.insert("dailySummaries", {
167
+ userId: "user-1",
168
+ date: "2026-03-15",
169
+ category: "activity",
170
+ totalSteps: 10000,
171
+ });
172
+ await ctx.db.insert("dailySummaries", {
173
+ userId: "user-1",
174
+ date: "2026-03-15",
175
+ category: "sleep",
176
+ sleepDurationMinutes: 480,
177
+ });
178
+ await ctx.db.insert("dailySummaries", {
179
+ userId: "user-1",
180
+ date: "2026-03-15",
181
+ category: "recovery",
182
+ restingHeartRate: 55,
183
+ hrvAvg: 65,
184
+ });
185
+ });
186
+
187
+ const all = await t.run(async (ctx) => {
188
+ return await ctx.db
189
+ .query("dailySummaries")
190
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-1").eq("date", "2026-03-15"))
191
+ .collect();
192
+ });
193
+
194
+ expect(all).toHaveLength(3);
195
+ const categories = all.map((s) => s.category);
196
+ expect(categories).toContain("activity");
197
+ expect(categories).toContain("sleep");
198
+ expect(categories).toContain("recovery");
199
+ });
200
+ });
201
+ });