@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.
- package/README.md +30 -4
- package/dist/client/index.d.ts +18 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +26 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/providerCapabilities.d.ts +12 -0
- package/dist/client/providerCapabilities.d.ts.map +1 -0
- package/dist/client/providerCapabilities.js +163 -0
- package/dist/client/providerCapabilities.js.map +1 -0
- package/dist/client/types.d.ts +61 -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.test.ts +41 -0
- package/src/client/index.ts +57 -1
- package/src/client/providerCapabilities.ts +182 -0
- package/src/client/types.ts +64 -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
package/src/client/index.ts
CHANGED
|
@@ -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: {
|
|
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
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
"
|
|
19
|
+
"allDayRespiration",
|
|
20
20
|
"pulseOx",
|
|
21
21
|
"bloodPressures",
|
|
22
22
|
"userMetrics",
|
|
23
23
|
"skinTemp",
|
|
24
24
|
"healthSnapshot",
|
|
25
|
-
"
|
|
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
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|