@clipin/convex-wearables 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +30 -4
  2. package/dist/client/index.d.ts +18 -2
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +26 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/providerCapabilities.d.ts +12 -0
  7. package/dist/client/providerCapabilities.d.ts.map +1 -0
  8. package/dist/client/providerCapabilities.js +163 -0
  9. package/dist/client/providerCapabilities.js.map +1 -0
  10. package/dist/client/types.d.ts +61 -1
  11. package/dist/client/types.d.ts.map +1 -1
  12. package/dist/client/types.js +1 -1
  13. package/dist/client/types.js.map +1 -1
  14. package/dist/component/garminBackfill.d.ts +9 -1
  15. package/dist/component/garminBackfill.d.ts.map +1 -1
  16. package/dist/component/garminBackfill.js +27 -6
  17. package/dist/component/garminBackfill.js.map +1 -1
  18. package/dist/component/garminWebhooks.d.ts.map +1 -1
  19. package/dist/component/garminWebhooks.js +26 -13
  20. package/dist/component/garminWebhooks.js.map +1 -1
  21. package/dist/component/providers/garmin.d.ts +4 -0
  22. package/dist/component/providers/garmin.d.ts.map +1 -1
  23. package/dist/component/providers/garmin.js +23 -8
  24. package/dist/component/providers/garmin.js.map +1 -1
  25. package/dist/component/sdkPush.d.ts +24 -0
  26. package/dist/component/sdkPush.d.ts.map +1 -1
  27. package/dist/component/sdkPush.js +101 -6
  28. package/dist/component/sdkPush.js.map +1 -1
  29. package/dist/component/syncWorkflow.d.ts.map +1 -1
  30. package/dist/component/syncWorkflow.js +5 -3
  31. package/dist/component/syncWorkflow.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/client/index.test.ts +41 -0
  34. package/src/client/index.ts +57 -1
  35. package/src/client/providerCapabilities.ts +182 -0
  36. package/src/client/types.ts +64 -1
  37. package/src/component/garminBackfill.ts +33 -6
  38. package/src/component/garminWebhooks.test.ts +24 -7
  39. package/src/component/garminWebhooks.ts +32 -13
  40. package/src/component/providers/garmin.ts +36 -12
  41. package/src/component/sdkPush.test.ts +54 -0
  42. package/src/component/sdkPush.ts +120 -6
  43. package/src/component/syncWorkflow.ts +5 -3
@@ -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: isoDateFromTimestamp(sleep.startTimeInSeconds * 1000),
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: "heart_rate_variability_sdnn",
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
- "heart_rate_variability_sdnn",
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
- hrvAvg: hrv.lastNightAvg,
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
- "respiration",
1163
+ "allDayRespiration",
1151
1164
  "pulseOx",
1152
1165
  "bloodPressures",
1153
1166
  "userMetrics",
1154
1167
  "skinTemp",
1155
1168
  "healthSnapshot",
1156
- "moveiq",
1169
+ "moveIQActivities",
1157
1170
  "mct",
1158
1171
  ];
1159
- if (!validTypes.includes(dataType)) {
1172
+ if (!validTypes.includes(endpointDataType)) {
1160
1173
  throw new Error(`Invalid backfill data type: ${dataType}`);
1161
1174
  }
1162
1175
 
1163
- await makeAuthenticatedRequest(API_BASE, `/wellness-api/rest/backfill/${dataType}`, accessToken, {
1164
- params: {
1165
- summaryStartTimeInSeconds: String(startTimeSeconds),
1166
- summaryEndTimeInSeconds: String(endTimeSeconds),
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) => {
@@ -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: item.originalSourceName ?? defaults?.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
- ...summary,
367
- source: summary.source ?? defaultMetadata.source,
368
- originalSourceName: summary.originalSourceName ?? defaultMetadata.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: device.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
- await step.runMutation(internal.connections.markSynced, {
529
- connectionId: connection._id,
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
  },