trainerroad-cli 0.1.0 → 0.2.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/README.md +60 -11
- package/package.json +11 -1
- package/src/cli.mjs +119 -12
- package/src/commands/discovery.mjs +7 -0
- package/src/commands/train-now.mjs +142 -0
- package/src/commands/workout-library.mjs +430 -0
- package/src/commands/workout-mutations.mjs +230 -0
- package/src/commands/workout-recommend.mjs +175 -0
- package/src/commands/workout-tools.mjs +326 -0
- package/src/commands/workouts.mjs +41 -9
- package/src/lib/agent-filters.mjs +9 -2
- package/src/lib/command-manifest.mjs +181 -3
- package/src/lib/planning-normalizers.mjs +9 -2
- package/src/lib/timezone.mjs +187 -0
- package/src/trainerroad-client.mjs +299 -5
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
2
|
+
const DEFAULT_LIMIT = 50;
|
|
3
|
+
|
|
4
|
+
const DURATION_BUCKETS = [
|
|
5
|
+
{ key: "lessThanFortyFive", min: 0, max: 44 },
|
|
6
|
+
{ key: "fortyFive", min: 45, max: 45 },
|
|
7
|
+
{ key: "oneHour", min: 60, max: 60 },
|
|
8
|
+
{ key: "oneHourFifteen", min: 75, max: 75 },
|
|
9
|
+
{ key: "oneHourThirty", min: 90, max: 90 },
|
|
10
|
+
{ key: "oneHourFortyFive", min: 105, max: 105 },
|
|
11
|
+
{ key: "twoHours", min: 120, max: 120 },
|
|
12
|
+
{ key: "twoHoursFifteen", min: 135, max: 135 },
|
|
13
|
+
{ key: "twoHoursThirty", min: 150, max: 150 },
|
|
14
|
+
{ key: "moreThanTwoHoursThirty", min: 151, max: Number.POSITIVE_INFINITY },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function splitCsv(value) {
|
|
18
|
+
return String(value ?? "")
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((item) => item.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripHtml(value) {
|
|
25
|
+
return String(value ?? "")
|
|
26
|
+
.replace(/<[^>]+>/g, " ")
|
|
27
|
+
.replace(/ /gi, " ")
|
|
28
|
+
.replace(/&/gi, "&")
|
|
29
|
+
.replace(/"/gi, '"')
|
|
30
|
+
.replace(/'/gi, "'")
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toCatalog(profilesByZone) {
|
|
36
|
+
const rows = Array.isArray(profilesByZone) ? profilesByZone : [];
|
|
37
|
+
return rows.map((zone) => ({
|
|
38
|
+
zoneId: zone.id ?? null,
|
|
39
|
+
zoneName: zone.name ?? null,
|
|
40
|
+
profiles: (Array.isArray(zone.workoutProfileOptions) ? zone.workoutProfileOptions : []).map((profile) => ({
|
|
41
|
+
profileId: profile.id ?? null,
|
|
42
|
+
profileName: profile.name ?? null,
|
|
43
|
+
durations: Array.isArray(profile.durations) ? profile.durations : [],
|
|
44
|
+
})),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveIdsByNameOrId(kind, catalog, rawNames, rawIds, valueGetter) {
|
|
49
|
+
const explicitIds = splitCsv(rawIds)
|
|
50
|
+
.map((value) => Number(value))
|
|
51
|
+
.filter((value) => Number.isFinite(value));
|
|
52
|
+
if (explicitIds.length > 0) return explicitIds;
|
|
53
|
+
|
|
54
|
+
const names = splitCsv(rawNames);
|
|
55
|
+
if (names.length === 0) return [];
|
|
56
|
+
|
|
57
|
+
const resolved = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const normalized = name.toLowerCase();
|
|
60
|
+
const exactMatches = catalog.filter((item) => {
|
|
61
|
+
const label = String(valueGetter(item) ?? "").toLowerCase();
|
|
62
|
+
return label === normalized;
|
|
63
|
+
});
|
|
64
|
+
const matches =
|
|
65
|
+
exactMatches.length > 0
|
|
66
|
+
? exactMatches
|
|
67
|
+
: catalog.filter((item) => {
|
|
68
|
+
const label = String(valueGetter(item) ?? "").toLowerCase();
|
|
69
|
+
return label.includes(normalized);
|
|
70
|
+
});
|
|
71
|
+
if (matches.length === 0) {
|
|
72
|
+
throw new Error(`No ${kind} matched "${name}".`);
|
|
73
|
+
}
|
|
74
|
+
if (matches.length > 1) {
|
|
75
|
+
const labels = matches.map((item) => valueGetter(item)).join(", ");
|
|
76
|
+
throw new Error(`Ambiguous ${kind} "${name}". Matches: ${labels}`);
|
|
77
|
+
}
|
|
78
|
+
resolved.push(matches[0].id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildDurationFilter(minDuration, maxDuration) {
|
|
85
|
+
const out = Object.fromEntries(DURATION_BUCKETS.map((bucket) => [bucket.key, false]));
|
|
86
|
+
if (minDuration == null && maxDuration == null) return out;
|
|
87
|
+
|
|
88
|
+
for (const bucket of DURATION_BUCKETS) {
|
|
89
|
+
const overlapsMin = minDuration == null || bucket.max >= minDuration;
|
|
90
|
+
const overlapsMax = maxDuration == null || bucket.min <= maxDuration;
|
|
91
|
+
if (overlapsMin && overlapsMax) out[bucket.key] = true;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function summarizeWorkout(workout) {
|
|
97
|
+
if (!workout || typeof workout !== "object") return null;
|
|
98
|
+
return {
|
|
99
|
+
workoutId: workout.id ?? null,
|
|
100
|
+
workoutName: workout.workoutName ?? workout.name ?? null,
|
|
101
|
+
zoneId: workout.progressionId ?? workout.zoneId ?? workout.progression?.id ?? null,
|
|
102
|
+
zoneName: workout.progression?.text ?? null,
|
|
103
|
+
profileId: workout.profileId ?? null,
|
|
104
|
+
profileName: workout.profileName ?? null,
|
|
105
|
+
durationMinutes: workout.duration ?? null,
|
|
106
|
+
tss: workout.tss ?? null,
|
|
107
|
+
intensityFactor: workout.intensityFactor ?? null,
|
|
108
|
+
averageFtpPercent: workout.averageFtpPercent ?? null,
|
|
109
|
+
progressionLevel: workout.progressionLevel ?? null,
|
|
110
|
+
workoutDifficultyRating: workout.workoutDifficultyRating ?? null,
|
|
111
|
+
workoutTypeId: workout.workoutTypeId ?? null,
|
|
112
|
+
workoutLabelId: workout.workoutLabelId ?? null,
|
|
113
|
+
isOutside: workout.isOutside ?? null,
|
|
114
|
+
hasInstructions: workout.hasInstructions ?? null,
|
|
115
|
+
firstPublishDate: workout.firstPublishDate ?? null,
|
|
116
|
+
indoorAlternativeId: workout.indoorAlternativeId ?? null,
|
|
117
|
+
energyKj: workout.kj ?? null,
|
|
118
|
+
goal: stripHtml(workout.goalDescription),
|
|
119
|
+
description: stripHtml(workout.workoutDescription),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function matchesNumberRange(value, min, max) {
|
|
124
|
+
if (!Number.isFinite(Number(value))) return false;
|
|
125
|
+
const numeric = Number(value);
|
|
126
|
+
if (min != null && numeric < min) return false;
|
|
127
|
+
if (max != null && numeric > max) return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function applyLocalFilters(records, query) {
|
|
132
|
+
return records.filter((item) => {
|
|
133
|
+
if (query.outside != null && item.isOutside !== query.outside) return false;
|
|
134
|
+
if (query.hasInstructions != null && item.hasInstructions !== query.hasInstructions) return false;
|
|
135
|
+
if (query.minDuration != null || query.maxDuration != null) {
|
|
136
|
+
if (!matchesNumberRange(item.durationMinutes, query.minDuration, query.maxDuration)) return false;
|
|
137
|
+
}
|
|
138
|
+
if (query.minTss != null || query.maxTss != null) {
|
|
139
|
+
if (!matchesNumberRange(item.tss, query.minTss, query.maxTss)) return false;
|
|
140
|
+
}
|
|
141
|
+
if (query.minLevel != null || query.maxLevel != null) {
|
|
142
|
+
if (!matchesNumberRange(item.progressionLevel, query.minLevel, query.maxLevel)) return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compareValues(left, right) {
|
|
149
|
+
if (left == null && right == null) return 0;
|
|
150
|
+
if (left == null) return 1;
|
|
151
|
+
if (right == null) return -1;
|
|
152
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
153
|
+
return String(left).localeCompare(String(right));
|
|
154
|
+
}
|
|
155
|
+
return Number(left) - Number(right);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sortRecords(records, sort) {
|
|
159
|
+
const mode = String(sort ?? "level").toLowerCase();
|
|
160
|
+
const mapping = {
|
|
161
|
+
name: ["workoutName", false],
|
|
162
|
+
"name-desc": ["workoutName", true],
|
|
163
|
+
duration: ["durationMinutes", false],
|
|
164
|
+
"duration-desc": ["durationMinutes", true],
|
|
165
|
+
tss: ["tss", false],
|
|
166
|
+
"tss-desc": ["tss", true],
|
|
167
|
+
level: ["progressionLevel", false],
|
|
168
|
+
"level-desc": ["progressionLevel", true],
|
|
169
|
+
};
|
|
170
|
+
const [field, desc] = mapping[mode] ?? mapping.level;
|
|
171
|
+
return [...records].sort((a, b) => {
|
|
172
|
+
const result = compareValues(a[field], b[field]);
|
|
173
|
+
return desc ? -result : result;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildServerPredicate({
|
|
178
|
+
pageNumber,
|
|
179
|
+
pageSize,
|
|
180
|
+
sortProperty,
|
|
181
|
+
isDescending,
|
|
182
|
+
searchText,
|
|
183
|
+
zoneIds,
|
|
184
|
+
profileIds,
|
|
185
|
+
outside,
|
|
186
|
+
hasInstructions,
|
|
187
|
+
minDuration,
|
|
188
|
+
maxDuration,
|
|
189
|
+
}) {
|
|
190
|
+
return {
|
|
191
|
+
pageNumber,
|
|
192
|
+
pageSize,
|
|
193
|
+
isDescending,
|
|
194
|
+
sortProperty,
|
|
195
|
+
allProfiles: { profileIds: [] },
|
|
196
|
+
custom: { yup: false, nope: false, memberAccessId: 0 },
|
|
197
|
+
durations: buildDurationFilter(minDuration, maxDuration),
|
|
198
|
+
favorite: { yup: false, nope: false, favoriteWorkoutIds: [] },
|
|
199
|
+
progressions: {
|
|
200
|
+
profileIds,
|
|
201
|
+
progressionIds: zoneIds,
|
|
202
|
+
progressionLevels: [],
|
|
203
|
+
adaptiveTrainingVersion: 1000,
|
|
204
|
+
workoutTypeIds: [],
|
|
205
|
+
},
|
|
206
|
+
restrictToTeams: false,
|
|
207
|
+
teamIds: [],
|
|
208
|
+
teamOptions: [],
|
|
209
|
+
workoutDifficultyRatings: {
|
|
210
|
+
productive: false,
|
|
211
|
+
stretch: false,
|
|
212
|
+
breakthrough: false,
|
|
213
|
+
notRecommended: false,
|
|
214
|
+
achievable: false,
|
|
215
|
+
recovery: false,
|
|
216
|
+
adaptiveTrainingVersion: 1000,
|
|
217
|
+
},
|
|
218
|
+
workoutInstructions: {
|
|
219
|
+
yup: hasInstructions === true,
|
|
220
|
+
nope: hasInstructions === false,
|
|
221
|
+
},
|
|
222
|
+
workoutLabels: { workoutLabelIds: [] },
|
|
223
|
+
workoutTags: { workoutTagIds: [] },
|
|
224
|
+
workoutTypes: {
|
|
225
|
+
raceSimulation: false,
|
|
226
|
+
standard: false,
|
|
227
|
+
test: false,
|
|
228
|
+
video: false,
|
|
229
|
+
warmup: false,
|
|
230
|
+
outside: outside === true,
|
|
231
|
+
},
|
|
232
|
+
zoneOptions: [],
|
|
233
|
+
searchText,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function requirePrivateMember(flags, deps) {
|
|
238
|
+
const { withClient } = deps;
|
|
239
|
+
const client = await withClient(flags);
|
|
240
|
+
try {
|
|
241
|
+
const memberInfo = await client.getMemberInfo();
|
|
242
|
+
return { client, memberInfo };
|
|
243
|
+
} catch {
|
|
244
|
+
throw new Error(
|
|
245
|
+
"workout-library requires private authenticated mode. Login first with trainerroad-cli login.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function queryWorkoutLibrary(
|
|
251
|
+
client,
|
|
252
|
+
memberInfo,
|
|
253
|
+
{
|
|
254
|
+
searchText = "",
|
|
255
|
+
zone = null,
|
|
256
|
+
zoneId = null,
|
|
257
|
+
profile = null,
|
|
258
|
+
profileId = null,
|
|
259
|
+
outside = null,
|
|
260
|
+
hasInstructions = null,
|
|
261
|
+
minDuration = null,
|
|
262
|
+
maxDuration = null,
|
|
263
|
+
minTss = null,
|
|
264
|
+
maxTss = null,
|
|
265
|
+
minLevel = null,
|
|
266
|
+
maxLevel = null,
|
|
267
|
+
sort = "level",
|
|
268
|
+
limit = DEFAULT_LIMIT,
|
|
269
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
270
|
+
} = {},
|
|
271
|
+
) {
|
|
272
|
+
const profilesByZone = await client.getWorkoutProfilesByZone(memberInfo.username);
|
|
273
|
+
const catalog = toCatalog(profilesByZone);
|
|
274
|
+
const flatZones = catalog.map((zone) => ({ id: zone.zoneId, name: zone.zoneName }));
|
|
275
|
+
const flatProfiles = catalog.flatMap((zone) =>
|
|
276
|
+
zone.profiles.map((profile) => ({
|
|
277
|
+
id: profile.profileId,
|
|
278
|
+
name: profile.profileName,
|
|
279
|
+
zoneId: zone.zoneId,
|
|
280
|
+
zoneName: zone.zoneName,
|
|
281
|
+
})),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const zoneIds = resolveIdsByNameOrId("zone", flatZones, zone, zoneId, (item) => item.name);
|
|
285
|
+
const profileCatalog =
|
|
286
|
+
zoneIds.length > 0 ? flatProfiles.filter((item) => zoneIds.includes(item.zoneId)) : flatProfiles;
|
|
287
|
+
const profileIds = resolveIdsByNameOrId(
|
|
288
|
+
"profile",
|
|
289
|
+
profileCatalog,
|
|
290
|
+
profile,
|
|
291
|
+
profileId,
|
|
292
|
+
(item) => item.name,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const normalizedSearchText = String(searchText ?? "").trim();
|
|
296
|
+
const serverSortMode = sort === "name" || sort === "name-desc" ? "name" : "progressionLevel";
|
|
297
|
+
const isDescending = sort.endsWith("-desc");
|
|
298
|
+
const normalizedPageSize = Math.min(100, Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE));
|
|
299
|
+
const normalizedLimit = Math.max(1, Number(limit) || DEFAULT_LIMIT);
|
|
300
|
+
|
|
301
|
+
let pageNumber = 0;
|
|
302
|
+
let serverTotalCount = null;
|
|
303
|
+
let fetchedRecords = [];
|
|
304
|
+
let pagesFetched = 0;
|
|
305
|
+
const localFilterQuery = {
|
|
306
|
+
outside,
|
|
307
|
+
hasInstructions,
|
|
308
|
+
minDuration,
|
|
309
|
+
maxDuration,
|
|
310
|
+
minTss,
|
|
311
|
+
maxTss,
|
|
312
|
+
minLevel,
|
|
313
|
+
maxLevel,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
while (true) {
|
|
317
|
+
const predicate = buildServerPredicate({
|
|
318
|
+
pageNumber,
|
|
319
|
+
pageSize: normalizedPageSize,
|
|
320
|
+
sortProperty: serverSortMode,
|
|
321
|
+
isDescending,
|
|
322
|
+
searchText: normalizedSearchText,
|
|
323
|
+
zoneIds,
|
|
324
|
+
profileIds,
|
|
325
|
+
outside,
|
|
326
|
+
hasInstructions,
|
|
327
|
+
minDuration,
|
|
328
|
+
maxDuration,
|
|
329
|
+
});
|
|
330
|
+
const payload = await client.searchWorkoutLibrary(predicate, memberInfo.username);
|
|
331
|
+
const rawWorkouts = Array.isArray(payload?.workouts) ? payload.workouts : [];
|
|
332
|
+
const summarized = rawWorkouts.map((item) => summarizeWorkout(item)).filter(Boolean);
|
|
333
|
+
fetchedRecords.push(...summarized);
|
|
334
|
+
pagesFetched += 1;
|
|
335
|
+
|
|
336
|
+
const totalCount = Number(payload?.predicate?.totalCount);
|
|
337
|
+
if (Number.isFinite(totalCount)) serverTotalCount = totalCount;
|
|
338
|
+
if (applyLocalFilters(fetchedRecords, localFilterQuery).length >= normalizedLimit) break;
|
|
339
|
+
if (rawWorkouts.length < normalizedPageSize) break;
|
|
340
|
+
if (serverTotalCount != null && (pageNumber + 1) * normalizedPageSize >= serverTotalCount) break;
|
|
341
|
+
pageNumber += 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const filtered = sortRecords(
|
|
345
|
+
applyLocalFilters(fetchedRecords, localFilterQuery),
|
|
346
|
+
sort,
|
|
347
|
+
).slice(0, normalizedLimit);
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
query: {
|
|
351
|
+
searchText: normalizedSearchText,
|
|
352
|
+
zoneIds,
|
|
353
|
+
profileIds,
|
|
354
|
+
outside,
|
|
355
|
+
hasInstructions,
|
|
356
|
+
minDuration,
|
|
357
|
+
maxDuration,
|
|
358
|
+
minTss,
|
|
359
|
+
maxTss,
|
|
360
|
+
minLevel,
|
|
361
|
+
maxLevel,
|
|
362
|
+
sort,
|
|
363
|
+
limit: normalizedLimit,
|
|
364
|
+
pageSize: normalizedPageSize,
|
|
365
|
+
},
|
|
366
|
+
fetch: {
|
|
367
|
+
pagesFetched,
|
|
368
|
+
fetchedCount: fetchedRecords.length,
|
|
369
|
+
serverTotalCount,
|
|
370
|
+
},
|
|
371
|
+
catalog,
|
|
372
|
+
records: filtered,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function commandWorkoutLibrary(flags, deps) {
|
|
377
|
+
const {
|
|
378
|
+
isJsonMode,
|
|
379
|
+
requirePositiveInteger,
|
|
380
|
+
requireNumber,
|
|
381
|
+
toBoolean,
|
|
382
|
+
writeOutput,
|
|
383
|
+
} = deps;
|
|
384
|
+
|
|
385
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
386
|
+
const library = await queryWorkoutLibrary(client, memberInfo, {
|
|
387
|
+
searchText: flags.search,
|
|
388
|
+
zone: flags.zone,
|
|
389
|
+
zoneId: flags["zone-id"],
|
|
390
|
+
profile: flags.profile,
|
|
391
|
+
profileId: flags["profile-id"],
|
|
392
|
+
outside: flags.outside == null ? null : toBoolean(flags.outside, false),
|
|
393
|
+
hasInstructions:
|
|
394
|
+
flags["has-instructions"] == null ? null : toBoolean(flags["has-instructions"], false),
|
|
395
|
+
minDuration: flags["min-duration"] == null ? null : requireNumber(flags["min-duration"], null),
|
|
396
|
+
maxDuration: flags["max-duration"] == null ? null : requireNumber(flags["max-duration"], null),
|
|
397
|
+
minTss: flags["min-tss"] == null ? null : requireNumber(flags["min-tss"], null),
|
|
398
|
+
maxTss: flags["max-tss"] == null ? null : requireNumber(flags["max-tss"], null),
|
|
399
|
+
minLevel: flags["min-level"] == null ? null : requireNumber(flags["min-level"], null),
|
|
400
|
+
maxLevel: flags["max-level"] == null ? null : requireNumber(flags["max-level"], null),
|
|
401
|
+
sort: String(flags.sort ?? "level"),
|
|
402
|
+
limit: requirePositiveInteger(flags.limit, DEFAULT_LIMIT),
|
|
403
|
+
pageSize: requirePositiveInteger(flags["page-size"], DEFAULT_PAGE_SIZE),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const payload = {
|
|
407
|
+
generatedAt: new Date().toISOString(),
|
|
408
|
+
command: "workout-library",
|
|
409
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
410
|
+
...library,
|
|
411
|
+
count: library.records.length,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
if (!isJsonMode(flags)) {
|
|
415
|
+
await writeOutput(payload, flags, (value) => {
|
|
416
|
+
const lines = [
|
|
417
|
+
`Workout library (${value.count}) search="${value.query.searchText}" fetched=${value.fetch.fetchedCount}/${value.fetch.serverTotalCount ?? "?"}`,
|
|
418
|
+
];
|
|
419
|
+
for (const item of value.records) {
|
|
420
|
+
lines.push(
|
|
421
|
+
`- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | zone=${item.zoneName ?? item.zoneId ?? "?"} | profile=${item.profileName ?? item.profileId ?? "?"} | duration=${item.durationMinutes ?? "?"}m | level=${item.progressionLevel ?? "?"} | tss=${item.tss ?? "?"} | outside=${item.isOutside}`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
430
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
const ALTERNATE_CATEGORIES = new Set(["similar", "easier", "harder", "longer", "shorter"]);
|
|
2
|
+
const SWITCH_MODES = new Set(["inside", "outside"]);
|
|
3
|
+
|
|
4
|
+
function requireFlag(flags, name) {
|
|
5
|
+
const value = flags[name];
|
|
6
|
+
if (value === undefined || value === null || value === "") {
|
|
7
|
+
throw new Error(`Missing required flag --${name}.`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toIsoDateFromApiDate(date) {
|
|
13
|
+
if (!date || typeof date !== "object") return null;
|
|
14
|
+
const year = String(date.year ?? "").padStart(4, "0");
|
|
15
|
+
const month = String(date.month ?? "").padStart(2, "0");
|
|
16
|
+
const day = String(date.day ?? "").padStart(2, "0");
|
|
17
|
+
if (!year.trim() || !month.trim() || !day.trim()) return null;
|
|
18
|
+
return `${year}-${month}-${day}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function summarizePlannedActivity(activity) {
|
|
22
|
+
if (!activity || typeof activity !== "object") return null;
|
|
23
|
+
const workout = activity.workout ?? null;
|
|
24
|
+
return {
|
|
25
|
+
plannedActivityId: activity.id ?? null,
|
|
26
|
+
date: toIsoDateFromApiDate(activity.date),
|
|
27
|
+
timeOfDay: activity.timeOfDay ?? null,
|
|
28
|
+
workoutId: workout?.id ?? null,
|
|
29
|
+
workoutName: workout?.name ?? activity.name ?? null,
|
|
30
|
+
isOutside: workout?.isOutside ?? null,
|
|
31
|
+
durationMinutes:
|
|
32
|
+
workout?.duration ??
|
|
33
|
+
(Number.isFinite(Number(activity.durationInSeconds))
|
|
34
|
+
? Math.round(Number(activity.durationInSeconds) / 60)
|
|
35
|
+
: null),
|
|
36
|
+
tss: activity.tss ?? workout?.tss ?? null,
|
|
37
|
+
canMove: activity.canMove ?? null,
|
|
38
|
+
recommendationReason: activity.recommendationReason ?? null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function summarizeAlternateWorkout(workout) {
|
|
43
|
+
if (!workout || typeof workout !== "object") return null;
|
|
44
|
+
return {
|
|
45
|
+
workoutId: workout.id ?? null,
|
|
46
|
+
workoutName: workout.workoutName ?? workout.name ?? null,
|
|
47
|
+
durationMinutes: workout.duration ?? null,
|
|
48
|
+
tss: workout.tss ?? null,
|
|
49
|
+
intensityFactor: workout.intensityFactor ?? null,
|
|
50
|
+
prescribedLevel: workout.prescribedLevel ?? null,
|
|
51
|
+
isOutside: workout.isOutside ?? null,
|
|
52
|
+
hasOutsideEquivalent: workout.hasOutsideEquivalent ?? null,
|
|
53
|
+
zoneId: workout.zoneId ?? null,
|
|
54
|
+
profileId: workout.profileId ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function requirePrivateMember(flags, deps) {
|
|
59
|
+
const { withClient } = deps;
|
|
60
|
+
const client = await withClient(flags);
|
|
61
|
+
let memberInfo;
|
|
62
|
+
try {
|
|
63
|
+
memberInfo = await client.getMemberInfo();
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"This command requires private authenticated mode. Login first with trainerroad-cli login.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { client, memberInfo };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchBeforeAfter(flags, deps, mutate) {
|
|
73
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
74
|
+
const plannedActivityId = String(requireFlag(flags, "id"));
|
|
75
|
+
const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
|
|
76
|
+
const mutation = await mutate({ client, memberInfo, plannedActivityId, before });
|
|
77
|
+
const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
|
|
78
|
+
return { client, memberInfo, plannedActivityId, before, after, mutation };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function commandWorkoutAlternates(flags, deps) {
|
|
82
|
+
const { isJsonMode, writeOutput } = deps;
|
|
83
|
+
const category = String(flags.category ?? "similar").toLowerCase();
|
|
84
|
+
if (!ALTERNATE_CATEGORIES.has(category)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Invalid --category "${category}". Expected one of: ${Array.from(ALTERNATE_CATEGORIES).join(", ")}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
91
|
+
const plannedActivityId = String(requireFlag(flags, "id"));
|
|
92
|
+
const plannedActivity = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
|
|
93
|
+
const alternates = await client.getPlannedActivityAlternates(
|
|
94
|
+
plannedActivityId,
|
|
95
|
+
category,
|
|
96
|
+
memberInfo.username,
|
|
97
|
+
);
|
|
98
|
+
const records = (Array.isArray(alternates?.workouts) ? alternates.workouts : [])
|
|
99
|
+
.map((item) => summarizeAlternateWorkout(item))
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
|
|
102
|
+
const payload = {
|
|
103
|
+
generatedAt: new Date().toISOString(),
|
|
104
|
+
command: "workout-alternates",
|
|
105
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
106
|
+
query: { plannedActivityId, category },
|
|
107
|
+
plannedActivity: summarizePlannedActivity(plannedActivity),
|
|
108
|
+
count: records.length,
|
|
109
|
+
records,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (!isJsonMode(flags)) {
|
|
113
|
+
await writeOutput(payload, flags, (value) => {
|
|
114
|
+
const lines = [
|
|
115
|
+
`Alternates (${value.count}) for ${value.plannedActivity?.workoutName ?? "workout"} [category=${value.query.category}]`,
|
|
116
|
+
];
|
|
117
|
+
for (const item of value.records) {
|
|
118
|
+
lines.push(
|
|
119
|
+
`- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"} | outside=${item.isOutside === null ? "?" : item.isOutside}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function commandMoveWorkout(flags, deps) {
|
|
131
|
+
const { isJsonMode, writeOutput } = deps;
|
|
132
|
+
const newDate = String(requireFlag(flags, "to"));
|
|
133
|
+
const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
|
|
134
|
+
flags,
|
|
135
|
+
deps,
|
|
136
|
+
async ({ client, memberInfo, plannedActivityId }) =>
|
|
137
|
+
client.movePlannedActivity(plannedActivityId, newDate, memberInfo.username),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const payload = {
|
|
141
|
+
generatedAt: new Date().toISOString(),
|
|
142
|
+
command: "move-workout",
|
|
143
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
144
|
+
query: { plannedActivityId, to: newDate },
|
|
145
|
+
before: summarizePlannedActivity(before),
|
|
146
|
+
after: summarizePlannedActivity(after),
|
|
147
|
+
mutation,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (!isJsonMode(flags)) {
|
|
151
|
+
await writeOutput(payload, flags, (value) => {
|
|
152
|
+
return `Moved ${value.after?.workoutName ?? "workout"} | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.date ?? "?"} -> ${value.after?.date ?? "?"}`;
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function commandReplaceWorkout(flags, deps) {
|
|
161
|
+
const { isJsonMode, toBoolean, writeOutput } = deps;
|
|
162
|
+
const alternateWorkoutId = Number(requireFlag(flags, "alternate-id"));
|
|
163
|
+
if (!Number.isFinite(alternateWorkoutId)) {
|
|
164
|
+
throw new Error(`Invalid --alternate-id "${flags["alternate-id"]}". Expected a numeric workout ID.`);
|
|
165
|
+
}
|
|
166
|
+
const updateDuration = toBoolean(flags["update-duration"], false);
|
|
167
|
+
|
|
168
|
+
const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
|
|
169
|
+
flags,
|
|
170
|
+
deps,
|
|
171
|
+
async ({ client, memberInfo, plannedActivityId }) =>
|
|
172
|
+
client.replacePlannedActivityWithAlternate(plannedActivityId, alternateWorkoutId, {
|
|
173
|
+
updateDuration,
|
|
174
|
+
usernameForReferer: memberInfo.username,
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const payload = {
|
|
179
|
+
generatedAt: new Date().toISOString(),
|
|
180
|
+
command: "replace-workout",
|
|
181
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
182
|
+
query: { plannedActivityId, alternateWorkoutId, updateDuration },
|
|
183
|
+
before: summarizePlannedActivity(before),
|
|
184
|
+
after: summarizePlannedActivity(after),
|
|
185
|
+
mutation: summarizePlannedActivity(mutation),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (!isJsonMode(flags)) {
|
|
189
|
+
await writeOutput(payload, flags, (value) => {
|
|
190
|
+
return `Replaced workout | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.workoutName ?? "?"} -> ${value.after?.workoutName ?? "?"}`;
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function commandSwitchWorkout(flags, deps) {
|
|
199
|
+
const { isJsonMode, writeOutput } = deps;
|
|
200
|
+
const mode = String(requireFlag(flags, "mode")).toLowerCase();
|
|
201
|
+
if (!SWITCH_MODES.has(mode)) {
|
|
202
|
+
throw new Error(`Invalid --mode "${mode}". Expected one of: ${Array.from(SWITCH_MODES).join(", ")}.`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
|
|
206
|
+
flags,
|
|
207
|
+
deps,
|
|
208
|
+
async ({ client, memberInfo, plannedActivityId }) =>
|
|
209
|
+
client.switchPlannedActivityMode(plannedActivityId, mode, memberInfo.username),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const payload = {
|
|
213
|
+
generatedAt: new Date().toISOString(),
|
|
214
|
+
command: "switch-workout",
|
|
215
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
216
|
+
query: { plannedActivityId, mode },
|
|
217
|
+
before: summarizePlannedActivity(before),
|
|
218
|
+
after: summarizePlannedActivity(after),
|
|
219
|
+
mutation: summarizePlannedActivity(mutation),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (!isJsonMode(flags)) {
|
|
223
|
+
await writeOutput(payload, flags, (value) => {
|
|
224
|
+
return `Switched workout | plannedActivityId=${value.query.plannedActivityId} | outside=${value.before?.isOutside} -> ${value.after?.isOutside}`;
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
230
|
+
}
|