@nativesquare/soma 0.6.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 (49) 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 +147 -0
  44. package/src/component/_generated/component.ts +142 -0
  45. package/src/component/garmin.ts +118 -0
  46. package/src/component/public.ts +135 -0
  47. package/src/garmin/client.ts +164 -0
  48. package/src/garmin/plannedWorkout.ts +333 -0
  49. package/src/garmin/types.ts +143 -0
@@ -85,6 +85,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
85
85
  { authUrl: string; codeVerifier: string; state: string },
86
86
  Name
87
87
  >;
88
+ pushPlannedWorkout: FunctionReference<
89
+ "action",
90
+ "internal",
91
+ {
92
+ clientId: string;
93
+ clientSecret: string;
94
+ plannedWorkoutId: string;
95
+ userId: string;
96
+ workoutProvider?: string;
97
+ },
98
+ { garminScheduleId: number | null; garminWorkoutId: number },
99
+ Name
100
+ >;
88
101
  syncGarmin: FunctionReference<
89
102
  "action",
90
103
  "internal",
@@ -123,6 +136,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
123
136
  null,
124
137
  Name
125
138
  >;
139
+ deletePlannedWorkout: FunctionReference<
140
+ "mutation",
141
+ "internal",
142
+ { plannedWorkoutId: string },
143
+ null,
144
+ Name
145
+ >;
126
146
  disconnect: FunctionReference<
127
147
  "mutation",
128
148
  "internal",
@@ -165,6 +185,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
165
185
  },
166
186
  Name
167
187
  >;
188
+ getPlannedWorkout: FunctionReference<
189
+ "query",
190
+ "internal",
191
+ { plannedWorkoutId: string },
192
+ any,
193
+ Name
194
+ >;
168
195
  ingestActivity: FunctionReference<
169
196
  "mutation",
170
197
  "internal",
@@ -1019,6 +1046,89 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1019
1046
  string,
1020
1047
  Name
1021
1048
  >;
1049
+ ingestPlannedWorkout: FunctionReference<
1050
+ "mutation",
1051
+ "internal",
1052
+ {
1053
+ connectionId: string;
1054
+ metadata: {
1055
+ created_date?: string;
1056
+ description?: string;
1057
+ estimated_calories?: number;
1058
+ estimated_distance_meters?: number;
1059
+ estimated_duration_seconds?: number;
1060
+ estimated_elevation_gain_meters?: number;
1061
+ estimated_energy_kj?: number;
1062
+ estimated_if?: number;
1063
+ estimated_pace_minutes_per_kilometer?: number;
1064
+ estimated_speed_meters_per_second?: number;
1065
+ estimated_tscore?: number;
1066
+ estimated_tss?: number;
1067
+ id?: string;
1068
+ name?: string;
1069
+ planned_date?: string;
1070
+ pool_length_meters?: number;
1071
+ provider?: string;
1072
+ type?: string;
1073
+ };
1074
+ steps?: Array<{
1075
+ description?: string;
1076
+ durations?: Array<{
1077
+ calories?: number;
1078
+ distance_meters?: number;
1079
+ duration_type?: string;
1080
+ hr_above_bpm?: number;
1081
+ hr_below_bpm?: number;
1082
+ power_above_watts?: number;
1083
+ power_below_watts?: number;
1084
+ reps?: number;
1085
+ rest_seconds?: number;
1086
+ seconds?: number;
1087
+ steps?: number;
1088
+ }>;
1089
+ equipment_type?: string;
1090
+ exercise_category?: string;
1091
+ exercise_name?: string;
1092
+ intensity?: string | number;
1093
+ name?: string;
1094
+ order?: number;
1095
+ steps?: Array<any>;
1096
+ stroke_type?: string;
1097
+ targets?: Array<{
1098
+ cadence?: number;
1099
+ cadence_high?: number;
1100
+ cadence_low?: number;
1101
+ hr_bpm_high?: number;
1102
+ hr_bpm_low?: number;
1103
+ hr_percentage?: number;
1104
+ hr_percentage_high?: number;
1105
+ hr_percentage_low?: number;
1106
+ if_high?: number;
1107
+ if_low?: number;
1108
+ pace_minutes_per_kilometer?: number;
1109
+ power_percentage?: number;
1110
+ power_percentage_high?: number;
1111
+ power_percentage_low?: number;
1112
+ power_watt?: number;
1113
+ power_watt_high?: number;
1114
+ power_watt_low?: number;
1115
+ repetitions?: number;
1116
+ speed_meters_per_second?: number;
1117
+ speed_percentage?: number;
1118
+ speed_percentage_high?: number;
1119
+ speed_percentage_low?: number;
1120
+ swim_strokes?: number;
1121
+ target_type?: string;
1122
+ tss?: number;
1123
+ }>;
1124
+ type?: string;
1125
+ weight_kg?: number;
1126
+ }>;
1127
+ userId: string;
1128
+ },
1129
+ string,
1130
+ Name
1131
+ >;
1022
1132
  ingestSleep: FunctionReference<
