@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.
- package/dist/client/index.d.ts +9 -4
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +50 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/backfillJobs.d.ts +11 -11
- package/dist/component/connections.d.ts +9 -9
- package/dist/component/connections.d.ts.map +1 -1
- package/dist/component/connections.js +2 -0
- package/dist/component/connections.js.map +1 -1
- package/dist/component/dataPoints.d.ts +5 -5
- package/dist/component/events.d.ts +13 -13
- package/dist/component/garminBackfill.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +2 -0
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/lifecycle.d.ts +1 -1
- package/dist/component/lifecycle.d.ts.map +1 -1
- package/dist/component/lifecycle.js +2 -0
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/oauthStates.d.ts +3 -3
- package/dist/component/schema.d.ts +26 -26
- package/dist/component/sdkPush.d.ts +11 -11
- package/dist/component/summaries.d.ts +4 -4
- package/dist/component/syncJobs.d.ts +23 -23
- package/dist/component/syncWorkflow.d.ts +2 -2
- package/dist/test.d.ts +421 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +17 -0
- package/dist/test.js.map +1 -0
- package/package.json +12 -2
- package/src/client/_generated/_ignore.ts +2 -0
- package/src/client/index.test.ts +52 -0
- package/src/client/index.ts +784 -0
- package/src/client/types.ts +533 -0
- package/src/component/_generated/_ignore.ts +2 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +74 -0
- package/src/component/_generated/dataModel.ts +40 -0
- package/src/component/_generated/server.ts +48 -0
- package/src/component/backfillJobs.test.ts +47 -0
- package/src/component/backfillJobs.ts +245 -0
- package/src/component/connections.test.ts +297 -0
- package/src/component/connections.ts +329 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/dataPoints.test.ts +282 -0
- package/src/component/dataPoints.ts +305 -0
- package/src/component/dataSources.test.ts +247 -0
- package/src/component/dataSources.ts +109 -0
- package/src/component/events.test.ts +380 -0
- package/src/component/events.ts +288 -0
- package/src/component/garminBackfill.ts +343 -0
- package/src/component/garminWebhooks.test.ts +609 -0
- package/src/component/garminWebhooks.ts +656 -0
- package/src/component/httpHandlers.ts +153 -0
- package/src/component/lifecycle.test.ts +179 -0
- package/src/component/lifecycle.ts +87 -0
- package/src/component/menstrualCycles.ts +124 -0
- package/src/component/oauthActions.ts +261 -0
- package/src/component/oauthStates.test.ts +170 -0
- package/src/component/oauthStates.ts +85 -0
- package/src/component/providerSettings.ts +66 -0
- package/src/component/providers/additionalProviders.test.ts +401 -0
- package/src/component/providers/garmin.ts +1169 -0
- package/src/component/providers/oauth.test.ts +174 -0
- package/src/component/providers/oauth.ts +246 -0
- package/src/component/providers/polar.ts +220 -0
- package/src/component/providers/registry.ts +37 -0
- package/src/component/providers/strava.test.ts +195 -0
- package/src/component/providers/strava.ts +253 -0
- package/src/component/providers/suunto.ts +592 -0
- package/src/component/providers/types.ts +189 -0
- package/src/component/providers/whoop.ts +600 -0
- package/src/component/schema.ts +339 -0
- package/src/component/sdkPush.test.ts +367 -0
- package/src/component/sdkPush.ts +440 -0
- package/src/component/summaries.test.ts +201 -0
- package/src/component/summaries.ts +143 -0
- package/src/component/syncJobs.test.ts +254 -0
- package/src/component/syncJobs.ts +140 -0
- package/src/component/syncWorkflow.test.ts +87 -0
- package/src/component/syncWorkflow.ts +739 -0
- package/src/component/test.setup.ts +6 -0
- package/src/component/workflowManager.ts +19 -0
- package/src/test.ts +25 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { internal } from "./_generated/api";
|
|
4
|
+
import schema from "./schema";
|
|
5
|
+
import { modules } from "./test.setup";
|
|
6
|
+
|
|
7
|
+
async function seedDataSource(
|
|
8
|
+
t: ReturnType<typeof convexTest>,
|
|
9
|
+
userId = "user-1",
|
|
10
|
+
provider: "garmin" | "strava" = "garmin",
|
|
11
|
+
) {
|
|
12
|
+
return await t.run(async (ctx) => {
|
|
13
|
+
return await ctx.db.insert("dataSources", {
|
|
14
|
+
userId,
|
|
15
|
+
provider,
|
|
16
|
+
deviceModel: "Forerunner 965",
|
|
17
|
+
source: "garmin-api",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("events", () => {
|
|
23
|
+
describe("storeEvent", () => {
|
|
24
|
+
it("creates a new workout event", async () => {
|
|
25
|
+
const t = convexTest(schema, modules);
|
|
26
|
+
const dsId = await seedDataSource(t);
|
|
27
|
+
|
|
28
|
+
const eventId = await t.run(async (ctx) => {
|
|
29
|
+
return await ctx.db.insert("events", {
|
|
30
|
+
dataSourceId: dsId,
|
|
31
|
+
userId: "user-1",
|
|
32
|
+
category: "workout",
|
|
33
|
+
type: "running",
|
|
34
|
+
startDatetime: 1710000000000,
|
|
35
|
+
endDatetime: 1710003600000,
|
|
36
|
+
durationSeconds: 3600,
|
|
37
|
+
distance: 10000,
|
|
38
|
+
heartRateAvg: 145,
|
|
39
|
+
energyBurned: 750,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(eventId).toBeDefined();
|
|
44
|
+
|
|
45
|
+
const event = await t.run(async (ctx) => {
|
|
46
|
+
return await ctx.db.get(eventId);
|
|
47
|
+
});
|
|
48
|
+
expect(event).toMatchObject({
|
|
49
|
+
category: "workout",
|
|
50
|
+
type: "running",
|
|
51
|
+
distance: 10000,
|
|
52
|
+
heartRateAvg: 145,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("creates a sleep event with stages", async () => {
|
|
57
|
+
const t = convexTest(schema, modules);
|
|
58
|
+
const dsId = await seedDataSource(t);
|
|
59
|
+
|
|
60
|
+
const eventId = await t.run(async (ctx) => {
|
|
61
|
+
return await ctx.db.insert("events", {
|
|
62
|
+
dataSourceId: dsId,
|
|
63
|
+
userId: "user-1",
|
|
64
|
+
category: "sleep",
|
|
65
|
+
type: "night_sleep",
|
|
66
|
+
startDatetime: 1710010000000,
|
|
67
|
+
endDatetime: 1710040000000,
|
|
68
|
+
sleepTotalDurationMinutes: 480,
|
|
69
|
+
sleepDeepMinutes: 90,
|
|
70
|
+
sleepRemMinutes: 120,
|
|
71
|
+
sleepLightMinutes: 200,
|
|
72
|
+
sleepAwakeMinutes: 70,
|
|
73
|
+
sleepEfficiencyScore: 85.5,
|
|
74
|
+
sleepStages: [
|
|
75
|
+
{ stage: "light", startTime: 1710010000000, endTime: 1710015000000 },
|
|
76
|
+
{ stage: "deep", startTime: 1710015000000, endTime: 1710020000000 },
|
|
77
|
+
{ stage: "rem", startTime: 1710020000000, endTime: 1710025000000 },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const event = await t.run(async (ctx) => {
|
|
83
|
+
return await ctx.db.get(eventId);
|
|
84
|
+
});
|
|
85
|
+
expect(event?.sleepStages).toHaveLength(3);
|
|
86
|
+
expect(event?.sleepDeepMinutes).toBe(90);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("deduplicates by externalId via index lookup", async () => {
|
|
90
|
+
const t = convexTest(schema, modules);
|
|
91
|
+
const dsId = await seedDataSource(t);
|
|
92
|
+
|
|
93
|
+
// Insert first
|
|
94
|
+
const id1 = await t.run(async (ctx) => {
|
|
95
|
+
return await ctx.db.insert("events", {
|
|
96
|
+
dataSourceId: dsId,
|
|
97
|
+
userId: "user-1",
|
|
98
|
+
category: "workout",
|
|
99
|
+
type: "running",
|
|
100
|
+
startDatetime: 1710000000000,
|
|
101
|
+
externalId: "strava-123",
|
|
102
|
+
distance: 5000,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// "Upsert" — find by externalId, patch
|
|
107
|
+
await t.run(async (ctx) => {
|
|
108
|
+
const existing = await ctx.db
|
|
109
|
+
.query("events")
|
|
110
|
+
.withIndex("by_external_id", (idx) => idx.eq("externalId", "strava-123"))
|
|
111
|
+
.first();
|
|
112
|
+
if (existing) {
|
|
113
|
+
await ctx.db.patch(existing._id, { distance: 5500 });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const event = await t.run(async (ctx) => {
|
|
118
|
+
return await ctx.db.get(id1);
|
|
119
|
+
});
|
|
120
|
+
expect(event?.distance).toBe(5500);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("deduplicates batch writes by source + start + end when externalId is missing", async () => {
|
|
124
|
+
const t = convexTest(schema, modules);
|
|
125
|
+
const dsId = await seedDataSource(t);
|
|
126
|
+
|
|
127
|
+
await t.run(async (ctx) => {
|
|
128
|
+
await ctx.db.insert("events", {
|
|
129
|
+
dataSourceId: dsId,
|
|
130
|
+
userId: "user-1",
|
|
131
|
+
category: "workout",
|
|
132
|
+
type: "running",
|
|
133
|
+
startDatetime: 1710000000000,
|
|
134
|
+
endDatetime: 1710003600000,
|
|
135
|
+
distance: 5000,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await t.mutation(internal.events.storeEventBatch, {
|
|
140
|
+
events: [
|
|
141
|
+
{
|
|
142
|
+
dataSourceId: dsId,
|
|
143
|
+
userId: "user-1",
|
|
144
|
+
category: "workout",
|
|
145
|
+
type: "running",
|
|
146
|
+
startDatetime: 1710000000000,
|
|
147
|
+
endDatetime: 1710003600000,
|
|
148
|
+
distance: 5500,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const events = await t.run(async (ctx) => {
|
|
154
|
+
return await ctx.db
|
|
155
|
+
.query("events")
|
|
156
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
157
|
+
idx.eq("userId", "user-1").eq("category", "workout"),
|
|
158
|
+
)
|
|
159
|
+
.collect();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(events).toHaveLength(1);
|
|
163
|
+
expect(events[0].distance).toBe(5500);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("getEvents", () => {
|
|
168
|
+
it("returns events filtered by category via index", async () => {
|
|
169
|
+
const t = convexTest(schema, modules);
|
|
170
|
+
const dsId = await seedDataSource(t);
|
|
171
|
+
|
|
172
|
+
await t.run(async (ctx) => {
|
|
173
|
+
await ctx.db.insert("events", {
|
|
174
|
+
dataSourceId: dsId,
|
|
175
|
+
userId: "user-1",
|
|
176
|
+
category: "workout",
|
|
177
|
+
type: "running",
|
|
178
|
+
startDatetime: 1710000000000,
|
|
179
|
+
});
|
|
180
|
+
await ctx.db.insert("events", {
|
|
181
|
+
dataSourceId: dsId,
|
|
182
|
+
userId: "user-1",
|
|
183
|
+
category: "workout",
|
|
184
|
+
type: "cycling",
|
|
185
|
+
startDatetime: 1710100000000,
|
|
186
|
+
});
|
|
187
|
+
await ctx.db.insert("events", {
|
|
188
|
+
dataSourceId: dsId,
|
|
189
|
+
userId: "user-1",
|
|
190
|
+
category: "sleep",
|
|
191
|
+
type: "night_sleep",
|
|
192
|
+
startDatetime: 1710050000000,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const workouts = await t.run(async (ctx) => {
|
|
197
|
+
return await ctx.db
|
|
198
|
+
.query("events")
|
|
199
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
200
|
+
idx.eq("userId", "user-1").eq("category", "workout"),
|
|
201
|
+
)
|
|
202
|
+
.collect();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(workouts).toHaveLength(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("filters by date range", async () => {
|
|
209
|
+
const t = convexTest(schema, modules);
|
|
210
|
+
const dsId = await seedDataSource(t);
|
|
211
|
+
|
|
212
|
+
await t.run(async (ctx) => {
|
|
213
|
+
await ctx.db.insert("events", {
|
|
214
|
+
dataSourceId: dsId,
|
|
215
|
+
userId: "user-1",
|
|
216
|
+
category: "workout",
|
|
217
|
+
type: "running",
|
|
218
|
+
startDatetime: 1710000000000,
|
|
219
|
+
});
|
|
220
|
+
await ctx.db.insert("events", {
|
|
221
|
+
dataSourceId: dsId,
|
|
222
|
+
userId: "user-1",
|
|
223
|
+
category: "workout",
|
|
224
|
+
type: "cycling",
|
|
225
|
+
startDatetime: 1710100000000,
|
|
226
|
+
});
|
|
227
|
+
await ctx.db.insert("events", {
|
|
228
|
+
dataSourceId: dsId,
|
|
229
|
+
userId: "user-1",
|
|
230
|
+
category: "workout",
|
|
231
|
+
type: "swimming",
|
|
232
|
+
startDatetime: 1710200000000,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const filtered = await t.run(async (ctx) => {
|
|
237
|
+
return await ctx.db
|
|
238
|
+
.query("events")
|
|
239
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
240
|
+
idx
|
|
241
|
+
.eq("userId", "user-1")
|
|
242
|
+
.eq("category", "workout")
|
|
243
|
+
.gte("startDatetime", 1710050000000)
|
|
244
|
+
.lte("startDatetime", 1710150000000),
|
|
245
|
+
)
|
|
246
|
+
.collect();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(filtered).toHaveLength(1);
|
|
250
|
+
expect(filtered[0].type).toBe("cycling");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("paginates results with take()", async () => {
|
|
254
|
+
const t = convexTest(schema, modules);
|
|
255
|
+
const dsId = await seedDataSource(t);
|
|
256
|
+
|
|
257
|
+
await t.run(async (ctx) => {
|
|
258
|
+
for (let i = 0; i < 5; i++) {
|
|
259
|
+
await ctx.db.insert("events", {
|
|
260
|
+
dataSourceId: dsId,
|
|
261
|
+
userId: "user-1",
|
|
262
|
+
category: "workout",
|
|
263
|
+
type: `run-${i}`,
|
|
264
|
+
startDatetime: 1710000000000 + i * 100000,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const page1 = await t.run(async (ctx) => {
|
|
270
|
+
return await ctx.db
|
|
271
|
+
.query("events")
|
|
272
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
273
|
+
idx.eq("userId", "user-1").eq("category", "workout"),
|
|
274
|
+
)
|
|
275
|
+
.order("desc")
|
|
276
|
+
.take(3);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(page1).toHaveLength(3);
|
|
280
|
+
// Most recent first
|
|
281
|
+
expect(page1[0].type).toBe("run-4");
|
|
282
|
+
expect(page1[2].type).toBe("run-2");
|
|
283
|
+
|
|
284
|
+
// Next page: events before the last one in page1
|
|
285
|
+
const page2 = await t.run(async (ctx) => {
|
|
286
|
+
return await ctx.db
|
|
287
|
+
.query("events")
|
|
288
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
289
|
+
idx
|
|
290
|
+
.eq("userId", "user-1")
|
|
291
|
+
.eq("category", "workout")
|
|
292
|
+
.lt("startDatetime", page1[2].startDatetime),
|
|
293
|
+
)
|
|
294
|
+
.order("desc")
|
|
295
|
+
.take(3);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(page2).toHaveLength(2);
|
|
299
|
+
expect(page2[0].type).toBe("run-1");
|
|
300
|
+
expect(page2[1].type).toBe("run-0");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("isolates data between users", async () => {
|
|
304
|
+
const t = convexTest(schema, modules);
|
|
305
|
+
const ds1 = await seedDataSource(t, "user-1");
|
|
306
|
+
const ds2 = await seedDataSource(t, "user-2", "strava");
|
|
307
|
+
|
|
308
|
+
await t.run(async (ctx) => {
|
|
309
|
+
await ctx.db.insert("events", {
|
|
310
|
+
dataSourceId: ds1,
|
|
311
|
+
userId: "user-1",
|
|
312
|
+
category: "workout",
|
|
313
|
+
type: "running",
|
|
314
|
+
startDatetime: 1710000000000,
|
|
315
|
+
});
|
|
316
|
+
await ctx.db.insert("events", {
|
|
317
|
+
dataSourceId: ds2,
|
|
318
|
+
userId: "user-2",
|
|
319
|
+
category: "workout",
|
|
320
|
+
type: "cycling",
|
|
321
|
+
startDatetime: 1710000000000,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const user1 = await t.run(async (ctx) => {
|
|
326
|
+
return await ctx.db
|
|
327
|
+
.query("events")
|
|
328
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
329
|
+
idx.eq("userId", "user-1").eq("category", "workout"),
|
|
330
|
+
)
|
|
331
|
+
.collect();
|
|
332
|
+
});
|
|
333
|
+
const user2 = await t.run(async (ctx) => {
|
|
334
|
+
return await ctx.db
|
|
335
|
+
.query("events")
|
|
336
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
337
|
+
idx.eq("userId", "user-2").eq("category", "workout"),
|
|
338
|
+
)
|
|
339
|
+
.collect();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(user1).toHaveLength(1);
|
|
343
|
+
expect(user1[0].type).toBe("running");
|
|
344
|
+
expect(user2).toHaveLength(1);
|
|
345
|
+
expect(user2[0].type).toBe("cycling");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("deduplicates by source + start + end via index", async () => {
|
|
349
|
+
const t = convexTest(schema, modules);
|
|
350
|
+
const dsId = await seedDataSource(t);
|
|
351
|
+
|
|
352
|
+
await t.run(async (ctx) => {
|
|
353
|
+
await ctx.db.insert("events", {
|
|
354
|
+
dataSourceId: dsId,
|
|
355
|
+
userId: "user-1",
|
|
356
|
+
category: "workout",
|
|
357
|
+
type: "cycling",
|
|
358
|
+
startDatetime: 1710000000000,
|
|
359
|
+
endDatetime: 1710003600000,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Check if duplicate exists before inserting
|
|
364
|
+
const exists = await t.run(async (ctx) => {
|
|
365
|
+
return await ctx.db
|
|
366
|
+
.query("events")
|
|
367
|
+
.withIndex("by_source_start_end", (idx) =>
|
|
368
|
+
idx
|
|
369
|
+
.eq("dataSourceId", dsId)
|
|
370
|
+
.eq("startDatetime", 1710000000000)
|
|
371
|
+
.eq("endDatetime", 1710003600000),
|
|
372
|
+
)
|
|
373
|
+
.first();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(exists).not.toBeNull();
|
|
377
|
+
expect(exists?.type).toBe("cycling");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { Id } from "./_generated/dataModel";
|
|
3
|
+
import { internalMutation, internalQuery, query } from "./_generated/server";
|
|
4
|
+
import { eventCategory } from "./schema";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Queries
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get events (workouts or sleep) for a user with cursor-based pagination.
|
|
12
|
+
*/
|
|
13
|
+
export const getEvents = query({
|
|
14
|
+
args: {
|
|
15
|
+
userId: v.string(),
|
|
16
|
+
category: eventCategory,
|
|
17
|
+
startDate: v.optional(v.number()),
|
|
18
|
+
endDate: v.optional(v.number()),
|
|
19
|
+
limit: v.optional(v.number()),
|
|
20
|
+
cursor: v.optional(v.string()),
|
|
21
|
+
},
|
|
22
|
+
returns: v.object({
|
|
23
|
+
events: v.array(v.any()),
|
|
24
|
+
nextCursor: v.union(v.string(), v.null()),
|
|
25
|
+
hasMore: v.boolean(),
|
|
26
|
+
}),
|
|
27
|
+
handler: async (ctx, args) => {
|
|
28
|
+
const limit = Math.min(args.limit ?? 20, 100);
|
|
29
|
+
|
|
30
|
+
const buildQuery = () => {
|
|
31
|
+
if (args.cursor) {
|
|
32
|
+
const cursorTime = Number(args.cursor);
|
|
33
|
+
return ctx.db
|
|
34
|
+
.query("events")
|
|
35
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
36
|
+
idx
|
|
37
|
+
.eq("userId", args.userId)
|
|
38
|
+
.eq("category", args.category)
|
|
39
|
+
.lt("startDatetime", cursorTime),
|
|
40
|
+
)
|
|
41
|
+
.order("desc");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (args.startDate !== undefined && args.endDate !== undefined) {
|
|
45
|
+
const { startDate, endDate } = args;
|
|
46
|
+
return ctx.db
|
|
47
|
+
.query("events")
|
|
48
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
49
|
+
idx
|
|
50
|
+
.eq("userId", args.userId)
|
|
51
|
+
.eq("category", args.category)
|
|
52
|
+
.gte("startDatetime", startDate)
|
|
53
|
+
.lte("startDatetime", endDate),
|
|
54
|
+
)
|
|
55
|
+
.order("desc");
|
|
56
|
+
}
|
|
57
|
+
if (args.startDate !== undefined) {
|
|
58
|
+
const { startDate } = args;
|
|
59
|
+
return ctx.db
|
|
60
|
+
.query("events")
|
|
61
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
62
|
+
idx
|
|
63
|
+
.eq("userId", args.userId)
|
|
64
|
+
.eq("category", args.category)
|
|
65
|
+
.gte("startDatetime", startDate),
|
|
66
|
+
)
|
|
67
|
+
.order("desc");
|
|
68
|
+
}
|
|
69
|
+
if (args.endDate !== undefined) {
|
|
70
|
+
const { endDate } = args;
|
|
71
|
+
return ctx.db
|
|
72
|
+
.query("events")
|
|
73
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
74
|
+
idx
|
|
75
|
+
.eq("userId", args.userId)
|
|
76
|
+
.eq("category", args.category)
|
|
77
|
+
.lte("startDatetime", endDate),
|
|
78
|
+
)
|
|
79
|
+
.order("desc");
|
|
80
|
+
}
|
|
81
|
+
return ctx.db
|
|
82
|
+
.query("events")
|
|
83
|
+
.withIndex("by_user_category_time", (idx) =>
|
|
84
|
+
idx.eq("userId", args.userId).eq("category", args.category),
|
|
85
|
+
)
|
|
86
|
+
.order("desc");
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const q = buildQuery();
|
|
90
|
+
|
|
91
|
+
const results = await q.take(limit + 1);
|
|
92
|
+
const hasMore = results.length > limit;
|
|
93
|
+
const events = hasMore ? results.slice(0, limit) : results;
|
|
94
|
+
const nextCursor =
|
|
95
|
+
hasMore && events.length > 0 ? String(events[events.length - 1].startDatetime) : null;
|
|
96
|
+
|
|
97
|
+
return { events, nextCursor, hasMore };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get a single event by its ID.
|
|
103
|
+
*/
|
|
104
|
+
export const getEvent = query({
|
|
105
|
+
args: { eventId: v.id("events") },
|
|
106
|
+
returns: v.any(),
|
|
107
|
+
handler: async (ctx, args) => {
|
|
108
|
+
return await ctx.db.get(args.eventId);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get an event by its external ID (for deduplication checks).
|
|
114
|
+
*/
|
|
115
|
+
export const getByExternalId = internalQuery({
|
|
116
|
+
args: { externalId: v.string() },
|
|
117
|
+
returns: v.any(),
|
|
118
|
+
handler: async (ctx, args) => {
|
|
119
|
+
return await ctx.db
|
|
120
|
+
.query("events")
|
|
121
|
+
.withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
|
|
122
|
+
.first();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Mutations
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Store a single event (workout or sleep). Deduplicates by externalId if provided.
|
|
132
|
+
*/
|
|
133
|
+
export const storeEvent = internalMutation({
|
|
134
|
+
args: {
|
|
135
|
+
dataSourceId: v.id("dataSources"),
|
|
136
|
+
userId: v.string(),
|
|
137
|
+
category: eventCategory,
|
|
138
|
+
type: v.optional(v.string()),
|
|
139
|
+
sourceName: v.optional(v.string()),
|
|
140
|
+
durationSeconds: v.optional(v.number()),
|
|
141
|
+
startDatetime: v.number(),
|
|
142
|
+
endDatetime: v.optional(v.number()),
|
|
143
|
+
externalId: v.optional(v.string()),
|
|
144
|
+
// Workout fields
|
|
145
|
+
heartRateMin: v.optional(v.number()),
|
|
146
|
+
heartRateMax: v.optional(v.number()),
|
|
147
|
+
heartRateAvg: v.optional(v.number()),
|
|
148
|
+
energyBurned: v.optional(v.number()),
|
|
149
|
+
distance: v.optional(v.number()),
|
|
150
|
+
stepsCount: v.optional(v.number()),
|
|
151
|
+
maxSpeed: v.optional(v.number()),
|
|
152
|
+
maxWatts: v.optional(v.number()),
|
|
153
|
+
movingTimeSeconds: v.optional(v.number()),
|
|
154
|
+
totalElevationGain: v.optional(v.number()),
|
|
155
|
+
averageSpeed: v.optional(v.number()),
|
|
156
|
+
averageWatts: v.optional(v.number()),
|
|
157
|
+
elevHigh: v.optional(v.number()),
|
|
158
|
+
elevLow: v.optional(v.number()),
|
|
159
|
+
// Sleep fields
|
|
160
|
+
sleepTotalDurationMinutes: v.optional(v.number()),
|
|
161
|
+
sleepTimeInBedMinutes: v.optional(v.number()),
|
|
162
|
+
sleepEfficiencyScore: v.optional(v.number()),
|
|
163
|
+
sleepDeepMinutes: v.optional(v.number()),
|
|
164
|
+
sleepRemMinutes: v.optional(v.number()),
|
|
165
|
+
sleepLightMinutes: v.optional(v.number()),
|
|
166
|
+
sleepAwakeMinutes: v.optional(v.number()),
|
|
167
|
+
isNap: v.optional(v.boolean()),
|
|
168
|
+
sleepStages: v.optional(
|
|
169
|
+
v.array(
|
|
170
|
+
v.object({
|
|
171
|
+
stage: v.string(),
|
|
172
|
+
startTime: v.number(),
|
|
173
|
+
endTime: v.number(),
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
returns: v.id("events"),
|
|
179
|
+
handler: async (ctx, args) => {
|
|
180
|
+
// Deduplicate by externalId
|
|
181
|
+
if (args.externalId) {
|
|
182
|
+
const existing = await ctx.db
|
|
183
|
+
.query("events")
|
|
184
|
+
.withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
|
|
185
|
+
.first();
|
|
186
|
+
if (existing) {
|
|
187
|
+
// Update existing record
|
|
188
|
+
await ctx.db.patch(existing._id, args);
|
|
189
|
+
return existing._id;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Deduplicate by source + start + end
|
|
194
|
+
const existing = await ctx.db
|
|
195
|
+
.query("events")
|
|
196
|
+
.withIndex("by_source_start_end", (idx) =>
|
|
197
|
+
idx
|
|
198
|
+
.eq("dataSourceId", args.dataSourceId)
|
|
199
|
+
.eq("startDatetime", args.startDatetime)
|
|
200
|
+
.eq("endDatetime", args.endDatetime ?? undefined),
|
|
201
|
+
)
|
|
202
|
+
.first();
|
|
203
|
+
|
|
204
|
+
if (existing) {
|
|
205
|
+
await ctx.db.patch(existing._id, args);
|
|
206
|
+
return existing._id;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return await ctx.db.insert("events", args);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Store a batch of events. Used by sync workflows to write multiple events
|
|
215
|
+
* in a single mutation (must complete within 1 second).
|
|
216
|
+
*/
|
|
217
|
+
export const storeEventBatch = internalMutation({
|
|
218
|
+
args: {
|
|
219
|
+
events: v.array(v.any()),
|
|
220
|
+
},
|
|
221
|
+
returns: v.array(v.id("events")),
|
|
222
|
+
handler: async (ctx, args) => {
|
|
223
|
+
const ids: Id<"events">[] = [];
|
|
224
|
+
for (const event of args.events) {
|
|
225
|
+
// Deduplicate by externalId
|
|
226
|
+
if (event.externalId) {
|
|
227
|
+
const existing = await ctx.db
|
|
228
|
+
.query("events")
|
|
229
|
+
.withIndex("by_external_id", (idx) => idx.eq("externalId", event.externalId))
|
|
230
|
+
.first();
|
|
231
|
+
if (existing) {
|
|
232
|
+
await ctx.db.patch(existing._id, event);
|
|
233
|
+
ids.push(existing._id);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const existing = await ctx.db
|
|
238
|
+
.query("events")
|
|
239
|
+
.withIndex("by_source_start_end", (idx) =>
|
|
240
|
+
idx
|
|
241
|
+
.eq("dataSourceId", event.dataSourceId)
|
|
242
|
+
.eq("startDatetime", event.startDatetime)
|
|
243
|
+
.eq("endDatetime", event.endDatetime ?? undefined),
|
|
244
|
+
)
|
|
245
|
+
.first();
|
|
246
|
+
if (existing) {
|
|
247
|
+
await ctx.db.patch(existing._id, event);
|
|
248
|
+
ids.push(existing._id);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const id = await ctx.db.insert("events", event);
|
|
252
|
+
ids.push(id);
|
|
253
|
+
}
|
|
254
|
+
return ids;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export const deleteByExternalId = internalMutation({
|
|
259
|
+
args: {
|
|
260
|
+
externalId: v.string(),
|
|
261
|
+
},
|
|
262
|
+
handler: async (ctx, args) => {
|
|
263
|
+
const event = await ctx.db
|
|
264
|
+
.query("events")
|
|
265
|
+
.withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
|
|
266
|
+
.first();
|
|
267
|
+
|
|
268
|
+
if (event) {
|
|
269
|
+
await ctx.db.delete(event._id);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Delete all events for a user. Used for GDPR/account deletion.
|
|
276
|
+
*/
|
|
277
|
+
export const deleteUserEvents = internalMutation({
|
|
278
|
+
args: { userId: v.string() },
|
|
279
|
+
handler: async (ctx, args) => {
|
|
280
|
+
const events = await ctx.db
|
|
281
|
+
.query("events")
|
|
282
|
+
.withIndex("by_user_category_time", (idx) => idx.eq("userId", args.userId))
|
|
283
|
+
.collect();
|
|
284
|
+
for (const event of events) {
|
|
285
|
+
await ctx.db.delete(event._id);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
});
|