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,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(/&nbsp;/gi, " ")
28
+ .replace(/&amp;/gi, "&")
29
+ .replace(/&quot;/gi, '"')
30
+ .replace(/&#39;/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
+ }