trainerroad-cli 0.1.1 → 0.3.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.
@@ -0,0 +1,310 @@
1
+ const ALTERNATE_CATEGORIES = new Set(["similar", "easier", "harder", "longer", "shorter"]);
2
+ const SWITCH_MODES = new Set(["inside", "outside"]);
3
+
4
+ function toIsoDateFromApiDate(date) {
5
+ if (!date || typeof date !== "object") return null;
6
+ const year = String(date.year ?? "").padStart(4, "0");
7
+ const month = String(date.month ?? "").padStart(2, "0");
8
+ const day = String(date.day ?? "").padStart(2, "0");
9
+ if (!year.trim() || !month.trim() || !day.trim()) return null;
10
+ return `${year}-${month}-${day}`;
11
+ }
12
+
13
+ function summarizePlannedActivity(activity) {
14
+ if (!activity || typeof activity !== "object") return null;
15
+ const workout = activity.workout ?? null;
16
+ return {
17
+ plannedActivityId: activity.id ?? null,
18
+ date: toIsoDateFromApiDate(activity.date),
19
+ timeOfDay: activity.timeOfDay ?? null,
20
+ workoutId: workout?.id ?? null,
21
+ workoutName: workout?.name ?? activity.name ?? null,
22
+ isOutside: workout?.isOutside ?? null,
23
+ durationMinutes:
24
+ workout?.duration ??
25
+ (Number.isFinite(Number(activity.durationInSeconds))
26
+ ? Math.round(Number(activity.durationInSeconds) / 60)
27
+ : null),
28
+ tss: activity.tss ?? workout?.tss ?? null,
29
+ canMove: activity.canMove ?? null,
30
+ recommendationReason: activity.recommendationReason ?? null,
31
+ };
32
+ }
33
+
34
+ function summarizeAlternateWorkout(workout) {
35
+ if (!workout || typeof workout !== "object") return null;
36
+ return {
37
+ workoutId: workout.id ?? null,
38
+ workoutName: workout.workoutName ?? workout.name ?? null,
39
+ durationMinutes: workout.duration ?? null,
40
+ tss: workout.tss ?? null,
41
+ intensityFactor: workout.intensityFactor ?? null,
42
+ prescribedLevel: workout.prescribedLevel ?? null,
43
+ isOutside: workout.isOutside ?? null,
44
+ hasOutsideEquivalent: workout.hasOutsideEquivalent ?? null,
45
+ zoneId: workout.zoneId ?? null,
46
+ profileId: workout.profileId ?? null,
47
+ };
48
+ }
49
+
50
+ async function requirePrivateMember(flags, deps) {
51
+ const { withClient } = deps;
52
+ const client = await withClient(flags);
53
+ let memberInfo;
54
+ try {
55
+ memberInfo = await client.getMemberInfo();
56
+ } catch {
57
+ throw new Error(
58
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
59
+ );
60
+ }
61
+ return { client, memberInfo };
62
+ }
63
+
64
+ export async function commandWorkoutAlternates(flags, deps) {
65
+ const { isJsonMode, requireFlag, writeOutput } = deps;
66
+ const category = String(flags.category ?? "similar").toLowerCase();
67
+ const plannedActivityId = String(requireFlag("workout-alternates", flags, "id"));
68
+ if (!ALTERNATE_CATEGORIES.has(category)) {
69
+ throw new Error(
70
+ `Invalid --category "${category}". Expected one of: ${Array.from(ALTERNATE_CATEGORIES).join(", ")}.`,
71
+ );
72
+ }
73
+
74
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
75
+ const plannedActivity = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
76
+ const alternates = await client.getPlannedActivityAlternates(
77
+ plannedActivityId,
78
+ category,
79
+ memberInfo.username,
80
+ );
81
+ const records = (Array.isArray(alternates?.workouts) ? alternates.workouts : [])
82
+ .map((item) => summarizeAlternateWorkout(item))
83
+ .filter(Boolean);
84
+
85
+ const payload = {
86
+ generatedAt: new Date().toISOString(),
87
+ command: "workout-alternates",
88
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
89
+ query: { plannedActivityId, category },
90
+ plannedActivity: summarizePlannedActivity(plannedActivity),
91
+ count: records.length,
92
+ records,
93
+ };
94
+
95
+ if (!isJsonMode(flags)) {
96
+ await writeOutput(payload, flags, (value) => {
97
+ const lines = [
98
+ `Alternates (${value.count}) for ${value.plannedActivity?.workoutName ?? "workout"} [category=${value.query.category}]`,
99
+ ];
100
+ for (const item of value.records) {
101
+ lines.push(
102
+ `- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"} | outside=${item.isOutside === null ? "?" : item.isOutside}`,
103
+ );
104
+ }
105
+ return lines.join("\n");
106
+ });
107
+ return;
108
+ }
109
+
110
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
111
+ }
112
+
113
+ export async function commandMoveWorkout(flags, deps) {
114
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
115
+ const dryRun = toBoolean(flags["dry-run"], false);
116
+ const plannedActivityId = String(requireFlag("move-workout", flags, "id"));
117
+ const newDate = String(requireFlag("move-workout", flags, "to"));
118
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
119
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
120
+ const beforeSummary = summarizePlannedActivity(before);
121
+ const noop = beforeSummary?.date === newDate;
122
+
123
+ if (dryRun || noop) {
124
+ const payload = {
125
+ generatedAt: new Date().toISOString(),
126
+ command: "move-workout",
127
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
128
+ query: { plannedActivityId, to: newDate },
129
+ dryRun,
130
+ noop,
131
+ before: beforeSummary,
132
+ after: noop ? beforeSummary : { ...beforeSummary, date: newDate },
133
+ mutation: null,
134
+ message: noop
135
+ ? `Workout is already scheduled on ${newDate}.`
136
+ : `Would move ${beforeSummary?.workoutName ?? "workout"} to ${newDate}.`,
137
+ };
138
+
139
+ if (!isJsonMode(flags)) {
140
+ await writeOutput(payload, flags, (value) => {
141
+ if (value.noop) {
142
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} is already on ${value.query.to}`;
143
+ }
144
+ return `Would move ${value.before?.workoutName ?? "workout"} | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.date ?? "?"} -> ${value.query.to}\nNo changes made.`;
145
+ });
146
+ return;
147
+ }
148
+
149
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
150
+ return;
151
+ }
152
+
153
+ const mutation = await client.movePlannedActivity(plannedActivityId, newDate, memberInfo.username);
154
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
155
+
156
+ const payload = {
157
+ generatedAt: new Date().toISOString(),
158
+ command: "move-workout",
159
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
160
+ query: { plannedActivityId, to: newDate },
161
+ before: summarizePlannedActivity(before),
162
+ after: summarizePlannedActivity(after),
163
+ mutation,
164
+ };
165
+
166
+ if (!isJsonMode(flags)) {
167
+ await writeOutput(payload, flags, (value) => {
168
+ return `Moved ${value.after?.workoutName ?? "workout"} | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.date ?? "?"} -> ${value.after?.date ?? "?"}`;
169
+ });
170
+ return;
171
+ }
172
+
173
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
174
+ }
175
+
176
+ export async function commandReplaceWorkout(flags, deps) {
177
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
178
+ const dryRun = toBoolean(flags["dry-run"], false);
179
+ const plannedActivityId = String(requireFlag("replace-workout", flags, "id"));
180
+ const alternateWorkoutId = Number(requireFlag("replace-workout", flags, "alternate-id"));
181
+ if (!Number.isFinite(alternateWorkoutId)) {
182
+ throw new Error(`Invalid --alternate-id "${flags["alternate-id"]}". Expected a numeric workout ID.`);
183
+ }
184
+ const updateDuration = toBoolean(flags["update-duration"], false);
185
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
186
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
187
+ const beforeSummary = summarizePlannedActivity(before);
188
+ const noop = Number(beforeSummary?.workoutId) === alternateWorkoutId;
189
+
190
+ if (dryRun || noop) {
191
+ const payload = {
192
+ generatedAt: new Date().toISOString(),
193
+ command: "replace-workout",
194
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
195
+ query: { plannedActivityId, alternateWorkoutId, updateDuration },
196
+ dryRun,
197
+ noop,
198
+ before: beforeSummary,
199
+ after: noop ? beforeSummary : null,
200
+ mutation: null,
201
+ message: noop
202
+ ? `Workout already uses alternate workout ${alternateWorkoutId}.`
203
+ : `Would replace workout ${beforeSummary?.workoutId ?? "?"} with ${alternateWorkoutId}.`,
204
+ };
205
+
206
+ if (!isJsonMode(flags)) {
207
+ await writeOutput(payload, flags, (value) => {
208
+ if (value.noop) {
209
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} already uses workoutId=${value.query.alternateWorkoutId}`;
210
+ }
211
+ return `Would replace workout | plannedActivityId=${value.query.plannedActivityId} | workoutId=${value.before?.workoutId ?? "?"} -> alternateWorkoutId=${value.query.alternateWorkoutId}\nNo changes made.`;
212
+ });
213
+ return;
214
+ }
215
+
216
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
217
+ return;
218
+ }
219
+
220
+ const mutation = await client.replacePlannedActivityWithAlternate(plannedActivityId, alternateWorkoutId, {
221
+ updateDuration,
222
+ usernameForReferer: memberInfo.username,
223
+ });
224
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
225
+
226
+ const payload = {
227
+ generatedAt: new Date().toISOString(),
228
+ command: "replace-workout",
229
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
230
+ query: { plannedActivityId, alternateWorkoutId, updateDuration },
231
+ before: summarizePlannedActivity(before),
232
+ after: summarizePlannedActivity(after),
233
+ mutation: summarizePlannedActivity(mutation),
234
+ };
235
+
236
+ if (!isJsonMode(flags)) {
237
+ await writeOutput(payload, flags, (value) => {
238
+ return `Replaced workout | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.workoutName ?? "?"} -> ${value.after?.workoutName ?? "?"}`;
239
+ });
240
+ return;
241
+ }
242
+
243
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
244
+ }
245
+
246
+ export async function commandSwitchWorkout(flags, deps) {
247
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
248
+ const dryRun = toBoolean(flags["dry-run"], false);
249
+ const plannedActivityId = String(requireFlag("switch-workout", flags, "id"));
250
+ const mode = String(requireFlag("switch-workout", flags, "mode")).toLowerCase();
251
+ if (!SWITCH_MODES.has(mode)) {
252
+ throw new Error(`Invalid --mode "${mode}". Expected one of: ${Array.from(SWITCH_MODES).join(", ")}.`);
253
+ }
254
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
255
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
256
+ const beforeSummary = summarizePlannedActivity(before);
257
+ const noop = beforeSummary?.isOutside === (mode === "outside");
258
+
259
+ if (dryRun || noop) {
260
+ const payload = {
261
+ generatedAt: new Date().toISOString(),
262
+ command: "switch-workout",
263
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
264
+ query: { plannedActivityId, mode },
265
+ dryRun,
266
+ noop,
267
+ before: beforeSummary,
268
+ after: noop ? beforeSummary : { ...beforeSummary, isOutside: mode === "outside" },
269
+ mutation: null,
270
+ message: noop
271
+ ? `Workout is already in ${mode} mode.`
272
+ : `Would switch workout to ${mode} mode.`,
273
+ };
274
+
275
+ if (!isJsonMode(flags)) {
276
+ await writeOutput(payload, flags, (value) => {
277
+ if (value.noop) {
278
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} is already ${value.query.mode}`;
279
+ }
280
+ return `Would switch workout | plannedActivityId=${value.query.plannedActivityId} | outside=${value.before?.isOutside} -> ${value.query.mode === "outside"}\nNo changes made.`;
281
+ });
282
+ return;
283
+ }
284
+
285
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
286
+ return;
287
+ }
288
+
289
+ const mutation = await client.switchPlannedActivityMode(plannedActivityId, mode, memberInfo.username);
290
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
291
+
292
+ const payload = {
293
+ generatedAt: new Date().toISOString(),
294
+ command: "switch-workout",
295
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
296
+ query: { plannedActivityId, mode },
297
+ before: summarizePlannedActivity(before),
298
+ after: summarizePlannedActivity(after),
299
+ mutation: summarizePlannedActivity(mutation),
300
+ };
301
+
302
+ if (!isJsonMode(flags)) {
303
+ await writeOutput(payload, flags, (value) => {
304
+ return `Switched workout | plannedActivityId=${value.query.plannedActivityId} | outside=${value.before?.isOutside} -> ${value.after?.isOutside}`;
305
+ });
306
+ return;
307
+ }
308
+
309
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
310
+ }
@@ -0,0 +1,175 @@
1
+ import { queryWorkoutLibrary } from "./workout-library.mjs";
2
+
3
+ async function requirePrivateMember(flags, deps) {
4
+ const { withClient } = deps;
5
+ const client = await withClient(flags);
6
+ try {
7
+ const memberInfo = await client.getMemberInfo();
8
+ return { client, memberInfo };
9
+ } catch {
10
+ throw new Error(
11
+ "workout-recommend requires private authenticated mode. Login first with trainerroad-cli login.",
12
+ );
13
+ }
14
+ }
15
+
16
+ function numericOrNull(value, deps) {
17
+ if (value == null) return null;
18
+ return deps.requireNumber(value, null);
19
+ }
20
+
21
+ function deriveTarget(explicit, min, max) {
22
+ if (explicit != null) return explicit;
23
+ if (min != null && max != null) return (min + max) / 2;
24
+ return null;
25
+ }
26
+
27
+ function compareValues(left, right) {
28
+ if (left === right) return 0;
29
+ if (left == null) return 1;
30
+ if (right == null) return -1;
31
+ if (typeof left === "string" || typeof right === "string") {
32
+ return String(left).localeCompare(String(right));
33
+ }
34
+ return Number(left) - Number(right);
35
+ }
36
+
37
+ function scoreWorkout(item, targets) {
38
+ let score = 0;
39
+ const reasons = [];
40
+
41
+ if (targets.level != null && item.progressionLevel != null) {
42
+ const delta = Math.abs(item.progressionLevel - targets.level);
43
+ score += delta * 10;
44
+ reasons.push({ metric: "level", target: targets.level, actual: item.progressionLevel, delta });
45
+ }
46
+
47
+ if (targets.duration != null && item.durationMinutes != null) {
48
+ const delta = Math.abs(item.durationMinutes - targets.duration);
49
+ score += delta / 5;
50
+ reasons.push({ metric: "duration", target: targets.duration, actual: item.durationMinutes, delta });
51
+ }
52
+
53
+ if (targets.tss != null && item.tss != null) {
54
+ const delta = Math.abs(item.tss - targets.tss);
55
+ score += delta / 10;
56
+ reasons.push({ metric: "tss", target: targets.tss, actual: item.tss, delta });
57
+ }
58
+
59
+ if (targets.searchText) {
60
+ const text = String(item.workoutName ?? "").toLowerCase();
61
+ const query = String(targets.searchText).toLowerCase();
62
+ if (text === query) score -= 2;
63
+ else if (text.includes(query)) score -= 1;
64
+ }
65
+
66
+ if (item.hasInstructions) score -= 0.15;
67
+ if (item.isOutside === false) score -= 0.05;
68
+
69
+ return { score, reasons };
70
+ }
71
+
72
+ export async function commandWorkoutRecommend(flags, deps) {
73
+ const { isJsonMode, requirePositiveInteger, requireNumber, toBoolean, writeOutput } = deps;
74
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
75
+
76
+ const count = requirePositiveInteger(flags.count, 5);
77
+ const candidateLimit = requirePositiveInteger(flags["candidate-limit"], 100);
78
+ const minDuration = numericOrNull(flags["min-duration"], deps);
79
+ const maxDuration = numericOrNull(flags["max-duration"], deps);
80
+ const minTss = numericOrNull(flags["min-tss"], deps);
81
+ const maxTss = numericOrNull(flags["max-tss"], deps);
82
+ const minLevel = numericOrNull(flags["min-level"], deps);
83
+ const maxLevel = numericOrNull(flags["max-level"], deps);
84
+ const targetDuration = deriveTarget(numericOrNull(flags["target-duration"], deps), minDuration, maxDuration);
85
+ const targetTss = deriveTarget(numericOrNull(flags["target-tss"], deps), minTss, maxTss);
86
+ const targetLevel = deriveTarget(numericOrNull(flags["target-level"], deps), minLevel, maxLevel);
87
+ const searchText = String(flags.search ?? "").trim();
88
+
89
+ const library = await queryWorkoutLibrary(client, memberInfo, {
90
+ searchText,
91
+ zone: flags.zone,
92
+ zoneId: flags["zone-id"],
93
+ profile: flags.profile,
94
+ profileId: flags["profile-id"],
95
+ outside: flags.outside == null ? null : toBoolean(flags.outside, false),
96
+ hasInstructions:
97
+ flags["has-instructions"] == null ? null : toBoolean(flags["has-instructions"], false),
98
+ minDuration,
99
+ maxDuration,
100
+ minTss,
101
+ maxTss,
102
+ minLevel,
103
+ maxLevel,
104
+ sort: String(flags.sort ?? "level"),
105
+ limit: candidateLimit,
106
+ pageSize: requirePositiveInteger(flags["page-size"], 50),
107
+ });
108
+
109
+ const targets = {
110
+ duration: targetDuration,
111
+ tss: targetTss,
112
+ level: targetLevel,
113
+ searchText,
114
+ };
115
+
116
+ const recommendations = library.records
117
+ .map((item) => {
118
+ const ranking = scoreWorkout(item, targets);
119
+ return { ...item, recommendationScore: ranking.score, rationale: ranking.reasons };
120
+ })
121
+ .sort(
122
+ (a, b) =>
123
+ compareValues(a.recommendationScore, b.recommendationScore) ||
124
+ compareValues(a.progressionLevel, b.progressionLevel) ||
125
+ compareValues(a.durationMinutes, b.durationMinutes) ||
126
+ compareValues(a.workoutName, b.workoutName),
127
+ )
128
+ .slice(0, count);
129
+
130
+ const payload = {
131
+ generatedAt: new Date().toISOString(),
132
+ command: "workout-recommend",
133
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
134
+ query: {
135
+ searchText,
136
+ zone: flags.zone ?? null,
137
+ zoneId: flags["zone-id"] ?? null,
138
+ profile: flags.profile ?? null,
139
+ profileId: flags["profile-id"] ?? null,
140
+ outside: flags.outside ?? null,
141
+ hasInstructions: flags["has-instructions"] ?? null,
142
+ minDuration,
143
+ maxDuration,
144
+ minTss,
145
+ maxTss,
146
+ minLevel,
147
+ maxLevel,
148
+ targetDuration,
149
+ targetTss,
150
+ targetLevel,
151
+ count,
152
+ candidateLimit,
153
+ },
154
+ fetch: library.fetch,
155
+ recommendationCount: recommendations.length,
156
+ records: recommendations,
157
+ };
158
+
159
+ if (!isJsonMode(flags)) {
160
+ await writeOutput(payload, flags, (value) => {
161
+ const lines = [
162
+ `Workout recommendations (${value.recommendationCount}) candidates=${value.fetch.fetchedCount}/${value.fetch.serverTotalCount ?? "?"}`,
163
+ ];
164
+ for (const item of value.records) {
165
+ lines.push(
166
+ `- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | score=${item.recommendationScore.toFixed(2)} | level=${item.progressionLevel ?? "?"} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"}`,
167
+ );
168
+ }
169
+ return lines.join("\n");
170
+ });
171
+ return;
172
+ }
173
+
174
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
175
+ }