@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.
Files changed (54) hide show
  1. package/dist/client/index.d.ts +151 -53
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +162 -69
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +130 -17
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +61 -43
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +208 -122
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/public.d.ts +363 -0
  12. package/dist/component/public.d.ts.map +1 -1
  13. package/dist/component/public.js +124 -0
  14. package/dist/component/public.js.map +1 -1
  15. package/dist/component/schema.d.ts +7 -9
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +9 -10
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +0 -1
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +0 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/enums.d.ts +1 -1
  24. package/dist/garmin/auth.d.ts +55 -46
  25. package/dist/garmin/auth.d.ts.map +1 -1
  26. package/dist/garmin/auth.js +82 -122
  27. package/dist/garmin/auth.js.map +1 -1
  28. package/dist/garmin/client.d.ts +64 -17
  29. package/dist/garmin/client.d.ts.map +1 -1
  30. package/dist/garmin/client.js +143 -29
  31. package/dist/garmin/client.js.map +1 -1
  32. package/dist/garmin/index.d.ts +3 -3
  33. package/dist/garmin/index.d.ts.map +1 -1
  34. package/dist/garmin/index.js +4 -4
  35. package/dist/garmin/index.js.map +1 -1
  36. package/dist/garmin/plannedWorkout.d.ts +12 -0
  37. package/dist/garmin/plannedWorkout.d.ts.map +1 -0
  38. package/dist/garmin/plannedWorkout.js +267 -0
  39. package/dist/garmin/plannedWorkout.js.map +1 -0
  40. package/dist/garmin/types.d.ts +78 -6
  41. package/dist/garmin/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.ts +236 -85
  44. package/src/component/_generated/component.ts +155 -17
  45. package/src/component/garmin.ts +258 -124
  46. package/src/component/public.ts +135 -0
  47. package/src/component/schema.ts +9 -10
  48. package/src/component/strava.ts +0 -1
  49. package/src/garmin/auth.test.ts +71 -96
  50. package/src/garmin/auth.ts +129 -193
  51. package/src/garmin/client.ts +197 -51
  52. package/src/garmin/index.ts +13 -14
  53. package/src/garmin/plannedWorkout.ts +333 -0
  54. package/src/garmin/types.ts +149 -7
@@ -1,7 +1,6 @@
1
1
  // ─── Garmin Health API Client ────────────────────────────────────────────────
2
2
  // Lightweight, fetch-based client for the Garmin Health API.
3
- // Every request is signed with OAuth 1.0a using the consumer and user tokens.
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
- /** Your application's consumer key (from Garmin Developer Portal). */
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
- * Defaults to `https://apis.garmin.com`.
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 signed with OAuth 1.0a. Time-range parameters
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
- * consumerKey: "your_key",
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: buildOAuthHeader(oauthParams),
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 ──────────────────────────────────────────────────────────────────
@@ -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 OAuth 1.0a HMAC-SHA1 signing.
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
- getRequestToken,
34
- getAccessToken,
35
- buildOAuthSignature,
36
- buildOAuthHeader,
37
- percentEncode,
38
- generateNonce,
39
- getTimestamp,
33
+ generateCodeVerifier,
34
+ generateCodeChallenge,
35
+ generateState,
36
+ buildAuthUrl,
37
+ exchangeCode,
38
+ refreshToken,
40
39
  } from "./auth.js";
41
40
  export type {
42
- GetRequestTokenOptions,
43
- GetAccessTokenOptions,
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
- GarminOAuthRequestTokenResponse,
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
+ }