1023
1133
  "mutation",
1024
1134
  "internal",
@@ -1243,6 +1353,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1243
1353
  any,
1244
1354
  Name
1245
1355
  >;
1356
+ listPlannedWorkouts: FunctionReference<
1357
+ "query",
1358
+ "internal",
1359
+ {
1360
+ endDate?: string;
1361
+ limit?: number;
1362
+ order?: "asc" | "desc";
1363
+ startDate?: string;
1364
+ userId: string;
1365
+ },
1366
+ any,
1367
+ Name
1368
+ >;
1246
1369
  listSleep: FunctionReference<
1247
1370
  "query",
1248
1371
  "internal",
@@ -1351,6 +1474,25 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1351
1474
  any,
1352
1475
  Name
1353
1476
  >;
1477
+ paginatePlannedWorkouts: FunctionReference<
1478
+ "query",
1479
+ "internal",
1480
+ {
1481
+ endDate?: string;
1482
+ paginationOpts: {
1483
+ cursor: string | null;
1484
+ endCursor?: string | null;
1485
+ id?: number;
1486
+ maximumBytesRead?: number;
1487
+ maximumRowsRead?: number;
1488
+ numItems: number;
1489
+ };
1490
+ startDate?: string;
1491
+ userId: string;
1492
+ },
1493
+ any,
1494
+ Name
1495
+ >;
1354
1496
  paginateSleep: FunctionReference<
1355
1497
  "query",
1356
1498
  "internal",
@@ -26,6 +26,7 @@ import { transformDaily } from "../garmin/daily.js";
26
26
  import { transformSleep } from "../garmin/sleep.js";
27
27
  import { transformBody } from "../garmin/body.js";
28
28
  import { transformMenstruation } from "../garmin/menstruation.js";
29
+ import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
29
30
 
30
31
  // Use anyApi to avoid circular type references between this file and _generated/api.ts.
31
32
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -562,6 +563,123 @@ export const disconnectGarmin = action({
562
563
  },
563
564
  });
564
565
 
