@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
@@ -15,6 +15,11 @@ import type {
15
15
  } from "convex/server";
16
16
  import { httpActionGeneric } from "convex/server";
17
17
  import type { ComponentApi } from "../component/_generated/component.js";
18
+ import {
19
+ getAllProviderCapabilityInfo as getAllProviderCapabilityInfoValue,
20
+ getProviderCapabilities as getProviderCapabilitiesValue,
21
+ getProviderCapabilityInfo as getProviderCapabilityInfoValue,
22
+ } from "./providerCapabilities.js";
18
23
  import type {
19
24
  AggregateStats,
20
25
  BackfillJob,
@@ -26,6 +31,9 @@ import type {
26
31
  EventsPage,
27
32
  GarminRoutesConfig,
28
33
  HealthEvent,
34
+ LiveSyncMode,
35
+ ProviderCapabilities,
36
+ ProviderCapabilityInfo,
29
37
  ProviderCredentials,
30
38
  ProviderName,
31
39
  RegisterRoutesConfig,
@@ -51,6 +59,18 @@ export {
51
59
  stravaWebhookEvent,
52
60
  stravaWebhookVerify,
53
61
  } from "../component/httpHandlers.js";
62
+ export {
63
+ createProviderCapabilities,
64
+ getAllProviderCapabilityInfo,
65
+ getDefaultLiveSyncMode,
66
+ getProviderCapabilities,
67
+ getProviderCapabilityInfo,
68
+ isLiveSyncConfigurable,
69
+ PROVIDER_NAMES,
70
+ supportsBackfill,
71
+ supportsHistoricalSync,
72
+ supportsManualSync,
73
+ } from "./providerCapabilities.js";
54
74
  export type { SeriesType, SleepEvent, SleepStage, WorkoutEvent } from "./types.js";
55
75
  export { SERIES_TYPES } from "./types.js";
56
76
  // Re-export types for consumers
@@ -65,6 +85,9 @@ export type {
65
85
  EventsPage,
66
86
  GarminRoutesConfig,
67
87
  HealthEvent,
88
+ LiveSyncMode,
89
+ ProviderCapabilities,
90
+ ProviderCapabilityInfo,
68
91
  ProviderCredentials,
69
92
  ProviderName,
70
93
  RegisterRoutesConfig,
@@ -140,6 +163,27 @@ export class WearablesClient {
140
163
  this.config = config;
141
164
  }
142
165
 
166
+ /**
167
+ * Get static delivery/sync capabilities for a provider.
168
+ */
169
+ getProviderCapabilities(provider: ProviderName): ProviderCapabilities {
170
+ return getProviderCapabilitiesValue(provider);
171
+ }
172
+
173
+ /**
174
+ * Get static provider capabilities plus derived UI-friendly flags.
175
+ */
176
+ getProviderCapabilityInfo(provider: ProviderName): ProviderCapabilityInfo {
177
+ return getProviderCapabilityInfoValue(provider);
178
+ }
179
+
180
+ /**
181
+ * Get static capability info for every supported provider.
182
+ */
183
+ getAllProviderCapabilityInfo(): ProviderCapabilityInfo[] {
184
+ return getAllProviderCapabilityInfoValue();
185
+ }
186
+
143
187
  // -----------------------------------------------------------------------
144
188
  // Connection Management
145
189
  // -----------------------------------------------------------------------
@@ -447,12 +491,21 @@ export class WearablesClient {
447
491
  */
448
492
  async startGarminBackfill(
449
493
  ctx: ActionRunner,
450
- args: { connectionId: string; lookbackDays?: number },
494
+ args: {
495
+ connectionId: string;
496
+ kind?: "full" | "recent";
497
+ lookbackDays?: number;
498
+ windowStart?: number;
499
+ windowEnd?: number;
500
+ },
451
501
  ): Promise<{ backfillJobId: string; workflowId: string; deduped: boolean }> {
452
502
  const credentials = this.requireProviderCredentials("garmin");
453
503
  return await ctx.runAction(this.component.garminBackfill.startGarminBackfill, {
454
504
  connectionId: args.connectionId,
505
+ kind: args.kind,
455
506
  lookbackDays: args.lookbackDays,
507
+ windowStart: args.windowStart,
508
+ windowEnd: args.windowEnd,
456
509
  clientId: credentials.clientId,
457
510
  clientSecret: credentials.clientSecret,
458
511
  });
@@ -789,12 +842,15 @@ function summarizeGarminPayload(payload: unknown) {
789
842
  bodyComps: getArrayLength(payload.bodyComps),
790
843
  hrv: getArrayLength(payload.hrv),
791
844
  stressDetails: getArrayLength(payload.stressDetails),
845
+ allDayRespiration: getArrayLength(payload.allDayRespiration),
792
846
  respiration: getArrayLength(payload.respiration),
793
847
  pulseOx: getArrayLength(payload.pulseOx),
848
+ pulseox: getArrayLength(payload.pulseox),
794
849
  bloodPressures: getArrayLength(payload.bloodPressures),
795
850
  userMetrics: getArrayLength(payload.userMetrics),
796
851
  skinTemp: getArrayLength(payload.skinTemp),
797
852
  healthSnapshot: getArrayLength(payload.healthSnapshot),
853
+ moveIQActivities: getArrayLength(payload.moveIQActivities),
798
854
  moveiq: getArrayLength(payload.moveiq),
799
855
  menstrualCycleTracking: getArrayLength(payload.menstrualCycleTracking),
800
856
  mct: getArrayLength(payload.mct),
@@ -0,0 +1,182 @@
1
+ import type {
2
+ LiveSyncMode,
3
+ ProviderCapabilities,
4
+ ProviderCapabilityInfo,
5
+ ProviderName,
6
+ } from "./types.js";
7
+
8
+ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
9
+ restPull: false,
10
+ clientSdk: false,
11
+ fileImport: false,
12
+ webhookCallback: false,
13
+ webhookStream: false,
14
+ webhookPing: false,
15
+ webhookRegistrationApi: false,
16
+ webhookInboundSecret: false,
17
+ maxHistoricalDays: null,
18
+ };
19
+
20
+ const PROVIDER_CAPABILITIES = {
21
+ garmin: {
22
+ restPull: false,
23
+ clientSdk: false,
24
+ fileImport: false,
25
+ webhookCallback: true,
26
+ webhookStream: true,
27
+ webhookPing: false,
28
+ webhookRegistrationApi: false,
29
+ webhookInboundSecret: false,
30
+ maxHistoricalDays: 30,
31
+ },
32
+ suunto: {
33
+ restPull: true,
34
+ clientSdk: false,
35
+ fileImport: false,
36
+ webhookCallback: false,
37
+ webhookStream: false,
38
+ webhookPing: false,
39
+ webhookRegistrationApi: false,
40
+ webhookInboundSecret: false,
41
+ maxHistoricalDays: null,
42
+ },
43
+ polar: {
44
+ restPull: true,
45
+ clientSdk: false,
46
+ fileImport: false,
47
+ webhookCallback: false,
48
+ webhookStream: false,
49
+ webhookPing: false,
50
+ webhookRegistrationApi: false,
51
+ webhookInboundSecret: false,
52
+ maxHistoricalDays: null,
53
+ },
54
+ whoop: {
55
+ restPull: true,
56
+ clientSdk: false,
57
+ fileImport: false,
58
+ webhookCallback: false,
59
+ webhookStream: false,
60
+ webhookPing: false,
61
+ webhookRegistrationApi: false,
62
+ webhookInboundSecret: false,
63
+ maxHistoricalDays: null,
64
+ },
65
+ strava: {
66
+ restPull: true,
67
+ clientSdk: false,
68
+ fileImport: false,
69
+ webhookCallback: false,
70
+ webhookStream: false,
71
+ webhookPing: true,
72
+ webhookRegistrationApi: false,
73
+ webhookInboundSecret: false,
74
+ maxHistoricalDays: null,
75
+ },
76
+ apple: {
77
+ restPull: false,
78
+ clientSdk: true,
79
+ fileImport: false,
80
+ webhookCallback: false,
81
+ webhookStream: false,
82
+ webhookPing: false,
83
+ webhookRegistrationApi: false,
84
+ webhookInboundSecret: false,
85
+ maxHistoricalDays: null,
86
+ },
87
+ samsung: {
88
+ restPull: false,
89
+ clientSdk: true,
90
+ fileImport: false,
91
+ webhookCallback: false,
92
+ webhookStream: false,
93
+ webhookPing: false,
94
+ webhookRegistrationApi: false,
95
+ webhookInboundSecret: false,
96
+ maxHistoricalDays: null,
97
+ },
98
+ google: {
99
+ restPull: false,
100
+ clientSdk: true,
101
+ fileImport: false,
102
+ webhookCallback: false,
103
+ webhookStream: false,
104
+ webhookPing: false,
105
+ webhookRegistrationApi: false,
106
+ webhookInboundSecret: false,
107
+ maxHistoricalDays: null,
108
+ },
109
+ } satisfies Record<ProviderName, ProviderCapabilities>;
110
+
111
+ export const PROVIDER_NAMES = Object.keys(PROVIDER_CAPABILITIES) as ProviderName[];
112
+
113
+ export function getProviderCapabilities(provider: ProviderName): ProviderCapabilities {
114
+ return { ...PROVIDER_CAPABILITIES[provider] };
115
+ }
116
+
117
+ export function getProviderCapabilityInfo(provider: ProviderName): ProviderCapabilityInfo {
118
+ const capabilities = getProviderCapabilities(provider);
119
+ return {
120
+ provider,
121
+ ...capabilities,
122
+ implemented: true,
123
+ liveSyncConfigurable: isLiveSyncConfigurable(capabilities),
124
+ defaultLiveSyncMode: getDefaultLiveSyncMode(provider),
125
+ supportsManualSync: supportsManualSync(provider),
126
+ supportsHistoricalSync: supportsHistoricalSync(provider),
127
+ supportsBackfill: supportsBackfill(provider),
128
+ };
129
+ }
130
+
131
+ export function getAllProviderCapabilityInfo(): ProviderCapabilityInfo[] {
132
+ return PROVIDER_NAMES.map((provider) => getProviderCapabilityInfo(provider));
133
+ }
134
+
135
+ export function getDefaultLiveSyncMode(provider: ProviderName): LiveSyncMode | null {
136
+ const capabilities = getProviderCapabilities(provider);
137
+ if (capabilities.restPull) return "pull";
138
+ if (capabilities.clientSdk) return null;
139
+ if (capabilities.webhookStream || capabilities.webhookPing) return "webhook";
140
+ return null;
141
+ }
142
+
143
+ export function isLiveSyncConfigurable(
144
+ providerOrCapabilities: ProviderName | ProviderCapabilities,
145
+ ): boolean {
146
+ const capabilities =
147
+ typeof providerOrCapabilities === "string"
148
+ ? getProviderCapabilities(providerOrCapabilities)
149
+ : providerOrCapabilities;
150
+ return capabilities.restPull && (capabilities.webhookStream || capabilities.webhookPing);
151
+ }
152
+
153
+ export function supportsManualSync(provider: ProviderName): boolean {
154
+ const capabilities = getProviderCapabilities(provider);
155
+ return capabilities.restPull;
156
+ }
157
+
158
+ export function supportsHistoricalSync(provider: ProviderName): boolean {
159
+ const capabilities = getProviderCapabilities(provider);
160
+ return capabilities.restPull || capabilities.webhookCallback;
161
+ }
162
+
163
+ export function supportsBackfill(provider: ProviderName): boolean {
164
+ const capabilities = getProviderCapabilities(provider);
165
+ return capabilities.webhookCallback;
166
+ }
167
+
168
+ export function createProviderCapabilities(
169
+ capabilities: Partial<ProviderCapabilities>,
170
+ ): ProviderCapabilities {
171
+ const merged = { ...DEFAULT_CAPABILITIES, ...capabilities };
172
+ if (merged.webhookStream && merged.webhookPing) {
173
+ throw new Error("webhookStream and webhookPing are mutually exclusive");
174
+ }
175
+ if (merged.webhookPing && !merged.restPull) {
176
+ throw new Error("webhookPing requires restPull because data must be fetched after the ping");
177
+ }
178
+ if (merged.webhookInboundSecret && !merged.webhookRegistrationApi) {
179
+ throw new Error("webhookInboundSecret requires webhookRegistrationApi");
180
+ }
181
+ return merged;
182
+ }
@@ -30,6 +30,57 @@ export type DurationInput = string | number;
30
30
 
31
31
  export type TimeSeriesRollupAggregation = "avg" | "min" | "max" | "last" | "count";
32
32
 
33
+ export type LiveSyncMode = "pull" | "webhook";
34
+
35
+ export interface ProviderCapabilities {
36
+ /**
37
+ * Provider exposes a REST API that can be polled for historical or recent data.
38
+ */
39
+ restPull: boolean;
40
+ /**
41
+ * Data arrives through the normalized mobile SDK push endpoint.
42
+ */
43
+ clientSdk: boolean;
44
+ /**
45
+ * Data arrives as a file import rather than provider API calls or SDK pushes.
46
+ */
47
+ fileImport: boolean;
48
+ /**
49
+ * We request a provider export/backfill and the provider calls back asynchronously.
50
+ */
51
+ webhookCallback: boolean;
52
+ /**
53
+ * Provider pushes complete data payloads inline to the component webhook.
54
+ */
55
+ webhookStream: boolean;
56
+ /**
57
+ * Provider sends lightweight notifications and data must be fetched separately.
58
+ */
59
+ webhookPing: boolean;
60
+ /**
61
+ * Provider supports programmatic webhook subscription registration.
62
+ */
63
+ webhookRegistrationApi: boolean;
64
+ /**
65
+ * Provider returns or requires a stored inbound webhook signing secret.
66
+ */
67
+ webhookInboundSecret: boolean;
68
+ /**
69
+ * Known historical sync lookback limit in days. Null means no known component-enforced limit.
70
+ */
71
+ maxHistoricalDays: number | null;
72
+ }
73
+
74
+ export interface ProviderCapabilityInfo extends ProviderCapabilities {
75
+ provider: ProviderName;
76
+ implemented: boolean;
77
+ liveSyncConfigurable: boolean;
78
+ defaultLiveSyncMode: LiveSyncMode | null;
79
+ supportsManualSync: boolean;
80
+ supportsHistoricalSync: boolean;
81
+ supportsBackfill: boolean;
82
+ }
83
+
33
84
  // ---------------------------------------------------------------------------
34
85
  // Provider configuration (passed by app)
35
86
  // ---------------------------------------------------------------------------
@@ -98,6 +149,10 @@ export interface SdkDeviceMetadata {
98
149
  source?: string;
99
150
  deviceType?: string;
100
151
  originalSourceName?: string;
152
+ appId?: string;
153
+ app_id?: string;
154
+ bundleIdentifier?: string;
155
+ bundle_identifier?: string;
101
156
  }
102
157
 
103
158
  export interface SdkSourceMetadata {
@@ -106,6 +161,10 @@ export interface SdkSourceMetadata {
106
161
  source?: string;
107
162
  deviceType?: string;
108
163
  originalSourceName?: string;
164
+ appId?: string;
165
+ app_id?: string;
166
+ bundleIdentifier?: string;
167
+ bundle_identifier?: string;
109
168
  }
110
169
 
111
170
  export interface SdkPushEvent extends SdkSourceMetadata {
@@ -157,6 +216,10 @@ export interface SdkPushSummary {
157
216
  category: string;
158
217
  source?: string;
159
218
  originalSourceName?: string;
219
+ appId?: string;
220
+ app_id?: string;
221
+ bundleIdentifier?: string;
222
+ bundle_identifier?: string;
160
223
  totalSteps?: number;
161
224
  totalCalories?: number;
162
225
  activeCalories?: number;
@@ -540,7 +603,7 @@ export const SERIES_TYPES = {
540
603
  peripheral_perfusion_index: { id: 27, unit: "score" },
541
604
  forced_vital_capacity: { id: 28, unit: "liters" },
542
605
  forced_expiratory_volume_1: { id: 29, unit: "liters" },
543
- peak_expiratory_flow_rate: { id: 30, unit: "liters" },
606
+ peak_expiratory_flow_rate: { id: 30, unit: "L/min" },
544
607
 
545
608
  // Body Composition
546
609
  height: { id: 40, unit: "cm" },
@@ -16,21 +16,31 @@ export const GARMIN_BACKFILL_TYPES = [
16
16
  "bodyComps",
17
17
  "hrv",
18
18
  "stressDetails",
19
- "respiration",
19
+ "allDayRespiration",
20
20
  "pulseOx",
21
21
  "bloodPressures",
22
22
  "userMetrics",
23
23
  "skinTemp",
24
24
  "healthSnapshot",
25
- "moveiq",
25
+ "moveIQActivities",
26
26
  "mct",
27
27
  ] as const;
28
+ export const RECENT_GARMIN_BACKFILL_TYPES = ["dailies", "epochs", "sleeps"] as const;
28
29
 
29
30
  type GarminBackfillType = (typeof GARMIN_BACKFILL_TYPES)[number];
30
31
 
32
+ export function getGarminBackfillTypesForJob(dataType: string | undefined): GarminBackfillType[] {
33
+ if (dataType === "recent") {
34
+ return [...RECENT_GARMIN_BACKFILL_TYPES];
35
+ }
36
+
37
+ return [...GARMIN_BACKFILL_TYPES];
38
+ }
39
+
31
40
  export const requestGarminBackfill = internalMutation({
32
41
  args: {
33
42
  connectionId: v.id("connections"),
43
+ kind: v.optional(v.union(v.literal("full"), v.literal("recent"))),
34
44
  windowStart: v.number(),
35
45
  windowEnd: v.number(),
36
46
  },
@@ -66,7 +76,7 @@ export const requestGarminBackfill = internalMutation({
66
76
  connectionId: connection._id,
67
77
  userId: connection.userId,
68
78
  provider: "garmin",
69
- dataType: "full",
79
+ dataType: args.kind ?? "full",
70
80
  status: "queued",
71
81
  startedAt: Date.now(),
72
82
  windowStart: args.windowStart,
@@ -186,7 +196,7 @@ export const runGarminBackfill = durableWorkflow.define({
186
196
 
187
197
  const completed = new Set<string>(job.completedDataTypes ?? []);
188
198
 
189
- for (const dataType of GARMIN_BACKFILL_TYPES) {
199
+ for (const dataType of getGarminBackfillTypesForJob(job.dataType)) {
190
200
  if (completed.has(dataType)) {
191
201
  continue;
192
202
  }
@@ -293,7 +303,10 @@ export const handleGarminBackfillComplete = internalMutation({
293
303
  export const startGarminBackfill = action({
294
304
  args: {
295
305
  connectionId: v.id("connections"),
306
+ kind: v.optional(v.union(v.literal("full"), v.literal("recent"))),
296
307
  lookbackDays: v.optional(v.number()),
308
+ windowStart: v.optional(v.number()),
309
+ windowEnd: v.optional(v.number()),
297
310
  clientId: v.optional(v.string()),
298
311
  clientSecret: v.optional(v.string()),
299
312
  },
@@ -326,12 +339,26 @@ export const startGarminBackfill = action({
326
339
  }
327
340
 
328
341
  const now = Date.now();
342
+ if (
343
+ (args.windowStart !== undefined && args.windowEnd === undefined) ||
344
+ (args.windowStart === undefined && args.windowEnd !== undefined)
345
+ ) {
346
+ throw new Error("windowStart and windowEnd must be provided together");
347
+ }
348
+
329
349
  const lookbackMs = (args.lookbackDays ?? DEFAULT_LOOKBACK_DAYS) * 24 * 60 * 60 * 1000;
350
+ const windowStart = args.windowStart ?? now - lookbackMs;
351
+ const windowEnd = args.windowEnd ?? now;
352
+
353
+ if (windowStart >= windowEnd) {
354
+ throw new Error("windowStart must be before windowEnd");
355
+ }
330
356
 
331
357
  const result = await ctx.runMutation(internal.garminBackfill.requestGarminBackfill, {
332
358
  connectionId: args.connectionId,
333
- windowStart: now - lookbackMs,
334
- windowEnd: now,
359
+ kind: args.kind,
360
+ windowStart,
361
+ windowEnd,
335
362
  });
336
363
 
337
364
  return {
@@ -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