@clipin/convex-wearables 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +6 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/component/dataPoints.d.ts +1 -0
- package/dist/component/dataPoints.d.ts.map +1 -1
- package/dist/component/dataPoints.js +140 -71
- package/dist/component/dataPoints.js.map +1 -1
- package/dist/component/garminWebhooks.js +3 -1
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/providers/types.d.ts +2 -0
- package/dist/component/providers/types.d.ts.map +1 -1
- package/dist/component/schema.d.ts +11 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +6 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sdkPush.d.ts +4 -0
- package/dist/component/sdkPush.d.ts.map +1 -1
- package/dist/component/sdkPush.js +5 -0
- package/dist/component/sdkPush.js.map +1 -1
- package/dist/component/summaries.d.ts +16 -1
- package/dist/component/summaries.d.ts.map +1 -1
- package/dist/component/summaries.js +72 -38
- package/dist/component/summaries.js.map +1 -1
- package/dist/component/syncWorkflow.d.ts.map +1 -1
- package/dist/component/syncWorkflow.js +2 -0
- package/dist/component/syncWorkflow.js.map +1 -1
- package/dist/test.d.ts +11 -1
- package/dist/test.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +5 -0
- package/src/client/types.ts +6 -0
- package/src/component/dataPoints.test.ts +214 -0
- package/src/component/dataPoints.ts +176 -79
- package/src/component/garminWebhooks.test.ts +2 -0
- package/src/component/garminWebhooks.ts +3 -1
- package/src/component/providers/types.ts +2 -0
- package/src/component/schema.ts +6 -0
- package/src/component/sdkPush.test.ts +83 -0
- package/src/component/sdkPush.ts +5 -0
- package/src/component/summaries.test.ts +99 -0
- package/src/component/summaries.ts +89 -39
- package/src/component/syncWorkflow.ts +2 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { convexTest } from "convex-test";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { api, internal } from "./_generated/api";
|
|
3
4
|
import schema from "./schema";
|
|
4
5
|
import { modules } from "./test.setup";
|
|
5
6
|
|
|
@@ -81,6 +82,62 @@ describe("summaries", () => {
|
|
|
81
82
|
expect(summaries[0].totalSteps).toBe(10000);
|
|
82
83
|
expect(summaries[0].activeMinutes).toBe(45);
|
|
83
84
|
});
|
|
85
|
+
|
|
86
|
+
it("upserts summaries by user provider category and date", async () => {
|
|
87
|
+
const t = convexTest(schema, modules);
|
|
88
|
+
|
|
89
|
+
await t.mutation(internal.summaries.upsert, {
|
|
90
|
+
userId: "user-1",
|
|
91
|
+
provider: "garmin",
|
|
92
|
+
date: "2026-03-15",
|
|
93
|
+
category: "activity",
|
|
94
|
+
totalSteps: 10000,
|
|
95
|
+
activeCalories: 600,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await t.mutation(internal.summaries.upsert, {
|
|
99
|
+
userId: "user-1",
|
|
100
|
+
provider: "apple",
|
|
101
|
+
date: "2026-03-15",
|
|
102
|
+
category: "activity",
|
|
103
|
+
source: "healthkit",
|
|
104
|
+
originalSourceName: "Apple Watch",
|
|
105
|
+
totalSteps: 8500,
|
|
106
|
+
activeCalories: 430,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await t.mutation(internal.summaries.upsert, {
|
|
110
|
+
userId: "user-1",
|
|
111
|
+
provider: "garmin",
|
|
112
|
+
date: "2026-03-15",
|
|
113
|
+
category: "activity",
|
|
114
|
+
totalSteps: 11000,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const summaries = await t.run(async (ctx) => {
|
|
118
|
+
return await ctx.db
|
|
119
|
+
.query("dailySummaries")
|
|
120
|
+
.withIndex("by_user_category_date", (idx) =>
|
|
121
|
+
idx.eq("userId", "user-1").eq("category", "activity").eq("date", "2026-03-15"),
|
|
122
|
+
)
|
|
123
|
+
.collect();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(summaries).toHaveLength(2);
|
|
127
|
+
const garmin = summaries.find((summary) => summary.provider === "garmin");
|
|
128
|
+
const apple = summaries.find((summary) => summary.provider === "apple");
|
|
129
|
+
|
|
130
|
+
expect(garmin).toMatchObject({
|
|
131
|
+
totalSteps: 11000,
|
|
132
|
+
activeCalories: 600,
|
|
133
|
+
});
|
|
134
|
+
expect(apple).toMatchObject({
|
|
135
|
+
source: "healthkit",
|
|
136
|
+
originalSourceName: "Apple Watch",
|
|
137
|
+
totalSteps: 8500,
|
|
138
|
+
activeCalories: 430,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
84
141
|
});
|
|
85
142
|
|
|
86
143
|
describe("getDailySummaries", () => {
|
|
@@ -156,6 +213,48 @@ describe("summaries", () => {
|
|
|
156
213
|
expect(sleep).toHaveLength(1);
|
|
157
214
|
expect(sleep[0].sleepDurationMinutes).toBe(480);
|
|
158
215
|
});
|
|
216
|
+
|
|
217
|
+
it("filters summaries by provider when requested", async () => {
|
|
218
|
+
const t = convexTest(schema, modules);
|
|
219
|
+
|
|
220
|
+
await t.mutation(internal.summaries.upsert, {
|
|
221
|
+
userId: "user-1",
|
|
222
|
+
provider: "garmin",
|
|
223
|
+
date: "2026-03-15",
|
|
224
|
+
category: "activity",
|
|
225
|
+
totalSteps: 10000,
|
|
226
|
+
});
|
|
227
|
+
await t.mutation(internal.summaries.upsert, {
|
|
228
|
+
userId: "user-1",
|
|
229
|
+
provider: "google",
|
|
230
|
+
date: "2026-03-15",
|
|
231
|
+
category: "activity",
|
|
232
|
+
source: "health-connect",
|
|
233
|
+
totalSteps: 9200,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const google = await t.query(api.summaries.getDailySummaries, {
|
|
237
|
+
userId: "user-1",
|
|
238
|
+
provider: "google",
|
|
239
|
+
category: "activity",
|
|
240
|
+
startDate: "2026-03-15",
|
|
241
|
+
endDate: "2026-03-15",
|
|
242
|
+
});
|
|
243
|
+
const mixed = await t.query(api.summaries.getDailySummaries, {
|
|
244
|
+
userId: "user-1",
|
|
245
|
+
category: "activity",
|
|
246
|
+
startDate: "2026-03-15",
|
|
247
|
+
endDate: "2026-03-15",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(google).toHaveLength(1);
|
|
251
|
+
expect(google[0]).toMatchObject({
|
|
252
|
+
provider: "google",
|
|
253
|
+
source: "health-connect",
|
|
254
|
+
totalSteps: 9200,
|
|
255
|
+
});
|
|
256
|
+
expect(mixed).toHaveLength(2);
|
|
257
|
+
});
|
|
159
258
|
});
|
|
160
259
|
|
|
161
260
|
describe("getByUserDate", () => {
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { internalMutation, internalQuery, query } from "./_generated/server";
|
|
3
|
+
import { providerName } from "./schema";
|
|
4
|
+
|
|
5
|
+
const summaryMetricsValidator = {
|
|
6
|
+
totalSteps: v.optional(v.number()),
|
|
7
|
+
totalCalories: v.optional(v.number()),
|
|
8
|
+
activeCalories: v.optional(v.number()),
|
|
9
|
+
activeMinutes: v.optional(v.number()),
|
|
10
|
+
totalDistance: v.optional(v.number()),
|
|
11
|
+
floorsClimbed: v.optional(v.number()),
|
|
12
|
+
avgHeartRate: v.optional(v.number()),
|
|
13
|
+
maxHeartRate: v.optional(v.number()),
|
|
14
|
+
minHeartRate: v.optional(v.number()),
|
|
15
|
+
sleepDurationMinutes: v.optional(v.number()),
|
|
16
|
+
sleepEfficiency: v.optional(v.number()),
|
|
17
|
+
deepSleepMinutes: v.optional(v.number()),
|
|
18
|
+
remSleepMinutes: v.optional(v.number()),
|
|
19
|
+
lightSleepMinutes: v.optional(v.number()),
|
|
20
|
+
awakeDuringMinutes: v.optional(v.number()),
|
|
21
|
+
timeInBedMinutes: v.optional(v.number()),
|
|
22
|
+
hrvAvg: v.optional(v.number()),
|
|
23
|
+
hrvRmssd: v.optional(v.number()),
|
|
24
|
+
restingHeartRate: v.optional(v.number()),
|
|
25
|
+
recoveryScore: v.optional(v.number()),
|
|
26
|
+
weight: v.optional(v.number()),
|
|
27
|
+
bodyFatPercentage: v.optional(v.number()),
|
|
28
|
+
bodyMassIndex: v.optional(v.number()),
|
|
29
|
+
leanBodyMass: v.optional(v.number()),
|
|
30
|
+
bodyTemperature: v.optional(v.number()),
|
|
31
|
+
avgStressLevel: v.optional(v.number()),
|
|
32
|
+
bodyBattery: v.optional(v.number()),
|
|
33
|
+
spo2Avg: v.optional(v.number()),
|
|
34
|
+
};
|
|
3
35
|
|
|
4
36
|
// ---------------------------------------------------------------------------
|
|
5
37
|
// Queries
|
|
@@ -7,17 +39,33 @@ import { internalMutation, internalQuery, query } from "./_generated/server";
|
|
|
7
39
|
|
|
8
40
|
/**
|
|
9
41
|
* Get daily summaries for a user by category and date range.
|
|
10
|
-
*
|
|
42
|
+
* When provider is omitted this returns provider-mixed storage rows and is not
|
|
43
|
+
* a canonical product view for multi-provider apps.
|
|
11
44
|
*/
|
|
12
45
|
export const getDailySummaries = query({
|
|
13
46
|
args: {
|
|
14
47
|
userId: v.string(),
|
|
48
|
+
provider: v.optional(providerName),
|
|
15
49
|
category: v.string(),
|
|
16
50
|
startDate: v.string(), // "2026-03-01"
|
|
17
51
|
endDate: v.string(), // "2026-03-15"
|
|
18
52
|
},
|
|
19
53
|
returns: v.array(v.any()),
|
|
20
54
|
handler: async (ctx, args) => {
|
|
55
|
+
if (args.provider !== undefined) {
|
|
56
|
+
return await ctx.db
|
|
57
|
+
.query("dailySummaries")
|
|
58
|
+
.withIndex("by_user_provider_category_date", (idx) =>
|
|
59
|
+
idx
|
|
60
|
+
.eq("userId", args.userId)
|
|
61
|
+
.eq("provider", args.provider)
|
|
62
|
+
.eq("category", args.category)
|
|
63
|
+
.gte("date", args.startDate)
|
|
64
|
+
.lte("date", args.endDate),
|
|
65
|
+
)
|
|
66
|
+
.collect();
|
|
67
|
+
}
|
|
68
|
+
|
|
21
69
|
return await ctx.db
|
|
22
70
|
.query("dailySummaries")
|
|
23
71
|
.withIndex("by_user_category_date", (idx) =>
|
|
@@ -37,10 +85,20 @@ export const getDailySummaries = query({
|
|
|
37
85
|
export const getByUserDate = internalQuery({
|
|
38
86
|
args: {
|
|
39
87
|
userId: v.string(),
|
|
88
|
+
provider: v.optional(providerName),
|
|
40
89
|
date: v.string(),
|
|
41
90
|
},
|
|
42
91
|
returns: v.array(v.any()),
|
|
43
92
|
handler: async (ctx, args) => {
|
|
93
|
+
if (args.provider !== undefined) {
|
|
94
|
+
return await ctx.db
|
|
95
|
+
.query("dailySummaries")
|
|
96
|
+
.withIndex("by_user_provider_date", (idx) =>
|
|
97
|
+
idx.eq("userId", args.userId).eq("provider", args.provider).eq("date", args.date),
|
|
98
|
+
)
|
|
99
|
+
.collect();
|
|
100
|
+
}
|
|
101
|
+
|
|
44
102
|
return await ctx.db
|
|
45
103
|
.query("dailySummaries")
|
|
46
104
|
.withIndex("by_user_date", (idx) => idx.eq("userId", args.userId).eq("date", args.date))
|
|
@@ -59,68 +117,60 @@ export const getByUserDate = internalQuery({
|
|
|
59
117
|
export const upsert = internalMutation({
|
|
60
118
|
args: {
|
|
61
119
|
userId: v.string(),
|
|
120
|
+
provider: providerName,
|
|
121
|
+
dataSourceId: v.optional(v.id("dataSources")),
|
|
122
|
+
source: v.optional(v.string()),
|
|
123
|
+
originalSourceName: v.optional(v.string()),
|
|
62
124
|
date: v.string(),
|
|
63
125
|
category: v.string(),
|
|
64
126
|
// All metric fields are optional — only provided fields are updated
|
|
65
|
-
|
|
66
|
-
totalCalories: v.optional(v.number()),
|
|
67
|
-
activeCalories: v.optional(v.number()),
|
|
68
|
-
activeMinutes: v.optional(v.number()),
|
|
69
|
-
totalDistance: v.optional(v.number()),
|
|
70
|
-
floorsClimbed: v.optional(v.number()),
|
|
71
|
-
avgHeartRate: v.optional(v.number()),
|
|
72
|
-
maxHeartRate: v.optional(v.number()),
|
|
73
|
-
minHeartRate: v.optional(v.number()),
|
|
74
|
-
sleepDurationMinutes: v.optional(v.number()),
|
|
75
|
-
sleepEfficiency: v.optional(v.number()),
|
|
76
|
-
deepSleepMinutes: v.optional(v.number()),
|
|
77
|
-
remSleepMinutes: v.optional(v.number()),
|
|
78
|
-
lightSleepMinutes: v.optional(v.number()),
|
|
79
|
-
awakeDuringMinutes: v.optional(v.number()),
|
|
80
|
-
timeInBedMinutes: v.optional(v.number()),
|
|
81
|
-
hrvAvg: v.optional(v.number()),
|
|
82
|
-
hrvRmssd: v.optional(v.number()),
|
|
83
|
-
restingHeartRate: v.optional(v.number()),
|
|
84
|
-
recoveryScore: v.optional(v.number()),
|
|
85
|
-
weight: v.optional(v.number()),
|
|
86
|
-
bodyFatPercentage: v.optional(v.number()),
|
|
87
|
-
bodyMassIndex: v.optional(v.number()),
|
|
88
|
-
leanBodyMass: v.optional(v.number()),
|
|
89
|
-
bodyTemperature: v.optional(v.number()),
|
|
90
|
-
avgStressLevel: v.optional(v.number()),
|
|
91
|
-
bodyBattery: v.optional(v.number()),
|
|
92
|
-
spo2Avg: v.optional(v.number()),
|
|
127
|
+
...summaryMetricsValidator,
|
|
93
128
|
},
|
|
94
129
|
returns: v.id("dailySummaries"),
|
|
95
130
|
handler: async (ctx, args) => {
|
|
96
|
-
const {
|
|
131
|
+
const {
|
|
132
|
+
userId,
|
|
133
|
+
provider,
|
|
134
|
+
dataSourceId,
|
|
135
|
+
source,
|
|
136
|
+
originalSourceName,
|
|
137
|
+
date,
|
|
138
|
+
category,
|
|
139
|
+
...metrics
|
|
140
|
+
} = args;
|
|
97
141
|
|
|
98
|
-
//
|
|
142
|
+
// New writes are provider-scoped. Legacy rows without provider remain
|
|
143
|
+
// readable through unfiltered queries but are not canonical for new ingest.
|
|
99
144
|
const existing = await ctx.db
|
|
100
145
|
.query("dailySummaries")
|
|
101
|
-
.withIndex("
|
|
102
|
-
idx.eq("userId", userId).eq("category", category).eq("date", date),
|
|
146
|
+
.withIndex("by_user_provider_category_date", (idx) =>
|
|
147
|
+
idx.eq("userId", userId).eq("provider", provider).eq("category", category).eq("date", date),
|
|
103
148
|
)
|
|
104
149
|
.first();
|
|
105
150
|
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
151
|
+
const definedFields: Record<string, unknown> = {};
|
|
152
|
+
for (const [key, value] of Object.entries({
|
|
153
|
+
dataSourceId,
|
|
154
|
+
source,
|
|
155
|
+
originalSourceName,
|
|
156
|
+
...metrics,
|
|
157
|
+
})) {
|
|
109
158
|
if (value !== undefined) {
|
|
110
|
-
|
|
159
|
+
definedFields[key] = value;
|
|
111
160
|
}
|
|
112
161
|
}
|
|
113
162
|
|
|
114
163
|
if (existing) {
|
|
115
|
-
await ctx.db.patch(existing._id,
|
|
164
|
+
await ctx.db.patch(existing._id, definedFields);
|
|
116
165
|
return existing._id;
|
|
117
166
|
}
|
|
118
167
|
|
|
119
168
|
return await ctx.db.insert("dailySummaries", {
|
|
120
169
|
userId,
|
|
170
|
+
provider,
|
|
121
171
|
date,
|
|
122
172
|
category,
|
|
123
|
-
...
|
|
173
|
+
...definedFields,
|
|
124
174
|
});
|
|
125
175
|
},
|
|
126
176
|
});
|
|
@@ -501,7 +501,9 @@ export const runConnectionSync = durableWorkflow.define({
|
|
|
501
501
|
for (const summary of batch.summaries as NormalizedDailySummary[]) {
|
|
502
502
|
await step.runMutation(internal.summaries.upsert, {
|
|
503
503
|
userId: job.userId,
|
|
504
|
+
provider: job.provider,
|
|
504
505
|
...summary,
|
|
506
|
+
source: summary.source ?? job.provider,
|
|
505
507
|
});
|
|
506
508
|
}
|
|
507
509
|
processed += batch.summaries.length;
|