@nativesquare/soma 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.d.ts +151 -53
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +162 -69
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +130 -17
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +61 -43
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +208 -122
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/public.d.ts +363 -0
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +124 -0
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +7 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +9 -10
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +0 -1
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +0 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/garmin/auth.d.ts +55 -46
- package/dist/garmin/auth.d.ts.map +1 -1
- package/dist/garmin/auth.js +82 -122
- package/dist/garmin/auth.js.map +1 -1
- package/dist/garmin/client.d.ts +64 -17
- package/dist/garmin/client.d.ts.map +1 -1
- package/dist/garmin/client.js +143 -29
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/index.d.ts +3 -3
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +4 -4
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/plannedWorkout.d.ts +12 -0
- package/dist/garmin/plannedWorkout.d.ts.map +1 -0
- package/dist/garmin/plannedWorkout.js +267 -0
- package/dist/garmin/plannedWorkout.js.map +1 -0
- package/dist/garmin/types.d.ts +78 -6
- package/dist/garmin/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +236 -85
- package/src/component/_generated/component.ts +155 -17
- package/src/component/garmin.ts +258 -124
- package/src/component/public.ts +135 -0
- package/src/component/schema.ts +9 -10
- package/src/component/strava.ts +0 -1
- package/src/garmin/auth.test.ts +71 -96
- package/src/garmin/auth.ts +129 -193
- package/src/garmin/client.ts +197 -51
- package/src/garmin/index.ts +13 -14
- package/src/garmin/plannedWorkout.ts +333 -0
- package/src/garmin/types.ts +149 -7
package/src/garmin/client.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// ─── Garmin Health API Client ────────────────────────────────────────────────
|
|
2
2
|
// Lightweight, fetch-based client for the Garmin Health API.
|
|
3
|
-
//
|
|
4
|
-
// Uses the Web Crypto API for HMAC-SHA1 signing and global `fetch`.
|
|
3
|
+
// Authenticates requests with an OAuth 2.0 Bearer token.
|
|
5
4
|
|
|
6
5
|
import type {
|
|
7
6
|
GarminActivity,
|
|
@@ -9,28 +8,18 @@ import type {
|
|
|
9
8
|
GarminSleep,
|
|
10
9
|
GarminBodyComposition,
|
|
11
10
|
GarminMenstrualCycleData,
|
|
11
|
+
GarminWorkout,
|
|
12
|
+
GarminWorkoutSchedule,
|
|
12
13
|
} from "./types.js";
|
|
13
|
-
import {
|
|
14
|
-
generateNonce,
|
|
15
|
-
getTimestamp,
|
|
16
|
-
buildOAuthSignature,
|
|
17
|
-
buildOAuthHeader,
|
|
18
|
-
} from "./auth.js";
|
|
19
14
|
|
|
20
15
|
const DEFAULT_BASE_URL = "https://apis.garmin.com";
|
|
21
16
|
|
|
22
17
|
export interface GarminClientOptions {
|
|
23
|
-
/**
|
|
24
|
-
consumerKey: string;
|
|
25
|
-
/** Your application's consumer secret. */
|
|
26
|
-
consumerSecret: string;
|
|
27
|
-
/** The user's permanent OAuth access token. */
|
|
18
|
+
/** The user's OAuth 2.0 access token. */
|
|
28
19
|
accessToken: string;
|
|
29
|
-
/** The user's permanent OAuth token secret. */
|
|
30
|
-
tokenSecret: string;
|
|
31
20
|
/**
|
|
32
21
|
* Base URL of the Garmin Health API.
|
|
33
|
-
*
|
|
22
|
+
* @default "https://apis.garmin.com"
|
|
34
23
|
*/
|
|
35
24
|
baseUrl?: string;
|
|
36
25
|
}
|
|
@@ -38,17 +27,14 @@ export interface GarminClientOptions {
|
|
|
38
27
|
/**
|
|
39
28
|
* A lightweight client for the Garmin Health API.
|
|
40
29
|
*
|
|
41
|
-
* All requests are
|
|
30
|
+
* All requests are authenticated with a Bearer token. Time-range parameters
|
|
42
31
|
* use Unix epoch seconds for `uploadStartTimeInSeconds` and
|
|
43
32
|
* `uploadEndTimeInSeconds`.
|
|
44
33
|
*
|
|
45
34
|
* @example
|
|
46
35
|
* ```ts
|
|
47
36
|
* const client = new GarminClient({
|
|
48
|
-
*
|
|
49
|
-
* consumerSecret: "your_secret",
|
|
50
|
-
* accessToken: "user_token",
|
|
51
|
-
* tokenSecret: "user_secret",
|
|
37
|
+
* accessToken: "user_access_token",
|
|
52
38
|
* });
|
|
53
39
|
*
|
|
54
40
|
* const dailies = await client.getDailies({
|
|
@@ -58,17 +44,11 @@ export interface GarminClientOptions {
|
|
|
58
44
|
* ```
|
|
59
45
|
*/
|
|
60
46
|
export class GarminClient {
|
|
61
|
-
private readonly consumerKey: string;
|
|
62
|
-
private readonly consumerSecret: string;
|
|
63
47
|
private readonly accessToken: string;
|
|
64
|
-
private readonly tokenSecret: string;
|
|
65
48
|
private readonly baseUrl: string;
|
|
66
49
|
|
|
67
50
|
constructor(opts: GarminClientOptions) {
|
|
68
|
-
this.consumerKey = opts.consumerKey;
|
|
69
|
-
this.consumerSecret = opts.consumerSecret;
|
|
70
51
|
this.accessToken = opts.accessToken;
|
|
71
|
-
this.tokenSecret = opts.tokenSecret;
|
|
72
52
|
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
73
53
|
}
|
|
74
54
|
|
|
@@ -168,6 +148,128 @@ export class GarminClient {
|
|
|
168
148
|
);
|
|
169
149
|
}
|
|
170
150
|
|
|
151
|
+
// ─── Training API V2 ────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check which permissions the user has granted.
|
|
155
|
+
*
|
|
156
|
+
* Garmin API: `GET /userPermissions/`
|
|
157
|
+
*/
|
|
158
|
+
async getUserPermissions(): Promise<string[]> {
|
|
159
|
+
return this.get<string[]>("/userPermissions/");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a workout in Garmin Connect.
|
|
164
|
+
*
|
|
165
|
+
* Garmin API: `POST /workoutportal/workout/v2`
|
|
166
|
+
* Note: uses a different base path than other Training API endpoints.
|
|
167
|
+
*/
|
|
168
|
+
async createWorkout(workout: GarminWorkout): Promise<GarminWorkout> {
|
|
169
|
+
return this.post<GarminWorkout>("/workoutportal/workout/v2", workout);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Retrieve a workout by ID.
|
|
174
|
+
*
|
|
175
|
+
* Garmin API: `GET /training-api/workout/v2/{workoutId}`
|
|
176
|
+
*/
|
|
177
|
+
async getWorkout(workoutId: number): Promise<GarminWorkout> {
|
|
178
|
+
return this.get<GarminWorkout>(
|
|
179
|
+
`/training-api/workout/v2/${workoutId}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Update a workout by ID.
|
|
185
|
+
*
|
|
186
|
+
* Garmin API: `PUT /training-api/workout/v2/{workoutId}`
|
|
187
|
+
*/
|
|
188
|
+
async updateWorkout(
|
|
189
|
+
workoutId: number,
|
|
190
|
+
workout: GarminWorkout,
|
|
191
|
+
): Promise<GarminWorkout> {
|
|
192
|
+
return this.put<GarminWorkout>(
|
|
193
|
+
`/training-api/workout/v2/${workoutId}`,
|
|
194
|
+
workout,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Delete a workout by ID.
|
|
200
|
+
*
|
|
201
|
+
* Garmin API: `DELETE /training-api/workout/v2/{workoutId}`
|
|
202
|
+
*/
|
|
203
|
+
async deleteWorkout(workoutId: number): Promise<void> {
|
|
204
|
+
await this.del(`/training-api/workout/v2/${workoutId}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Schedule a workout to a specific date on the user's calendar.
|
|
209
|
+
*
|
|
210
|
+
* Garmin API: `POST /training-api/schedule/`
|
|
211
|
+
*/
|
|
212
|
+
async createSchedule(
|
|
213
|
+
workoutId: number,
|
|
214
|
+
date: string,
|
|
215
|
+
): Promise<GarminWorkoutSchedule> {
|
|
216
|
+
return this.post<GarminWorkoutSchedule>("/training-api/schedule/", {
|
|
217
|
+
workoutId,
|
|
218
|
+
date,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Retrieve workout schedules for a date range.
|
|
224
|
+
*
|
|
225
|
+
* Garmin API: `GET /training-api/schedule?startDate=...&endDate=...`
|
|
226
|
+
*/
|
|
227
|
+
async getSchedulesByDate(
|
|
228
|
+
startDate: string,
|
|
229
|
+
endDate: string,
|
|
230
|
+
): Promise<GarminWorkoutSchedule[]> {
|
|
231
|
+
return this.get<GarminWorkoutSchedule[]>("/training-api/schedule", {
|
|
232
|
+
startDate,
|
|
233
|
+
endDate,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Delete a workout schedule by ID.
|
|
239
|
+
*
|
|
240
|
+
* Garmin API: `DELETE /training-api/schedule/{scheduleId}`
|
|
241
|
+
*/
|
|
242
|
+
async deleteSchedule(scheduleId: number): Promise<void> {
|
|
243
|
+
await this.del(`/training-api/schedule/${scheduleId}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── User Deregistration ──────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Delete the user's registration with Garmin.
|
|
250
|
+
*
|
|
251
|
+
* Must be called when the user disconnects or deletes their account
|
|
252
|
+
* to comply with Garmin's API requirements.
|
|
253
|
+
*/
|
|
254
|
+
async deleteUserRegistration(): Promise<void> {
|
|
255
|
+
const url = `${this.baseUrl}/wellness-api/rest/user/registration`;
|
|
256
|
+
const response = await fetch(url, {
|
|
257
|
+
method: "DELETE",
|
|
258
|
+
headers: {
|
|
259
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
const body = await response.text().catch(() => "");
|
|
265
|
+
throw new GarminApiError(
|
|
266
|
+
`Garmin API error: ${response.status} ${response.statusText}`,
|
|
267
|
+
response.status,
|
|
268
|
+
body,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
171
273
|
// ─── Internal ─────────────────────────────────────────────────────────
|
|
172
274
|
|
|
173
275
|
private async get<T>(
|
|
@@ -180,33 +282,10 @@ export class GarminClient {
|
|
|
180
282
|
: "";
|
|
181
283
|
const requestUrl = `${fullUrl}${qs}`;
|
|
182
284
|
|
|
183
|
-
const nonce = generateNonce();
|
|
184
|
-
const timestamp = getTimestamp();
|
|
185
|
-
|
|
186
|
-
const oauthParams: Record<string, string> = {
|
|
187
|
-
oauth_consumer_key: this.consumerKey,
|
|
188
|
-
oauth_nonce: nonce,
|
|
189
|
-
oauth_signature_method: "HMAC-SHA1",
|
|
190
|
-
oauth_timestamp: timestamp,
|
|
191
|
-
oauth_token: this.accessToken,
|
|
192
|
-
oauth_version: "1.0",
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
// OAuth signature must include both OAuth params and query params
|
|
196
|
-
const allParams = { ...oauthParams, ...(queryParams ?? {}) };
|
|
197
|
-
const signature = await buildOAuthSignature(
|
|
198
|
-
"GET",
|
|
199
|
-
fullUrl,
|
|
200
|
-
allParams,
|
|
201
|
-
this.consumerSecret,
|
|
202
|
-
this.tokenSecret,
|
|
203
|
-
);
|
|
204
|
-
oauthParams.oauth_signature = signature;
|
|
205
|
-
|
|
206
285
|
const response = await fetch(requestUrl, {
|
|
207
286
|
method: "GET",
|
|
208
287
|
headers: {
|
|
209
|
-
Authorization:
|
|
288
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
210
289
|
Accept: "application/json",
|
|
211
290
|
},
|
|
212
291
|
});
|
|
@@ -222,6 +301,73 @@ export class GarminClient {
|
|
|
222
301
|
|
|
223
302
|
return (await response.json()) as T;
|
|
224
303
|
}
|
|
304
|
+
|
|
305
|
+
private async post<T>(path: string, body: unknown): Promise<T> {
|
|
306
|
+
const url = `${this.baseUrl}${path}`;
|
|
307
|
+
const response = await fetch(url, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: {
|
|
310
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
Accept: "application/json",
|
|
313
|
+
},
|
|
314
|
+
body: JSON.stringify(body),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
const text = await response.text().catch(() => "");
|
|
319
|
+
throw new GarminApiError(
|
|
320
|
+
`Garmin API error: ${response.status} ${response.statusText}`,
|
|
321
|
+
response.status,
|
|
322
|
+
text,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return (await response.json()) as T;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async put<T>(path: string, body: unknown): Promise<T> {
|
|
330
|
+
const url = `${this.baseUrl}${path}`;
|
|
331
|
+
const response = await fetch(url, {
|
|
332
|
+
method: "PUT",
|
|
333
|
+
headers: {
|
|
334
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
335
|
+
"Content-Type": "application/json",
|
|
336
|
+
Accept: "application/json",
|
|
337
|
+
},
|
|
338
|
+
body: JSON.stringify(body),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (!response.ok) {
|
|
342
|
+
const text = await response.text().catch(() => "");
|
|
343
|
+
throw new GarminApiError(
|
|
344
|
+
`Garmin API error: ${response.status} ${response.statusText}`,
|
|
345
|
+
response.status,
|
|
346
|
+
text,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return (await response.json()) as T;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async del(path: string): Promise<void> {
|
|
354
|
+
const url = `${this.baseUrl}${path}`;
|
|
355
|
+
const response = await fetch(url, {
|
|
356
|
+
method: "DELETE",
|
|
357
|
+
headers: {
|
|
358
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
const text = await response.text().catch(() => "");
|
|
364
|
+
throw new GarminApiError(
|
|
365
|
+
`Garmin API error: ${response.status} ${response.statusText}`,
|
|
366
|
+
response.status,
|
|
367
|
+
text,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
225
371
|
}
|
|
226
372
|
|
|
227
373
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
package/src/garmin/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ─── @nativesquare/soma/garmin ───────────────────────────────────────────────
|
|
2
|
-
// Garmin Health API → Soma schema transformers, API client, OAuth helpers, and sync.
|
|
2
|
+
// Garmin Health API → Soma schema transformers, API client, OAuth 2.0 PKCE helpers, and sync.
|
|
3
3
|
//
|
|
4
|
-
// Uses the Web Crypto API for
|
|
4
|
+
// Uses the Web Crypto API for PKCE code challenge generation.
|
|
5
5
|
// Compatible with both the Convex V8 runtime and Node.js environments.
|
|
6
6
|
|
|
7
7
|
// ── Transformers ─────────────────────────────────────────────────────────────
|
|
@@ -28,19 +28,19 @@ export { mapSleepLevel } from "./maps/sleep-level.js";
|
|
|
28
28
|
export { GarminClient, GarminApiError } from "./client.js";
|
|
29
29
|
export type { GarminClientOptions, TimeRangeParams } from "./client.js";
|
|
30
30
|
|
|
31
|
-
// ── OAuth Helpers
|
|
31
|
+
// ── OAuth 2.0 PKCE Helpers ───────────────────────────────────────────────────
|
|
32
32
|
export {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
getTimestamp,
|
|
33
|
+
generateCodeVerifier,
|
|
34
|
+
generateCodeChallenge,
|
|
35
|
+
generateState,
|
|
36
|
+
buildAuthUrl,
|
|
37
|
+
exchangeCode,
|
|
38
|
+
refreshToken,
|
|
40
39
|
} from "./auth.js";
|
|
41
40
|
export type {
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
BuildAuthUrlOptions,
|
|
42
|
+
ExchangeCodeOptions,
|
|
43
|
+
RefreshTokenOptions,
|
|
44
44
|
} from "./auth.js";
|
|
45
45
|
|
|
46
46
|
// ── Sync Helpers ─────────────────────────────────────────────────────────────
|
|
@@ -70,7 +70,6 @@ export type {
|
|
|
70
70
|
GarminMenstrualCycleData,
|
|
71
71
|
GarminUserProfile,
|
|
72
72
|
GarminActivityType,
|
|
73
|
-
|
|
74
|
-
GarminOAuthAccessTokenResponse,
|
|
73
|
+
GarminOAuth2TokenResponse,
|
|
75
74
|
GarminWebhookPayload,
|
|
76
75
|
} from "./types.js";
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// ─── Soma → Garmin Training API V2 Transformer ─────────────────────────────
|
|
2
|
+
// Maps Soma's Terra-style planned workout model to the Garmin Training API V2
|
|
3
|
+
// JSON format for workout creation and scheduling.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
GarminWorkout,
|
|
7
|
+
GarminWorkoutSegment,
|
|
8
|
+
GarminWorkoutStep,
|
|
9
|
+
GarminWorkoutRepeatStep,
|
|
10
|
+
GarminWorkoutSport,
|
|
11
|
+
GarminStepIntensity,
|
|
12
|
+
GarminDurationType,
|
|
13
|
+
GarminTargetType,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
type SomaPlannedWorkout = Record<string, any>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Transform a Soma planned workout document into a Garmin Training API V2
|
|
23
|
+
* workout payload ready for `POST /workoutportal/workout/v2`.
|
|
24
|
+
*
|
|
25
|
+
* @param somaWorkout - The planned workout document from the Soma DB
|
|
26
|
+
* @param providerName - Name shown to the user in Garmin Connect (20 chars max)
|
|
27
|
+
*/
|
|
28
|
+
export function transformPlannedWorkoutToGarmin(
|
|
29
|
+
somaWorkout: SomaPlannedWorkout,
|
|
30
|
+
providerName: string,
|
|
31
|
+
): GarminWorkout {
|
|
32
|
+
const metadata = somaWorkout.metadata ?? {};
|
|
33
|
+
const sport = mapSportType(metadata.type);
|
|
34
|
+
const steps = transformSteps(somaWorkout.steps ?? []);
|
|
35
|
+
|
|
36
|
+
const segment: GarminWorkoutSegment = {
|
|
37
|
+
segmentOrder: 1,
|
|
38
|
+
sport,
|
|
39
|
+
poolLength: metadata.pool_length_meters ?? null,
|
|
40
|
+
poolLengthUnit: metadata.pool_length_meters != null ? "METER" : null,
|
|
41
|
+
estimatedDurationInSecs: null,
|
|
42
|
+
estimatedDistanceInMeters: null,
|
|
43
|
+
steps,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
workoutName: metadata.name ?? "Workout",
|
|
48
|
+
description: metadata.description ?? null,
|
|
49
|
+
sport,
|
|
50
|
+
estimatedDurationInSecs: null,
|
|
51
|
+
estimatedDistanceInMeters: null,
|
|
52
|
+
poolLength: metadata.pool_length_meters ?? null,
|
|
53
|
+
poolLengthUnit: metadata.pool_length_meters != null ? "METER" : null,
|
|
54
|
+
workoutProvider: providerName.slice(0, 20),
|
|
55
|
+
workoutSourceId: providerName.slice(0, 20),
|
|
56
|
+
isSessionTransitionEnabled: false,
|
|
57
|
+
segments: [segment],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Step Transformation ─────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function transformSteps(
|
|
64
|
+
somaSteps: Array<Record<string, unknown>>,
|
|
65
|
+
): Array<GarminWorkoutStep | GarminWorkoutRepeatStep> {
|
|
66
|
+
const result: Array<GarminWorkoutStep | GarminWorkoutRepeatStep> = [];
|
|
67
|
+
let order = 1;
|
|
68
|
+
|
|
69
|
+
for (const step of somaSteps) {
|
|
70
|
+
const stepType = String(step.type ?? "STEP").toUpperCase();
|
|
71
|
+
|
|
72
|
+
if (stepType === "REPEAT_STEP") {
|
|
73
|
+
const nestedSteps = Array.isArray(step.steps) ? step.steps : [];
|
|
74
|
+
const reps = extractRepeatCount(step);
|
|
75
|
+
|
|
76
|
+
const repeatStep: GarminWorkoutRepeatStep = {
|
|
77
|
+
type: "WorkoutRepeatStep",
|
|
78
|
+
stepOrder: order++,
|
|
79
|
+
repeatType: "REPEAT_UNTIL_STEPS_CMPLT",
|
|
80
|
+
repeatValue: reps,
|
|
81
|
+
steps: transformSteps(nestedSteps),
|
|
82
|
+
};
|
|
83
|
+
result.push(repeatStep);
|
|
84
|
+
} else {
|
|
85
|
+
const workoutStep = transformSingleStep(step, order++);
|
|
86
|
+
result.push(workoutStep);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function transformSingleStep(
|
|
94
|
+
step: Record<string, unknown>,
|
|
95
|
+
stepOrder: number,
|
|
96
|
+
): GarminWorkoutStep {
|
|
97
|
+
const { durationType, durationValue, durationValueType } =
|
|
98
|
+
extractDuration(step);
|
|
99
|
+
const { targetType, targetValueLow, targetValueHigh, targetValueType } =
|
|
100
|
+
extractTarget(step);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
type: "WorkoutStep",
|
|
104
|
+
stepOrder,
|
|
105
|
+
intensity: mapIntensity(step.intensity),
|
|
106
|
+
description: (step.description as string) ?? null,
|
|
107
|
+
durationType,
|
|
108
|
+
durationValue,
|
|
109
|
+
durationValueType,
|
|
110
|
+
targetType,
|
|
111
|
+
targetValue: null,
|
|
112
|
+
targetValueLow,
|
|
113
|
+
targetValueHigh,
|
|
114
|
+
targetValueType,
|
|
115
|
+
secondaryTargetType: null,
|
|
116
|
+
secondaryTargetValue: null,
|
|
117
|
+
secondaryTargetValueLow: null,
|
|
118
|
+
secondaryTargetValueHigh: null,
|
|
119
|
+
secondaryTargetValueType: null,
|
|
120
|
+
strokeType: (step.stroke_type as string) ?? null,
|
|
121
|
+
drillType: null,
|
|
122
|
+
equipmentType: (step.equipment_type as string) ?? null,
|
|
123
|
+
exerciseCategory: (step.exercise_category as string) ?? null,
|
|
124
|
+
exerciseName: (step.exercise_name as string) ?? null,
|
|
125
|
+
weightValue: (step.weight_kg as number) ?? null,
|
|
126
|
+
weightDisplayUnit: step.weight_kg != null ? "KILOGRAM" : null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Duration Extraction ─────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
interface DurationResult {
|
|
133
|
+
durationType: GarminDurationType;
|
|
134
|
+
durationValue: number | null;
|
|
135
|
+
durationValueType: string | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractDuration(step: Record<string, unknown>): DurationResult {
|
|
139
|
+
const durations = step.durations as Array<Record<string, unknown>> | undefined;
|
|
140
|
+
if (!durations || durations.length === 0) {
|
|
141
|
+
return { durationType: "OPEN", durationValue: null, durationValueType: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const dur = durations[0];
|
|
145
|
+
const type = String(dur.duration_type ?? "OPEN").toUpperCase();
|
|
146
|
+
|
|
147
|
+
switch (type) {
|
|
148
|
+
case "TIME":
|
|
149
|
+
return {
|
|
150
|
+
durationType: "TIME",
|
|
151
|
+
durationValue: (dur.seconds as number) ?? null,
|
|
152
|
+
durationValueType: null,
|
|
153
|
+
};
|
|
154
|
+
case "DISTANCE_METERS":
|
|
155
|
+
case "DISTANCE":
|
|
156
|
+
return {
|
|
157
|
+
durationType: "DISTANCE",
|
|
158
|
+
durationValue: (dur.distance_meters as number) ?? null,
|
|
159
|
+
durationValueType: null,
|
|
160
|
+
};
|
|
161
|
+
case "HR_LESS_THAN":
|
|
162
|
+
return {
|
|
163
|
+
durationType: "HR_LESS_THAN",
|
|
164
|
+
durationValue: (dur.hr_below_bpm as number) ?? null,
|
|
165
|
+
durationValueType: null,
|
|
166
|
+
};
|
|
167
|
+
case "HR_GREATER_THAN":
|
|
168
|
+
return {
|
|
169
|
+
durationType: "HR_GREATER_THAN",
|
|
170
|
+
durationValue: (dur.hr_above_bpm as number) ?? null,
|
|
171
|
+
durationValueType: null,
|
|
172
|
+
};
|
|
173
|
+
case "CALORIES":
|
|
174
|
+
return {
|
|
175
|
+
durationType: "CALORIES",
|
|
176
|
+
durationValue: (dur.calories as number) ?? null,
|
|
177
|
+
durationValueType: null,
|
|
178
|
+
};
|
|
179
|
+
case "POWER_LESS_THAN":
|
|
180
|
+
return {
|
|
181
|
+
durationType: "POWER_LESS_THAN",
|
|
182
|
+
durationValue: (dur.power_below_watts as number) ?? null,
|
|
183
|
+
durationValueType: null,
|
|
184
|
+
};
|
|
185
|
+
case "POWER_GREATER_THAN":
|
|
186
|
+
return {
|
|
187
|
+
durationType: "POWER_GREATER_THAN",
|
|
188
|
+
durationValue: (dur.power_above_watts as number) ?? null,
|
|
189
|
+
durationValueType: null,
|
|
190
|
+
};
|
|
191
|
+
case "REPS":
|
|
192
|
+
return {
|
|
193
|
+
durationType: "REPS",
|
|
194
|
+
durationValue: (dur.reps as number) ?? null,
|
|
195
|
+
durationValueType: null,
|
|
196
|
+
};
|
|
197
|
+
case "FIXED_REST":
|
|
198
|
+
return {
|
|
199
|
+
durationType: "FIXED_REST",
|
|
200
|
+
durationValue: (dur.rest_seconds as number) ?? (dur.seconds as number) ?? null,
|
|
201
|
+
durationValueType: null,
|
|
202
|
+
};
|
|
203
|
+
default:
|
|
204
|
+
return { durationType: "OPEN", durationValue: null, durationValueType: null };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Target Extraction ───────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
interface TargetResult {
|
|
211
|
+
targetType: GarminTargetType | null;
|
|
212
|
+
targetValueLow: number | null;
|
|
213
|
+
targetValueHigh: number | null;
|
|
214
|
+
targetValueType: string | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function extractTarget(step: Record<string, unknown>): TargetResult {
|
|
218
|
+
const targets = step.targets as Array<Record<string, unknown>> | undefined;
|
|
219
|
+
if (!targets || targets.length === 0) {
|
|
220
|
+
return { targetType: null, targetValueLow: null, targetValueHigh: null, targetValueType: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const target = targets[0];
|
|
224
|
+
const type = String(target.target_type ?? "OPEN").toUpperCase();
|
|
225
|
+
|
|
226
|
+
switch (type) {
|
|
227
|
+
case "HEART_RATE":
|
|
228
|
+
if (target.hr_percentage_low != null || target.hr_percentage_high != null) {
|
|
229
|
+
return {
|
|
230
|
+
targetType: "HEART_RATE",
|
|
231
|
+
targetValueLow: (target.hr_percentage_low as number) ?? null,
|
|
232
|
+
targetValueHigh: (target.hr_percentage_high as number) ?? null,
|
|
233
|
+
targetValueType: "PERCENT",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
targetType: "HEART_RATE",
|
|
238
|
+
targetValueLow: (target.hr_bpm_low as number) ?? null,
|
|
239
|
+
targetValueHigh: (target.hr_bpm_high as number) ?? null,
|
|
240
|
+
targetValueType: null,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
case "SPEED":
|
|
244
|
+
case "PACE":
|
|
245
|
+
return {
|
|
246
|
+
targetType: "SPEED",
|
|
247
|
+
targetValueLow: (target.speed_meters_per_second as number) ?? null,
|
|
248
|
+
targetValueHigh: (target.speed_meters_per_second as number) ?? null,
|
|
249
|
+
targetValueType: null,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
case "CADENCE":
|
|
253
|
+
return {
|
|
254
|
+
targetType: "CADENCE",
|
|
255
|
+
targetValueLow: (target.cadence_low as number) ?? (target.cadence as number) ?? null,
|
|
256
|
+
targetValueHigh: (target.cadence_high as number) ?? (target.cadence as number) ?? null,
|
|
257
|
+
targetValueType: null,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
case "POWER":
|
|
261
|
+
if (target.power_percentage_low != null || target.power_percentage_high != null) {
|
|
262
|
+
return {
|
|
263
|
+
targetType: "POWER",
|
|
264
|
+
targetValueLow: (target.power_percentage_low as number) ?? null,
|
|
265
|
+
targetValueHigh: (target.power_percentage_high as number) ?? null,
|
|
266
|
+
targetValueType: "PERCENT",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
targetType: "POWER",
|
|
271
|
+
targetValueLow: (target.power_watt_low as number) ?? (target.power_watt as number) ?? null,
|
|
272
|
+
targetValueHigh: (target.power_watt_high as number) ?? (target.power_watt as number) ?? null,
|
|
273
|
+
targetValueType: null,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
case "OPEN":
|
|
277
|
+
return { targetType: "OPEN", targetValueLow: null, targetValueHigh: null, targetValueType: null };
|
|
278
|
+
|
|
279
|
+
default:
|
|
280
|
+
return { targetType: null, targetValueLow: null, targetValueHigh: null, targetValueType: null };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Enum Mapping ────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
const SPORT_MAP: Record<string, GarminWorkoutSport> = {
|
|
287
|
+
RUNNING: "RUNNING",
|
|
288
|
+
BIKING: "CYCLING",
|
|
289
|
+
CYCLING: "CYCLING",
|
|
290
|
+
SWIMMING: "LAP_SWIMMING",
|
|
291
|
+
LAP_SWIMMING: "LAP_SWIMMING",
|
|
292
|
+
STRENGTH_TRAINING: "STRENGTH_TRAINING",
|
|
293
|
+
STRENGTH: "STRENGTH_TRAINING",
|
|
294
|
+
CARDIO: "CARDIO_TRAINING",
|
|
295
|
+
CARDIO_TRAINING: "CARDIO_TRAINING",
|
|
296
|
+
YOGA: "YOGA",
|
|
297
|
+
PILATES: "PILATES",
|
|
298
|
+
MULTI_SPORT: "MULTI_SPORT",
|
|
299
|
+
GENERIC: "GENERIC",
|
|
300
|
+
HIIT: "CARDIO_TRAINING",
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
function mapSportType(type: string | undefined): GarminWorkoutSport {
|
|
304
|
+
if (!type) return "RUNNING";
|
|
305
|
+
return SPORT_MAP[type.toUpperCase()] ?? "GENERIC";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const INTENSITY_MAP: Record<string, GarminStepIntensity> = {
|
|
309
|
+
REST: "REST",
|
|
310
|
+
WARMUP: "WARMUP",
|
|
311
|
+
WARM_UP: "WARMUP",
|
|
312
|
+
COOLDOWN: "COOLDOWN",
|
|
313
|
+
COOL_DOWN: "COOLDOWN",
|
|
314
|
+
RECOVERY: "RECOVERY",
|
|
315
|
+
ACTIVE: "ACTIVE",
|
|
316
|
+
INTERVAL: "INTERVAL",
|
|
317
|
+
MAIN: "MAIN",
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
function mapIntensity(value: unknown): GarminStepIntensity {
|
|
321
|
+
if (value == null) return "ACTIVE";
|
|
322
|
+
const str = String(value).toUpperCase();
|
|
323
|
+
return INTENSITY_MAP[str] ?? "ACTIVE";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function extractRepeatCount(step: Record<string, unknown>): number {
|
|
327
|
+
const durations = step.durations as Array<Record<string, unknown>> | undefined;
|
|
328
|
+
if (durations && durations.length > 0) {
|
|
329
|
+
const reps = durations[0].reps as number | undefined;
|
|
330
|
+
if (reps != null) return reps;
|
|
331
|
+
}
|
|
332
|
+
return 1;
|
|
333
|
+
}
|