@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.
Files changed (36) hide show
  1. package/dist/client/index.d.ts +3 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +6 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/types.d.ts +13 -1
  6. package/dist/client/types.d.ts.map +1 -1
  7. package/dist/client/types.js +1 -1
  8. package/dist/client/types.js.map +1 -1
  9. package/dist/component/garminBackfill.d.ts +9 -1
  10. package/dist/component/garminBackfill.d.ts.map +1 -1
  11. package/dist/component/garminBackfill.js +27 -6
  12. package/dist/component/garminBackfill.js.map +1 -1
  13. package/dist/component/garminWebhooks.d.ts.map +1 -1
  14. package/dist/component/garminWebhooks.js +26 -13
  15. package/dist/component/garminWebhooks.js.map +1 -1
  16. package/dist/component/providers/garmin.d.ts +4 -0
  17. package/dist/component/providers/garmin.d.ts.map +1 -1
  18. package/dist/component/providers/garmin.js +23 -8
  19. package/dist/component/providers/garmin.js.map +1 -1
  20. package/dist/component/sdkPush.d.ts +24 -0
  21. package/dist/component/sdkPush.d.ts.map +1 -1
  22. package/dist/component/sdkPush.js +101 -6
  23. package/dist/component/sdkPush.js.map +1 -1
  24. package/dist/component/syncWorkflow.d.ts.map +1 -1
  25. package/dist/component/syncWorkflow.js +5 -3
  26. package/dist/component/syncWorkflow.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/index.ts +13 -1
  29. package/src/client/types.ts +13 -1
  30. package/src/component/garminBackfill.ts +33 -6
  31. package/src/component/garminWebhooks.test.ts +24 -7
  32. package/src/component/garminWebhooks.ts +32 -13
  33. package/src/component/providers/garmin.ts +36 -12
  34. package/src/component/sdkPush.test.ts +54 -0
  35. package/src/component/sdkPush.ts +120 -6
  36. 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
- respiration: [
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
- moveiq: [
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
- hrvAvg: 55,
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
- "heart_rate_variability_sdnn",
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
- "respiration",
726
+ "allDayRespiration",
726
727
  "pulseOx",
727
728
  "bloodPressures",
728
729
  "userMetrics",
729
730
  "skinTemp",
730
731
  "healthSnapshot",
731
- "moveiq",
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
- if (payload.respiration?.length) {
248
- for (const respiration of payload.respiration) {
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, "respiration", connection._id);
264
+ addSignal(signalBuckets, "allDayRespiration", connection._id);
261
265
  }
262
266
  }
263
267
 
264
- if (payload.pulseOx?.length) {
265
- for (const pulseOx of payload.pulseOx) {
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
- if (payload.moveiq?.length) {
348
- for (const moveIQ of payload.moveiq) {
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, "moveiq", connection._id);
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 "respiration":
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 "moveiq":
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: 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
  },