@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,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
+ });