@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.
- 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 +147 -0
- package/src/component/_generated/component.ts +142 -0
- package/src/component/garmin.ts +118 -0
- package/src/component/public.ts +135 -0
- package/src/garmin/client.ts +164 -0
- package/src/garmin/plannedWorkout.ts +333 -0
- 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",
|
package/src/component/garmin.ts
CHANGED
|
@@ -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 {
|
package/src/component/public.ts
CHANGED
|
@@ -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
|
/**
|
package/src/garmin/client.ts
CHANGED
|
@@ -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 ──────────────────────────────────────────────────────────────────
|