566
+ // ─── Training API ────────────────────────────────────────────────────────────
567
+
568
+ /**
569
+ * Push a planned workout from Soma's DB to Garmin Connect.
570
+ *
571
+ * Reads the planned workout document, transforms it to Garmin Training API V2
572
+ * format, creates the workout at Garmin, and optionally schedules it if a
573
+ * `planned_date` is set in the metadata.
574
+ *
575
+ * Returns the Garmin workout ID and schedule ID (if scheduled).
576
+ */
577
+ export const pushPlannedWorkout = action({
578
+ args: {
579
+ userId: v.string(),
580
+ clientId: v.string(),
581
+ clientSecret: v.string(),
582
+ plannedWorkoutId: v.string(),
583
+ workoutProvider: v.optional(v.string()),
584
+ },
585
+ returns: v.object({
586
+ garminWorkoutId: v.number(),
587
+ garminScheduleId: v.union(v.number(), v.null()),
588
+ }),
589
+ handler: async (ctx, args) => {
590
+ const connection = await ctx.runQuery(
591
+ internalApi.private.getConnectionByProvider,
592
+ { userId: args.userId, provider: "GARMIN" },
593
+ );
594
+ if (!connection) {
595
+ throw new Error(
596
+ `No Garmin connection found for user "${args.userId}". ` +
597
+ "Call connectGarmin first.",
598
+ );
599
+ }
600
+ if (!connection.active) {
601
+ throw new Error(
602
+ `Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
603
+ );
604
+ }
605
+
606
+ const connectionId = connection._id;
607
+
608
+ const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
609
+ connectionId,
610
+ });
611
+ if (!tokenDoc) {
612
+ throw new Error(
613
+ "No Garmin tokens found for this connection. " +
614
+ "The connection may have been created before token storage was available.",
615
+ );
616
+ }
617
+
618
+ let accessToken = tokenDoc.accessToken;
619
+
620
+ const nowSeconds = Math.floor(Date.now() / 1000);
621
+ if (
622
+ tokenDoc.expiresAt &&
623
+ tokenDoc.refreshToken &&
624
+ nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
625
+ ) {
626
+ const refreshed = await refreshToken({
627
+ clientId: args.clientId,
628
+ clientSecret: args.clientSecret,
629
+ refreshToken: tokenDoc.refreshToken,
630
+ });
631
+
632
+ accessToken = refreshed.access_token;
633
+ const newExpiresAt = nowSeconds + refreshed.expires_in;
634
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
635
+ connectionId,
636
+ accessToken: refreshed.access_token,
637
+ refreshToken: refreshed.refresh_token,
638
+ expiresAt: newExpiresAt,
639
+ });
640
+ }
641
+
642
+ const plannedWorkout = await ctx.runQuery(
643
+ publicApi.public.getPlannedWorkout,
644
+ { plannedWorkoutId: args.plannedWorkoutId as never },
645
+ );
646
+ if (!plannedWorkout) {
647
+ throw new Error(
648
+ `Planned workout "${args.plannedWorkoutId}" not found.`,
649
+ );
650
+ }
651
+
652
+ const providerName = args.workoutProvider ?? "Soma";
653
+ const garminWorkout = transformPlannedWorkoutToGarmin(
654
+ plannedWorkout,
655
+ providerName,
656
+ );
657
+
658
+ const client = new GarminClient({ accessToken });
659
+ const created = await client.createWorkout(garminWorkout);
660
+
661
+ if (!created.workoutId) {
662
+ throw new Error("Garmin API did not return a workoutId after creation.");
663
+ }
664
+
665
+ let garminScheduleId: number | null = null;
666
+
667
+ const plannedDate = plannedWorkout.metadata?.planned_date;
668
+ if (plannedDate) {
669
+ const schedule = await client.createSchedule(
670
+ created.workoutId,
671
+ plannedDate,
672
+ );
673
+ garminScheduleId = schedule.scheduleId ?? null;
674
+ }
675
+
676
+ return {
677
+ garminWorkoutId: created.workoutId,
678
+ garminScheduleId,
679
+ };
680
+ },
681
+ });
682
+
565
683
  // ─── Internal Helpers ────────────────────────────────────────────────────────
566
684
 
567
685
  interface SyncAllConfig {
@@ -8,6 +8,7 @@ import { dailyValidator } from "./validators/daily.js";
8
8
  import { sleepValidator } from "./validators/sleep.js";
9
9
  import { menstruationValidator } from "./validators/menstruation.js";
10
10
  import { nutritionValidator } from "./validators/nutrition.js";
11
+ import { plannedWorkoutValidator } from "./validators/plannedWorkout.js";
11
12
 
12
13
  // ─── Return Validators ──────────────────────────────────────────────────────
13
14
 
@@ -776,6 +777,140 @@ export const paginateMenstruation = query({
776
777
  },
777
778
  });
778
779
 
780
+ // ── Planned Workouts ────────────────────────────────────────────────────────
781
+
782
+ /**
783
+ * Ingest a planned workout record.
784
+ *
785
+ * Upserts by `connectionId + metadata.id` when an id is present.
786
+ * Falls back to insert if no id is provided.
787
+ */
788
+ export const ingestPlannedWorkout = mutation({
789
+ args: plannedWorkoutValidator,
790
+ returns: v.id("plannedWorkouts"),
791
+ handler: async (ctx, args) => {
792
+ const metadataId = args.metadata.id;
793
+ if (metadataId) {
794
+ const results = await ctx.db
795
+ .query("plannedWorkouts")
796
+ .withIndex("by_connectionId", (q) =>
797
+ q.eq("connectionId", args.connectionId),
798
+ )
799
+ .collect();
800
+ const existing = results.find((r) => r.metadata.id === metadataId);
801
+
802
+ if (existing) {
803
+ await ctx.db.patch(existing._id, args);
804
+ return existing._id;
805
+ }
806
+ }
807
+ return await ctx.db.insert("plannedWorkouts", args);
808
+ },
809
+ });
810
+
811
+ /**
812
+ * List planned workout records for a user, optionally filtered by planned date range.
813
+ *
814
+ * @param args.userId - The host app's user identifier
815
+ * @param args.startDate - Optional lower bound (inclusive) on metadata.planned_date (YYYY-MM-DD)
816
+ * @param args.endDate - Optional upper bound (inclusive) on metadata.planned_date (YYYY-MM-DD)
817
+ * @param args.order - Sort order: "asc" or "desc" (default: "desc")
818
+ * @param args.limit - Optional max number of results to return
819
+ */
820
+ export const listPlannedWorkouts = query({
821
+ args: {
822
+ userId: v.string(),
823
+ startDate: v.optional(v.string()),
824
+ endDate: v.optional(v.string()),
825
+ order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
826
+ limit: v.optional(v.number()),
827
+ },
828
+ handler: async (ctx, args) => {
829
+ const q = ctx.db
830
+ .query("plannedWorkouts")
831
+ .withIndex("by_userId_plannedDate", (q) => {
832
+ const base = q.eq("userId", args.userId);
833
+ if (args.startDate !== undefined && args.endDate !== undefined) {
834
+ return base
835
+ .gte("metadata.planned_date", args.startDate)
836
+ .lte("metadata.planned_date", args.endDate);
837
+ }
838
+ if (args.startDate !== undefined) {
839
+ return base.gte("metadata.planned_date", args.startDate);
840
+ }
841
+ if (args.endDate !== undefined) {
842
+ return base.lte("metadata.planned_date", args.endDate);
843
+ }
844
+ return base;
845
+ })
846
+ .order(args.order ?? "desc");
847
+ return args.limit ? await q.take(args.limit) : await q.collect();
848
+ },
849
+ });
850
+
851
+ /**
852
+ * Paginate planned workout records for a user, optionally filtered by planned date range.
853
+ *
854
+ * Returns `{ page, isDone, continueCursor }` for cursor-based pagination.
855
+ */
856
+ export const paginatePlannedWorkouts = query({
857
+ args: {
858
+ userId: v.string(),
859
+ startDate: v.optional(v.string()),
860
+ endDate: v.optional(v.string()),
861
+ paginationOpts: paginationOptsValidator,
862
+ },
863
+ handler: async (ctx, args) => {
864
+ return await ctx.db
865
+ .query("plannedWorkouts")
866
+ .withIndex("by_userId_plannedDate", (q) => {
867
+ const base = q.eq("userId", args.userId);
868
+ if (args.startDate !== undefined && args.endDate !== undefined) {
869
+ return base
870
+ .gte("metadata.planned_date", args.startDate)
871
+ .lte("metadata.planned_date", args.endDate);
872
+ }
873
+ if (args.startDate !== undefined) {
874
+ return base.gte("metadata.planned_date", args.startDate);
875
+ }
876
+ if (args.endDate !== undefined) {
877
+ return base.lte("metadata.planned_date", args.endDate);
878
+ }
879
+ return base;
880
+ })
881
+ .order("desc")
882
+ .paginate(args.paginationOpts);
883
+ },
884
+ });
885
+
886
+ /**
887
+ * Delete a planned workout by document ID.
888
+ */
889
+ export const deletePlannedWorkout = mutation({
890
+ args: { plannedWorkoutId: v.id("plannedWorkouts") },
891
+ returns: v.null(),
892
+ handler: async (ctx, args) => {
893
+ const existing = await ctx.db.get(args.plannedWorkoutId);
894
+ if (!existing) {
895
+ throw new Error(
896
+ `Planned workout "${args.plannedWorkoutId}" not found`,
897
+ );
898
+ }
899
+ await ctx.db.delete(existing._id);
900
+ return null;
901
+ },
902
+ });
903
+
904
+ /**
905
+ * Get a single planned workout by document ID.
906
+ */
907
+ export const getPlannedWorkout = query({
908
+ args: { plannedWorkoutId: v.id("plannedWorkouts") },
909
+ handler: async (ctx, args) => {
910
+ return await ctx.db.get(args.plannedWorkoutId);
911
+ },
912
+ });
913
+
779
914
  // ── Athletes ────────────────────────────────────────────────────────────────
780
915
 
781
916
  /**
@@ -8,6 +8,8 @@ import type {
8
8
  GarminSleep,
9
9
  GarminBodyComposition,
10
10
  GarminMenstrualCycleData,
11
+ GarminWorkout,
12
+ GarminWorkoutSchedule,
11
13
  } from "./types.js";
12
14
 
13
15
  const DEFAULT_BASE_URL = "https://apis.garmin.com";
@@ -146,6 +148,101 @@ export class GarminClient {
146
148
  );
147
149
  }
148
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
+
149
246
  // ─── User Deregistration ──────────────────────────────────────────────
150
247
 
151
248
  /**
@@ -204,6 +301,73 @@ export class GarminClient {
204
301
 
205
302
  return (await response.json()) as T;
206
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
+ }
207
371
  }
208
372
 
209
373
  // ─── Helpers ──────────────────────────────────────────────────────────────────