@clipin/convex-wearables 0.2.1 → 0.3.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/dist/client/index.d.ts +3 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +13 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/component/garminBackfill.d.ts +9 -1
- package/dist/component/garminBackfill.d.ts.map +1 -1
- package/dist/component/garminBackfill.js +27 -6
- package/dist/component/garminBackfill.js.map +1 -1
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +26 -13
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/providers/garmin.d.ts +4 -0
- package/dist/component/providers/garmin.d.ts.map +1 -1
- package/dist/component/providers/garmin.js +23 -8
- package/dist/component/providers/garmin.js.map +1 -1
- package/dist/component/sdkPush.d.ts +24 -0
- package/dist/component/sdkPush.d.ts.map +1 -1
- package/dist/component/sdkPush.js +101 -6
- package/dist/component/sdkPush.js.map +1 -1
- package/dist/component/syncWorkflow.d.ts.map +1 -1
- package/dist/component/syncWorkflow.js +5 -3
- package/dist/component/syncWorkflow.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +13 -1
- package/src/client/types.ts +13 -1
- package/src/component/garminBackfill.ts +33 -6
- package/src/component/garminWebhooks.test.ts +24 -7
- package/src/component/garminWebhooks.ts +32 -13
- package/src/component/providers/garmin.ts +36 -12
- package/src/component/sdkPush.test.ts +54 -0
- package/src/component/sdkPush.ts +120 -6
- package/src/component/syncWorkflow.ts +5 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { convexTest } from "convex-test";
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { api, internal } from "./_generated/api";
|
|
4
|
-
import { GARMIN_BACKFILL_TYPES } from "./garminBackfill";
|
|
4
|
+
import { GARMIN_BACKFILL_TYPES, getGarminBackfillTypesForJob } from "./garminBackfill";
|
|
5
5
|
import { triggerBackfill } from "./providers/garmin";
|
|
6
6
|
import schema from "./schema";
|
|
7
7
|
import { modules } from "./test.setup";
|
|
@@ -301,7 +301,7 @@ describe("garminWebhooks", () => {
|
|
|
301
301
|
},
|
|
302
302
|
},
|
|
303
303
|
],
|
|
304
|
-
|
|
304
|
+
allDayRespiration: [
|
|
305
305
|
{
|
|
306
306
|
userId: "garmin-user-1",
|
|
307
307
|
summaryId: "resp-1",
|
|
@@ -364,7 +364,7 @@ describe("garminWebhooks", () => {
|
|
|
364
364
|
respiration: 13.8,
|
|
365
365
|
},
|
|
366
366
|
],
|
|
367
|
-
|
|
367
|
+
moveIQActivities: [
|
|
368
368
|
{
|
|
369
369
|
userId: "garmin-user-1",
|
|
370
370
|
summaryId: "moveiq-1",
|
|
@@ -413,6 +413,7 @@ describe("garminWebhooks", () => {
|
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
expect(result.connection?.scope).toBe("ACTIVITY_EXPORT HEALTH_EXPORT");
|
|
416
|
+
expect(result.connection?.lastSyncedAt).toEqual(expect.any(Number));
|
|
416
417
|
expect(result.events).toHaveLength(4);
|
|
417
418
|
expect(result.events.map((event) => event.type)).toEqual(
|
|
418
419
|
expect.arrayContaining(["running", "cycling", "sleep_session", "moveiq_walking"]),
|
|
@@ -448,7 +449,7 @@ describe("garminWebhooks", () => {
|
|
|
448
449
|
restingHeartRate: 48,
|
|
449
450
|
avgStressLevel: 30,
|
|
450
451
|
bodyBattery: 45,
|
|
451
|
-
|
|
452
|
+
hrvRmssd: 55,
|
|
452
453
|
spo2Avg: 97,
|
|
453
454
|
});
|
|
454
455
|
expect(bodySummary).toMatchObject({
|
|
@@ -476,7 +477,7 @@ describe("garminWebhooks", () => {
|
|
|
476
477
|
"garmin_fitness_age",
|
|
477
478
|
"garmin_stress_level",
|
|
478
479
|
"heart_rate",
|
|
479
|
-
"
|
|
480
|
+
"heart_rate_variability_rmssd",
|
|
480
481
|
"oxygen_saturation",
|
|
481
482
|
"respiratory_rate",
|
|
482
483
|
"resting_heart_rate",
|
|
@@ -722,18 +723,23 @@ describe("garminBackfill", () => {
|
|
|
722
723
|
"bodyComps",
|
|
723
724
|
"hrv",
|
|
724
725
|
"stressDetails",
|
|
725
|
-
"
|
|
726
|
+
"allDayRespiration",
|
|
726
727
|
"pulseOx",
|
|
727
728
|
"bloodPressures",
|
|
728
729
|
"userMetrics",
|
|
729
730
|
"skinTemp",
|
|
730
731
|
"healthSnapshot",
|
|
731
|
-
"
|
|
732
|
+
"moveIQActivities",
|
|
732
733
|
"mct",
|
|
733
734
|
]),
|
|
734
735
|
);
|
|
735
736
|
});
|
|
736
737
|
|
|
738
|
+
it("limits recent Garmin backfills to freshness-critical feeds", () => {
|
|
739
|
+
expect(getGarminBackfillTypesForJob("recent")).toEqual(["dailies", "epochs", "sleeps"]);
|
|
740
|
+
expect(getGarminBackfillTypesForJob("full")).toEqual(GARMIN_BACKFILL_TYPES);
|
|
741
|
+
});
|
|
742
|
+
|
|
737
743
|
it("triggers extended Garmin backfill endpoints even when Garmin returns an empty 202 body", async () => {
|
|
738
744
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 202 }));
|
|
739
745
|
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
|
@@ -751,4 +757,15 @@ describe("garminBackfill", () => {
|
|
|
751
757
|
Accept: "application/json",
|
|
752
758
|
});
|
|
753
759
|
});
|
|
760
|
+
|
|
761
|
+
it("maps legacy Garmin backfill aliases to Garmin's current endpoint names", async () => {
|
|
762
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 202 }));
|
|
763
|
+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
|
764
|
+
|
|
765
|
+
await triggerBackfill("garmin-token", "respiration", 100, 200);
|
|
766
|
+
await triggerBackfill("garmin-token", "moveiq", 100, 200);
|
|
767
|
+
|
|
768
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/backfill/allDayRespiration?");
|
|
769
|
+
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/backfill/moveIQActivities?");
|
|
770
|
+
});
|
|
754
771
|
});
|
|
@@ -244,8 +244,12 @@ export const processPushPayload = action({
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
const respirationEntries =
|
|
248
|
+
payload.allDayRespiration && payload.allDayRespiration.length > 0
|
|
249
|
+
? payload.allDayRespiration
|
|
250
|
+
: payload.respiration;
|
|
251
|
+
if (respirationEntries?.length) {
|
|
252
|
+
for (const respiration of respirationEntries) {
|
|
249
253
|
const connection = await resolveConnection(ctx, respiration.userId);
|
|
250
254
|
if (!connection) continue;
|
|
251
255
|
|
|
@@ -257,12 +261,14 @@ export const processPushPayload = action({
|
|
|
257
261
|
dataSourceId,
|
|
258
262
|
normalizeRespirationDataPoints(respiration),
|
|
259
263
|
);
|
|
260
|
-
addSignal(signalBuckets, "
|
|
264
|
+
addSignal(signalBuckets, "allDayRespiration", connection._id);
|
|
261
265
|
}
|
|
262
266
|
}
|
|
263
267
|
|
|
264
|
-
|
|
265
|
-
|
|
268
|
+
const pulseOxEntries =
|
|
269
|
+
payload.pulseOx && payload.pulseOx.length > 0 ? payload.pulseOx : payload.pulseox;
|
|
270
|
+
if (pulseOxEntries?.length) {
|
|
271
|
+
for (const pulseOx of pulseOxEntries) {
|
|
266
272
|
const connection = await resolveConnection(ctx, pulseOx.userId);
|
|
267
273
|
if (!connection) continue;
|
|
268
274
|
|
|
@@ -344,8 +350,12 @@ export const processPushPayload = action({
|
|
|
344
350
|
}
|
|
345
351
|
}
|
|
346
352
|
|
|
347
|
-
|
|
348
|
-
|
|
353
|
+
const moveIQEntries =
|
|
354
|
+
payload.moveIQActivities && payload.moveIQActivities.length > 0
|
|
355
|
+
? payload.moveIQActivities
|
|
356
|
+
: payload.moveiq;
|
|
357
|
+
if (moveIQEntries?.length) {
|
|
358
|
+
for (const moveIQ of moveIQEntries) {
|
|
349
359
|
const connection = await resolveConnection(ctx, moveIQ.userId);
|
|
350
360
|
if (!connection) continue;
|
|
351
361
|
|
|
@@ -365,7 +375,7 @@ export const processPushPayload = action({
|
|
|
365
375
|
externalId: event.externalId,
|
|
366
376
|
});
|
|
367
377
|
|
|
368
|
-
addSignal(signalBuckets, "
|
|
378
|
+
addSignal(signalBuckets, "moveIQActivities", connection._id);
|
|
369
379
|
}
|
|
370
380
|
}
|
|
371
381
|
|
|
@@ -444,6 +454,15 @@ export const processPushPayload = action({
|
|
|
444
454
|
}
|
|
445
455
|
}
|
|
446
456
|
|
|
457
|
+
const syncedConnectionIds = new Set(
|
|
458
|
+
[...signalBuckets.values()].flatMap((connectionIds) => [...connectionIds]),
|
|
459
|
+
);
|
|
460
|
+
for (const connectionId of syncedConnectionIds) {
|
|
461
|
+
await ctx.runMutation(internal.connections.markSynced, {
|
|
462
|
+
connectionId: connectionId as Id<"connections">,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
447
466
|
return null;
|
|
448
467
|
},
|
|
449
468
|
});
|
|
@@ -744,10 +763,10 @@ function getPayloadItemCount(payload: GarminPushPayload, dataType: string): numb
|
|
|
744
763
|
return payload.hrv?.length;
|
|
745
764
|
case "stressDetails":
|
|
746
765
|
return payload.stressDetails?.length;
|
|
747
|
-
case "
|
|
748
|
-
return payload.respiration?.length;
|
|
766
|
+
case "allDayRespiration":
|
|
767
|
+
return payload.allDayRespiration?.length ?? payload.respiration?.length;
|
|
749
768
|
case "pulseOx":
|
|
750
|
-
return payload.pulseOx?.length;
|
|
769
|
+
return payload.pulseOx?.length ?? payload.pulseox?.length;
|
|
751
770
|
case "bloodPressures":
|
|
752
771
|
return payload.bloodPressures?.length;
|
|
753
772
|
case "userMetrics":
|
|
@@ -756,8 +775,8 @@ function getPayloadItemCount(payload: GarminPushPayload, dataType: string): numb
|
|
|
756
775
|
return payload.skinTemp?.length;
|
|
757
776
|
case "healthSnapshot":
|
|
758
777
|
return payload.healthSnapshot?.length;
|
|
759
|
-
case "
|
|
760
|
-
return payload.moveiq?.length;
|
|
778
|
+
case "moveIQActivities":
|
|
779
|
+
return payload.moveIQActivities?.length ?? payload.moveiq?.length;
|
|
761
780
|
case "mct":
|
|
762
781
|
return payload.menstrualCycleTracking && payload.menstrualCycleTracking.length > 0
|
|
763
782
|
? payload.menstrualCycleTracking.length
|
|
@@ -148,6 +148,7 @@ export interface GarminHrv {
|
|
|
148
148
|
userId: string;
|
|
149
149
|
summaryId?: string;
|
|
150
150
|
startTimeInSeconds: number;
|
|
151
|
+
startTimeOffsetInSeconds?: number;
|
|
151
152
|
calendarDate?: string;
|
|
152
153
|
lastNightAvg?: number;
|
|
153
154
|
hrvValues?: Record<string, number>;
|
|
@@ -258,12 +259,15 @@ export interface GarminPushPayload {
|
|
|
258
259
|
bodyComps?: GarminBodyComp[];
|
|
259
260
|
hrv?: GarminHrv[];
|
|
260
261
|
stressDetails?: GarminStressDetails[];
|
|
262
|
+
allDayRespiration?: GarminRespiration[];
|
|
261
263
|
respiration?: GarminRespiration[];
|
|
262
264
|
pulseOx?: GarminPulseOx[];
|
|
265
|
+
pulseox?: GarminPulseOx[];
|
|
263
266
|
bloodPressures?: GarminBloodPressure[];
|
|
264
267
|
userMetrics?: GarminUserMetrics[];
|
|
265
268
|
skinTemp?: GarminSkinTemp[];
|
|
266
269
|
healthSnapshot?: GarminHealthSnapshot[];
|
|
270
|
+
moveIQActivities?: GarminMoveIQ[];
|
|
267
271
|
moveiq?: GarminMoveIQ[];
|
|
268
272
|
menstrualCycleTracking?: GarminMCTSummary[];
|
|
269
273
|
mct?: GarminMCTSummary[];
|
|
@@ -282,6 +286,13 @@ function isoDateFromCalendarDate(
|
|
|
282
286
|
return calendarDate ?? isoDateFromTimestamp(fallbackTimestampMs);
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
function isoDateFromTimestampWithOffset(timestampMs: number, offsetSeconds?: number): string {
|
|
290
|
+
if (offsetSeconds === undefined) {
|
|
291
|
+
return isoDateFromTimestamp(timestampMs);
|
|
292
|
+
}
|
|
293
|
+
return isoDateFromTimestamp(timestampMs + offsetSeconds * 1000);
|
|
294
|
+
}
|
|
295
|
+
|
|
285
296
|
function calendarDateToMiddayTimestamp(calendarDate: string | undefined): number | null {
|
|
286
297
|
if (!calendarDate) {
|
|
287
298
|
return null;
|
|
@@ -541,8 +552,9 @@ export function normalizeSleep(sleep: GarminSleep): NormalizedEvent {
|
|
|
541
552
|
}
|
|
542
553
|
|
|
543
554
|
export function normalizeSleepSummary(sleep: GarminSleep): NormalizedDailySummary {
|
|
555
|
+
const endMs = (sleep.startTimeInSeconds + sleep.durationInSeconds) * 1000;
|
|
544
556
|
return {
|
|
545
|
-
date:
|
|
557
|
+
date: isoDateFromTimestampWithOffset(endMs, sleep.startTimeOffsetInSeconds),
|
|
546
558
|
category: "sleep",
|
|
547
559
|
sleepDurationMinutes: Math.floor(sleep.durationInSeconds / 60),
|
|
548
560
|
sleepEfficiency: sleep.overallSleepScore?.value,
|
|
@@ -734,7 +746,7 @@ export function normalizeHrvDataPoints(hrv: GarminHrv): NormalizedDataPoint[] {
|
|
|
734
746
|
const points: NormalizedDataPoint[] = [];
|
|
735
747
|
if (hrv.lastNightAvg != null) {
|
|
736
748
|
points.push({
|
|
737
|
-
seriesType: "
|
|
749
|
+
seriesType: "heart_rate_variability_rmssd",
|
|
738
750
|
recordedAt: hrv.startTimeInSeconds * 1000,
|
|
739
751
|
value: hrv.lastNightAvg,
|
|
740
752
|
externalId: hrv.summaryId,
|
|
@@ -745,7 +757,7 @@ export function normalizeHrvDataPoints(hrv: GarminHrv): NormalizedDataPoint[] {
|
|
|
745
757
|
...buildOffsetDataPoints(
|
|
746
758
|
hrv.hrvValues,
|
|
747
759
|
hrv.startTimeInSeconds,
|
|
748
|
-
"
|
|
760
|
+
"heart_rate_variability_rmssd",
|
|
749
761
|
hrv.summaryId,
|
|
750
762
|
),
|
|
751
763
|
);
|
|
@@ -761,7 +773,7 @@ export function normalizeHrvSummary(hrv: GarminHrv): NormalizedDailySummary | nu
|
|
|
761
773
|
return {
|
|
762
774
|
date: isoDateFromCalendarDate(hrv.calendarDate, hrv.startTimeInSeconds * 1000),
|
|
763
775
|
category: "recovery",
|
|
764
|
-
|
|
776
|
+
hrvRmssd: hrv.lastNightAvg,
|
|
765
777
|
};
|
|
766
778
|
}
|
|
767
779
|
|
|
@@ -1138,6 +1150,7 @@ export async function triggerBackfill(
|
|
|
1138
1150
|
startTimeSeconds: number,
|
|
1139
1151
|
endTimeSeconds: number,
|
|
1140
1152
|
): Promise<void> {
|
|
1153
|
+
const endpointDataType = normalizeBackfillDataType(dataType);
|
|
1141
1154
|
const validTypes = [
|
|
1142
1155
|
"activities",
|
|
1143
1156
|
"activityDetails",
|
|
@@ -1147,23 +1160,34 @@ export async function triggerBackfill(
|
|
|
1147
1160
|
"bodyComps",
|
|
1148
1161
|
"hrv",
|
|
1149
1162
|
"stressDetails",
|
|
1150
|
-
"
|
|
1163
|
+
"allDayRespiration",
|
|
1151
1164
|
"pulseOx",
|
|
1152
1165
|
"bloodPressures",
|
|
1153
1166
|
"userMetrics",
|
|
1154
1167
|
"skinTemp",
|
|
1155
1168
|
"healthSnapshot",
|
|
1156
|
-
"
|
|
1169
|
+
"moveIQActivities",
|
|
1157
1170
|
"mct",
|
|
1158
1171
|
];
|
|
1159
|
-
if (!validTypes.includes(
|
|
1172
|
+
if (!validTypes.includes(endpointDataType)) {
|
|
1160
1173
|
throw new Error(`Invalid backfill data type: ${dataType}`);
|
|
1161
1174
|
}
|
|
1162
1175
|
|
|
1163
|
-
await makeAuthenticatedRequest(
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1176
|
+
await makeAuthenticatedRequest(
|
|
1177
|
+
API_BASE,
|
|
1178
|
+
`/wellness-api/rest/backfill/${endpointDataType}`,
|
|
1179
|
+
accessToken,
|
|
1180
|
+
{
|
|
1181
|
+
params: {
|
|
1182
|
+
summaryStartTimeInSeconds: String(startTimeSeconds),
|
|
1183
|
+
summaryEndTimeInSeconds: String(endTimeSeconds),
|
|
1184
|
+
},
|
|
1167
1185
|
},
|
|
1168
|
-
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function normalizeBackfillDataType(dataType: string): string {
|
|
1190
|
+
if (dataType === "respiration") return "allDayRespiration";
|
|
1191
|
+
if (dataType === "moveiq") return "moveIQActivities";
|
|
1192
|
+
return dataType;
|
|
1169
1193
|
}
|
|
@@ -245,7 +245,17 @@ describe("sdkPush", () => {
|
|
|
245
245
|
model: "Pixel 9 Pro",
|
|
246
246
|
softwareVersion: "Android 16",
|
|
247
247
|
source: "health-connect",
|
|
248
|
+
appId: "com.thirdparty.writer",
|
|
248
249
|
},
|
|
250
|
+
events: [
|
|
251
|
+
{
|
|
252
|
+
category: "workout",
|
|
253
|
+
type: "CYCLING_STATIONARY",
|
|
254
|
+
startDatetime: Date.parse("2026-03-18T08:00:00Z"),
|
|
255
|
+
endDatetime: Date.parse("2026-03-18T08:45:00Z"),
|
|
256
|
+
externalId: "hc-cycling-1",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
249
259
|
dataPoints: [
|
|
250
260
|
{
|
|
251
261
|
seriesType: "hrv_rmssd",
|
|
@@ -271,6 +281,30 @@ describe("sdkPush", () => {
|
|
|
271
281
|
value: 340,
|
|
272
282
|
externalId: "hc-active-calories-1",
|
|
273
283
|
},
|
|
284
|
+
{
|
|
285
|
+
seriesType: "POWER",
|
|
286
|
+
recordedAt: Date.parse("2026-03-18T12:03:00Z"),
|
|
287
|
+
value: 220,
|
|
288
|
+
externalId: "hc-power-1",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
seriesType: "SPEED",
|
|
292
|
+
recordedAt: Date.parse("2026-03-18T12:04:00Z"),
|
|
293
|
+
value: 7.5,
|
|
294
|
+
externalId: "hc-speed-1",
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
seriesType: "CYCLING_PEDALING_CADENCE",
|
|
298
|
+
recordedAt: Date.parse("2026-03-18T12:05:00Z"),
|
|
299
|
+
value: 88,
|
|
300
|
+
externalId: "hc-cadence-1",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
seriesType: "TOTAL_CALORIES_BURNED",
|
|
304
|
+
recordedAt: Date.parse("2026-03-18T12:06:00Z"),
|
|
305
|
+
value: 830,
|
|
306
|
+
externalId: "hc-total-calories-1",
|
|
307
|
+
},
|
|
274
308
|
],
|
|
275
309
|
dailySummaries: [
|
|
276
310
|
{
|
|
@@ -293,6 +327,22 @@ describe("sdkPush", () => {
|
|
|
293
327
|
deviceModel: "Pixel 9 Pro",
|
|
294
328
|
softwareVersion: "Android 16",
|
|
295
329
|
source: "health-connect",
|
|
330
|
+
originalSourceName: "com.thirdparty.writer",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const events = 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-3").eq("category", "workout"),
|
|
338
|
+
)
|
|
339
|
+
.collect();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(events).toHaveLength(1);
|
|
343
|
+
expect(events[0]).toMatchObject({
|
|
344
|
+
type: "indoor_cycling",
|
|
345
|
+
externalId: "hc-cycling-1",
|
|
296
346
|
});
|
|
297
347
|
|
|
298
348
|
const points = await t.run(async (ctx) => {
|
|
@@ -304,9 +354,13 @@ describe("sdkPush", () => {
|
|
|
304
354
|
|
|
305
355
|
expect(points.map((point) => point.seriesType).sort()).toEqual([
|
|
306
356
|
"active_calories",
|
|
357
|
+
"cadence",
|
|
307
358
|
"distance",
|
|
308
359
|
"floors_climbed",
|
|
309
360
|
"heart_rate_variability_rmssd",
|
|
361
|
+
"power",
|
|
362
|
+
"speed",
|
|
363
|
+
"total_calories",
|
|
310
364
|
]);
|
|
311
365
|
|
|
312
366
|
const summaries = await t.run(async (ctx) => {
|
package/src/component/sdkPush.ts
CHANGED
|
@@ -13,6 +13,14 @@ const MAX_DATA_POINTS_PER_REQUEST = 10000;
|
|
|
13
13
|
const MAX_SUMMARIES_PER_REQUEST = 1000;
|
|
14
14
|
const SERIES_TYPE_ALIASES = {
|
|
15
15
|
hrv_rmssd: "heart_rate_variability_rmssd",
|
|
16
|
+
POWER: "power",
|
|
17
|
+
power: "power",
|
|
18
|
+
SPEED: "speed",
|
|
19
|
+
speed: "speed",
|
|
20
|
+
CYCLING_PEDALING_CADENCE: "cadence",
|
|
21
|
+
cycling_pedaling_cadence: "cadence",
|
|
22
|
+
TOTAL_CALORIES_BURNED: "total_calories",
|
|
23
|
+
total_calories_burned: "total_calories",
|
|
16
24
|
} as const;
|
|
17
25
|
const validSeriesTypes = new Set(Object.keys(SERIES_TYPES));
|
|
18
26
|
|
|
@@ -22,6 +30,10 @@ const deviceMetadataValidator = v.object({
|
|
|
22
30
|
source: v.optional(v.string()),
|
|
23
31
|
deviceType: v.optional(v.string()),
|
|
24
32
|
originalSourceName: v.optional(v.string()),
|
|
33
|
+
appId: v.optional(v.string()),
|
|
34
|
+
app_id: v.optional(v.string()),
|
|
35
|
+
bundleIdentifier: v.optional(v.string()),
|
|
36
|
+
bundle_identifier: v.optional(v.string()),
|
|
25
37
|
});
|
|
26
38
|
|
|
27
39
|
const sourceMetadataValidator = v.object({
|
|
@@ -30,6 +42,10 @@ const sourceMetadataValidator = v.object({
|
|
|
30
42
|
source: v.optional(v.string()),
|
|
31
43
|
deviceType: v.optional(v.string()),
|
|
32
44
|
originalSourceName: v.optional(v.string()),
|
|
45
|
+
appId: v.optional(v.string()),
|
|
46
|
+
app_id: v.optional(v.string()),
|
|
47
|
+
bundleIdentifier: v.optional(v.string()),
|
|
48
|
+
bundle_identifier: v.optional(v.string()),
|
|
33
49
|
});
|
|
34
50
|
|
|
35
51
|
const sdkEventValidator = v.object({
|
|
@@ -76,6 +92,10 @@ const sdkEventValidator = v.object({
|
|
|
76
92
|
source: v.optional(v.string()),
|
|
77
93
|
deviceType: v.optional(v.string()),
|
|
78
94
|
originalSourceName: v.optional(v.string()),
|
|
95
|
+
appId: v.optional(v.string()),
|
|
96
|
+
app_id: v.optional(v.string()),
|
|
97
|
+
bundleIdentifier: v.optional(v.string()),
|
|
98
|
+
bundle_identifier: v.optional(v.string()),
|
|
79
99
|
});
|
|
80
100
|
|
|
81
101
|
const sdkDataPointValidator = v.object({
|
|
@@ -88,6 +108,10 @@ const sdkDataPointValidator = v.object({
|
|
|
88
108
|
source: v.optional(v.string()),
|
|
89
109
|
deviceType: v.optional(v.string()),
|
|
90
110
|
originalSourceName: v.optional(v.string()),
|
|
111
|
+
appId: v.optional(v.string()),
|
|
112
|
+
app_id: v.optional(v.string()),
|
|
113
|
+
bundleIdentifier: v.optional(v.string()),
|
|
114
|
+
bundle_identifier: v.optional(v.string()),
|
|
91
115
|
});
|
|
92
116
|
|
|
93
117
|
const sdkSummaryValidator = v.object({
|
|
@@ -95,6 +119,10 @@ const sdkSummaryValidator = v.object({
|
|
|
95
119
|
category: v.string(),
|
|
96
120
|
source: v.optional(v.string()),
|
|
97
121
|
originalSourceName: v.optional(v.string()),
|
|
122
|
+
appId: v.optional(v.string()),
|
|
123
|
+
app_id: v.optional(v.string()),
|
|
124
|
+
bundleIdentifier: v.optional(v.string()),
|
|
125
|
+
bundle_identifier: v.optional(v.string()),
|
|
98
126
|
totalSteps: v.optional(v.number()),
|
|
99
127
|
totalCalories: v.optional(v.number()),
|
|
100
128
|
activeCalories: v.optional(v.number()),
|
|
@@ -133,6 +161,10 @@ type SourceMetadata = {
|
|
|
133
161
|
source?: string;
|
|
134
162
|
deviceType?: string;
|
|
135
163
|
originalSourceName?: string;
|
|
164
|
+
appId?: string;
|
|
165
|
+
app_id?: string;
|
|
166
|
+
bundleIdentifier?: string;
|
|
167
|
+
bundle_identifier?: string;
|
|
136
168
|
};
|
|
137
169
|
|
|
138
170
|
type DataSourceCache = Map<string, Id<"dataSources">>;
|
|
@@ -158,7 +190,17 @@ function resolveSourceMetadata(
|
|
|
158
190
|
softwareVersion: item.softwareVersion ?? defaults?.softwareVersion,
|
|
159
191
|
source: item.source ?? defaults?.source,
|
|
160
192
|
deviceType: item.deviceType ?? defaults?.deviceType,
|
|
161
|
-
originalSourceName:
|
|
193
|
+
originalSourceName:
|
|
194
|
+
item.originalSourceName ??
|
|
195
|
+
item.appId ??
|
|
196
|
+
item.app_id ??
|
|
197
|
+
item.bundleIdentifier ??
|
|
198
|
+
item.bundle_identifier ??
|
|
199
|
+
defaults?.originalSourceName,
|
|
200
|
+
appId: item.appId ?? defaults?.appId,
|
|
201
|
+
app_id: item.app_id ?? defaults?.app_id,
|
|
202
|
+
bundleIdentifier: item.bundleIdentifier ?? defaults?.bundleIdentifier,
|
|
203
|
+
bundle_identifier: item.bundle_identifier ?? defaults?.bundle_identifier,
|
|
162
204
|
};
|
|
163
205
|
}
|
|
164
206
|
|
|
@@ -245,6 +287,10 @@ export const ingestNormalizedPayload = action({
|
|
|
245
287
|
source: event.source,
|
|
246
288
|
deviceType: event.deviceType,
|
|
247
289
|
originalSourceName: event.originalSourceName,
|
|
290
|
+
appId: event.appId,
|
|
291
|
+
app_id: event.app_id,
|
|
292
|
+
bundleIdentifier: event.bundleIdentifier,
|
|
293
|
+
bundle_identifier: event.bundle_identifier,
|
|
248
294
|
});
|
|
249
295
|
const dataSourceId = await ensureDataSource(
|
|
250
296
|
ctx,
|
|
@@ -261,7 +307,7 @@ export const ingestNormalizedPayload = action({
|
|
|
261
307
|
dataSourceId,
|
|
262
308
|
userId: args.userId,
|
|
263
309
|
category: event.category,
|
|
264
|
-
type: event.type,
|
|
310
|
+
type: event.category === "workout" ? normalizeWorkoutType(event.type) : event.type,
|
|
265
311
|
sourceName: event.sourceName ?? defaultSourceName(args.provider),
|
|
266
312
|
durationSeconds: event.durationSeconds,
|
|
267
313
|
startDatetime: event.startDatetime,
|
|
@@ -322,6 +368,10 @@ export const ingestNormalizedPayload = action({
|
|
|
322
368
|
source: point.source,
|
|
323
369
|
deviceType: point.deviceType,
|
|
324
370
|
originalSourceName: point.originalSourceName,
|
|
371
|
+
appId: point.appId,
|
|
372
|
+
app_id: point.app_id,
|
|
373
|
+
bundleIdentifier: point.bundleIdentifier,
|
|
374
|
+
bundle_identifier: point.bundle_identifier,
|
|
325
375
|
});
|
|
326
376
|
const dataSourceId = await ensureDataSource(
|
|
327
377
|
ctx,
|
|
@@ -360,12 +410,27 @@ export const ingestNormalizedPayload = action({
|
|
|
360
410
|
}
|
|
361
411
|
|
|
362
412
|
for (const summary of summaries) {
|
|
413
|
+
const {
|
|
414
|
+
appId,
|
|
415
|
+
app_id,
|
|
416
|
+
bundleIdentifier,
|
|
417
|
+
bundle_identifier,
|
|
418
|
+
originalSourceName,
|
|
419
|
+
source,
|
|
420
|
+
...summaryMetrics
|
|
421
|
+
} = summary;
|
|
363
422
|
await ctx.runMutation(internal.summaries.upsert, {
|
|
364
423
|
userId: args.userId,
|
|
365
424
|
provider: args.provider,
|
|
366
|
-
...
|
|
367
|
-
source:
|
|
368
|
-
originalSourceName:
|
|
425
|
+
...summaryMetrics,
|
|
426
|
+
source: source ?? defaultMetadata.source,
|
|
427
|
+
originalSourceName:
|
|
428
|
+
originalSourceName ??
|
|
429
|
+
appId ??
|
|
430
|
+
app_id ??
|
|
431
|
+
bundleIdentifier ??
|
|
432
|
+
bundle_identifier ??
|
|
433
|
+
defaultMetadata.originalSourceName,
|
|
369
434
|
});
|
|
370
435
|
}
|
|
371
436
|
|
|
@@ -390,6 +455,10 @@ function sourceMetadataFromDevice(
|
|
|
390
455
|
source?: string;
|
|
391
456
|
deviceType?: string;
|
|
392
457
|
originalSourceName?: string;
|
|
458
|
+
appId?: string;
|
|
459
|
+
app_id?: string;
|
|
460
|
+
bundleIdentifier?: string;
|
|
461
|
+
bundle_identifier?: string;
|
|
393
462
|
}
|
|
394
463
|
| undefined,
|
|
395
464
|
): SourceMetadata | undefined {
|
|
@@ -399,7 +468,16 @@ function sourceMetadataFromDevice(
|
|
|
399
468
|
softwareVersion: device.softwareVersion,
|
|
400
469
|
source: device.source,
|
|
401
470
|
deviceType: device.deviceType,
|
|
402
|
-
originalSourceName:
|
|
471
|
+
originalSourceName:
|
|
472
|
+
device.originalSourceName ??
|
|
473
|
+
device.appId ??
|
|
474
|
+
device.app_id ??
|
|
475
|
+
device.bundleIdentifier ??
|
|
476
|
+
device.bundle_identifier,
|
|
477
|
+
appId: device.appId,
|
|
478
|
+
app_id: device.app_id,
|
|
479
|
+
bundleIdentifier: device.bundleIdentifier,
|
|
480
|
+
bundle_identifier: device.bundle_identifier,
|
|
403
481
|
};
|
|
404
482
|
}
|
|
405
483
|
|
|
@@ -412,6 +490,42 @@ function normalizeSeriesType(seriesType: string): string {
|
|
|
412
490
|
return normalized;
|
|
413
491
|
}
|
|
414
492
|
|
|
493
|
+
const SDK_WORKOUT_TYPE_ALIASES: Record<string, string> = {
|
|
494
|
+
cycling_stationary: "indoor_cycling",
|
|
495
|
+
boot_camp: "cardio_training",
|
|
496
|
+
calisthenics: "strength_training",
|
|
497
|
+
dancing: "dance",
|
|
498
|
+
exercise_class: "cardio_training",
|
|
499
|
+
football_american: "american_football",
|
|
500
|
+
football_australian: "football",
|
|
501
|
+
frisbee_disc: "disc_sports",
|
|
502
|
+
guided_breathing: "meditation",
|
|
503
|
+
ice_hockey: "hockey",
|
|
504
|
+
ice_skating: "ice_skating",
|
|
505
|
+
paddling: "paddling",
|
|
506
|
+
paragliding: "paragliding",
|
|
507
|
+
rock_climbing: "rock_climbing",
|
|
508
|
+
roller_hockey: "hockey",
|
|
509
|
+
rowing_machine: "rowing_machine",
|
|
510
|
+
running_treadmill: "treadmill",
|
|
511
|
+
scuba_diving: "diving",
|
|
512
|
+
skiing: "alpine_skiing",
|
|
513
|
+
snowshoeing: "snowshoeing",
|
|
514
|
+
stair_climbing_machine: "stair_climbing",
|
|
515
|
+
stretching: "stretching",
|
|
516
|
+
swimming_open_water: "open_water_swimming",
|
|
517
|
+
swimming_pool: "pool_swimming",
|
|
518
|
+
weightlifting: "strength_training",
|
|
519
|
+
wheelchair: "wheelchair",
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
function normalizeWorkoutType(type: string | undefined): string | undefined {
|
|
523
|
+
if (type === undefined) return undefined;
|
|
524
|
+
const normalized = type.trim().toLowerCase();
|
|
525
|
+
if (!normalized) return undefined;
|
|
526
|
+
return SDK_WORKOUT_TYPE_ALIASES[normalized] ?? normalized;
|
|
527
|
+
}
|
|
528
|
+
|
|
415
529
|
function assertPayloadWithinLimits(args: {
|
|
416
530
|
events: unknown[];
|
|
417
531
|
dataPoints: unknown[];
|
|
@@ -525,9 +525,11 @@ export const runConnectionSync = durableWorkflow.define({
|
|
|
525
525
|
}
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
528
|
+
if (processed > 0) {
|
|
529
|
+
await step.runMutation(internal.connections.markSynced, {
|
|
530
|
+
connectionId: connection._id,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
531
533
|
|
|
532
534
|
return { recordsProcessed: processed };
|
|
533
535
|
},
|