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
package/README.md
CHANGED
|
@@ -14,28 +14,48 @@ CLI to fetch TrainerRoad data for your account, including:
|
|
|
14
14
|
- power ranking and power records
|
|
15
15
|
- weight history
|
|
16
16
|
|
|
17
|
+
It can also perform a small set of verified calendar writes for planned workouts:
|
|
18
|
+
|
|
19
|
+
- search the workout library by zone/profile/search text/duration/level
|
|
20
|
+
- fetch AI suggested workouts from TrainNow
|
|
21
|
+
- recommend library workouts against target duration/level/TSS
|
|
22
|
+
- fetch workout-library details by workout ID
|
|
23
|
+
- add a library workout to a calendar date
|
|
24
|
+
- copy an existing planned workout to another date
|
|
25
|
+
- move a planned workout to a new date
|
|
26
|
+
- list TrainerRoad alternate workout options
|
|
27
|
+
- replace a workout with a specific alternate
|
|
28
|
+
- switch a workout between inside and outside
|
|
29
|
+
|
|
17
30
|
## Install
|
|
18
31
|
|
|
19
|
-
###
|
|
32
|
+
### Run without install (npx)
|
|
20
33
|
|
|
21
34
|
```bash
|
|
22
|
-
npx --yes
|
|
35
|
+
npx --yes trainerroad-cli help
|
|
23
36
|
```
|
|
24
37
|
|
|
25
|
-
###
|
|
38
|
+
### Global install
|
|
26
39
|
|
|
27
40
|
```bash
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
npm install
|
|
31
|
-
npm exec --yes trainerroad-cli -- help
|
|
41
|
+
npm install -g trainerroad-cli
|
|
42
|
+
trainerroad-cli help
|
|
32
43
|
```
|
|
33
44
|
|
|
34
|
-
###
|
|
45
|
+
### Local project install
|
|
35
46
|
|
|
36
47
|
```bash
|
|
37
|
-
npm install
|
|
38
|
-
trainerroad-cli help
|
|
48
|
+
npm install trainerroad-cli
|
|
49
|
+
npx trainerroad-cli help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Local development (from source)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/quinnsprouse/trainerroad-cli.git
|
|
56
|
+
cd trainerroad-cli
|
|
57
|
+
npm install
|
|
58
|
+
npm run help
|
|
39
59
|
```
|
|
40
60
|
|
|
41
61
|
## Quickstart
|
|
@@ -56,9 +76,37 @@ trainerroad-cli past --days 30 --json
|
|
|
56
76
|
trainerroad-cli plan --view current --json
|
|
57
77
|
trainerroad-cli levels --json
|
|
58
78
|
trainerroad-cli ftp --json
|
|
79
|
+
trainerroad-cli today --tz America/New_York --json
|
|
80
|
+
trainerroad-cli train-now --duration 60 --json
|
|
81
|
+
trainerroad-cli workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json
|
|
82
|
+
trainerroad-cli workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json
|
|
83
|
+
trainerroad-cli workout-details --id 18128 --include-chart --json
|
|
84
|
+
trainerroad-cli add-workout --workout-id 18128 --date 2026-03-16 --json
|
|
85
|
+
trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
3. Mutate planned workouts
|
|
89
|
+
|
|
90
|
+
First get a planned workout ID from `future --details`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
trainerroad-cli future --days 14 --details --json
|
|
59
94
|
```
|
|
60
95
|
|
|
61
|
-
|
|
96
|
+
Then use that planned activity ID:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
trainerroad-cli workout-alternates --id <planned-activity-id> --category easier --json
|
|
100
|
+
trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --json
|
|
101
|
+
trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> --json
|
|
102
|
+
trainerroad-cli switch-workout --id <planned-activity-id> --mode outside --json
|
|
103
|
+
trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`copy-workout` is the reliable way to place an existing planned workout on another date.
|
|
107
|
+
`add-workout` exists, but TrainerRoad's add endpoints are still inconsistent and may fail even after retry/reconciliation.
|
|
108
|
+
|
|
109
|
+
4. Discover all commands
|
|
62
110
|
|
|
63
111
|
```bash
|
|
64
112
|
trainerroad-cli help
|
|
@@ -80,6 +128,7 @@ Use `--target <username>` and/or `--public` for public mode queries.
|
|
|
80
128
|
- `--jsonl`: one record per line
|
|
81
129
|
- `--fields a,b,c`: project record fields
|
|
82
130
|
- `--records-only`: lighter record payloads
|
|
131
|
+
- `--tz <IANA timezone>`: localize day boundaries/timestamps (defaults to `TR_TIMEZONE` or system timezone)
|
|
83
132
|
|
|
84
133
|
## Security
|
|
85
134
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trainerroad-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Unofficial CLI for authenticating with TrainerRoad and querying timeline/workout data",
|
|
5
5
|
"main": "src/cli.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"login": "node src/cli.mjs login",
|
|
22
22
|
"whoami": "node src/cli.mjs whoami",
|
|
23
23
|
"timeline": "node src/cli.mjs timeline",
|
|
24
|
+
"train-now": "node src/cli.mjs train-now",
|
|
24
25
|
"events": "node src/cli.mjs events",
|
|
25
26
|
"annotations": "node src/cli.mjs annotations",
|
|
26
27
|
"levels": "node src/cli.mjs levels",
|
|
@@ -33,6 +34,15 @@
|
|
|
33
34
|
"ftp-prediction": "node src/cli.mjs ftp-prediction",
|
|
34
35
|
"power-ranking": "node src/cli.mjs power-ranking",
|
|
35
36
|
"power-records": "node src/cli.mjs power-records",
|
|
37
|
+
"workout-library": "node src/cli.mjs workout-library",
|
|
38
|
+
"workout-recommend": "node src/cli.mjs workout-recommend",
|
|
39
|
+
"workout-details": "node src/cli.mjs workout-details",
|
|
40
|
+
"add-workout": "node src/cli.mjs add-workout",
|
|
41
|
+
"copy-workout": "node src/cli.mjs copy-workout",
|
|
42
|
+
"workout-alternates": "node src/cli.mjs workout-alternates",
|
|
43
|
+
"move-workout": "node src/cli.mjs move-workout",
|
|
44
|
+
"replace-workout": "node src/cli.mjs replace-workout",
|
|
45
|
+
"switch-workout": "node src/cli.mjs switch-workout",
|
|
36
46
|
"logout": "node src/cli.mjs logout"
|
|
37
47
|
},
|
|
38
48
|
"keywords": [
|
package/src/cli.mjs
CHANGED
|
@@ -31,8 +31,28 @@ import { commandLevels } from "./commands/levels.mjs";
|
|
|
31
31
|
import { commandPlan } from "./commands/plan.mjs";
|
|
32
32
|
import { commandPowerRanking, commandPowerRecords } from "./commands/power.mjs";
|
|
33
33
|
import { commandTimeline } from "./commands/timeline.mjs";
|
|
34
|
+
import { commandTrainNow } from "./commands/train-now.mjs";
|
|
34
35
|
import { commandWeightHistory } from "./commands/weight-history.mjs";
|
|
36
|
+
import { commandWorkoutLibrary } from "./commands/workout-library.mjs";
|
|
37
|
+
import { commandWorkoutRecommend } from "./commands/workout-recommend.mjs";
|
|
38
|
+
import { commandAddWorkout, commandCopyWorkout, commandWorkoutDetails } from "./commands/workout-tools.mjs";
|
|
39
|
+
import {
|
|
40
|
+
commandMoveWorkout,
|
|
41
|
+
commandReplaceWorkout,
|
|
42
|
+
commandSwitchWorkout,
|
|
43
|
+
commandWorkoutAlternates,
|
|
44
|
+
} from "./commands/workout-mutations.mjs";
|
|
35
45
|
import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
|
|
46
|
+
import {
|
|
47
|
+
formatDateTimeInTimeZone,
|
|
48
|
+
isoDateShiftInTimeZone,
|
|
49
|
+
normalizeTimeZone,
|
|
50
|
+
parseApiDateTime,
|
|
51
|
+
summarizeActivityTimeWindow,
|
|
52
|
+
toDateOnlyInTimeZone,
|
|
53
|
+
} from "./lib/timezone.mjs";
|
|
54
|
+
|
|
55
|
+
let ACTIVE_TIME_ZONE = normalizeTimeZone();
|
|
36
56
|
|
|
37
57
|
function printGlobalHelp() {
|
|
38
58
|
console.log("trainerroad-cli (unofficial)");
|
|
@@ -53,9 +73,18 @@ function printGlobalHelp() {
|
|
|
53
73
|
console.log("Examples:");
|
|
54
74
|
console.log(" node src/cli.mjs login --username quinnsprouse --password-stdin");
|
|
55
75
|
console.log(" node src/cli.mjs future --days 30 --details --json");
|
|
76
|
+
console.log(" node src/cli.mjs today --tz America/New_York --json");
|
|
56
77
|
console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
|
|
57
78
|
console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
|
|
58
79
|
console.log(" node src/cli.mjs ftp --target quinnsprouse --public --json");
|
|
80
|
+
console.log(" node src/cli.mjs move-workout --id <planned-id> --to 2026-03-13 --json");
|
|
81
|
+
console.log(" node src/cli.mjs workout-alternates --id <planned-id> --category easier --json");
|
|
82
|
+
console.log(' node src/cli.mjs workout-library --zone "Endurance" --profile "Sustained Power" --min-duration 45 --max-duration 75 --json');
|
|
83
|
+
console.log(' node src/cli.mjs workout-recommend --zone "Endurance" --profile "Sustained Power" --target-duration 60 --target-level 1.0 --count 3 --json');
|
|
84
|
+
console.log(" node src/cli.mjs train-now --duration 60 --json");
|
|
85
|
+
console.log(" node src/cli.mjs workout-details --id 18128 --include-chart --json");
|
|
86
|
+
console.log(" node src/cli.mjs add-workout --workout-id 18128 --date 2026-03-16 --json");
|
|
87
|
+
console.log(" node src/cli.mjs copy-workout --id <planned-id> --date 2026-03-16 --json");
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
function levenshteinDistance(left, right) {
|
|
@@ -179,6 +208,7 @@ function printCommandHelp(command, flags = {}) {
|
|
|
179
208
|
command,
|
|
180
209
|
summary: def.summary,
|
|
181
210
|
usage: def.usage,
|
|
211
|
+
timezoneOption: "--tz <IANA timezone> (defaults to TR_TIMEZONE or system timezone)",
|
|
182
212
|
supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
|
|
183
213
|
agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
|
|
184
214
|
agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
|
|
@@ -194,6 +224,9 @@ function printCommandHelp(command, flags = {}) {
|
|
|
194
224
|
console.log("");
|
|
195
225
|
console.log("Usage:");
|
|
196
226
|
for (const line of def.usage) console.log(` ${line}`);
|
|
227
|
+
console.log("");
|
|
228
|
+
console.log("Timezone:");
|
|
229
|
+
console.log(" --tz <IANA timezone> Override local-day bucketing (defaults: TR_TIMEZONE or system timezone).");
|
|
197
230
|
if (FILTERABLE_COMMANDS.has(command)) {
|
|
198
231
|
console.log("");
|
|
199
232
|
console.log("Agent filters:");
|
|
@@ -234,9 +267,7 @@ function parseArgs(argv) {
|
|
|
234
267
|
}
|
|
235
268
|
|
|
236
269
|
function isoDateShift(days) {
|
|
237
|
-
|
|
238
|
-
date.setUTCDate(date.getUTCDate() + days);
|
|
239
|
-
return date.toISOString().slice(0, 10);
|
|
270
|
+
return isoDateShiftInTimeZone(days, ACTIVE_TIME_ZONE);
|
|
240
271
|
}
|
|
241
272
|
|
|
242
273
|
function toIsoDateFromPlanned(item) {
|
|
@@ -244,8 +275,26 @@ function toIsoDateFromPlanned(item) {
|
|
|
244
275
|
}
|
|
245
276
|
|
|
246
277
|
function toIsoDate(value) {
|
|
247
|
-
if (typeof value === "string" && value.length >= 10
|
|
248
|
-
|
|
278
|
+
if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
279
|
+
return value.slice(0, 10);
|
|
280
|
+
}
|
|
281
|
+
return (
|
|
282
|
+
toDateOnlyInTimeZone(value, ACTIVE_TIME_ZONE, { assumeUtcForOffsetlessDateTime: true }) ??
|
|
283
|
+
new Date(value).toISOString().slice(0, 10)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatDateTime(value) {
|
|
288
|
+
return (
|
|
289
|
+
formatDateTimeInTimeZone(value, ACTIVE_TIME_ZONE, { assumeUtcForOffsetlessDateTime: true }) ??
|
|
290
|
+
String(value ?? "")
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function summarizeActivityTime(started, durationInSeconds) {
|
|
295
|
+
return summarizeActivityTimeWindow(started, durationInSeconds, ACTIVE_TIME_ZONE, {
|
|
296
|
+
assumeUtcForOffsetlessDateTime: true,
|
|
297
|
+
});
|
|
249
298
|
}
|
|
250
299
|
|
|
251
300
|
function normalizeDateOnlyInput(value, fallback) {
|
|
@@ -292,8 +341,10 @@ function normalizeFtpHistory(raw) {
|
|
|
292
341
|
const valueRaw = item?.value ?? item?.Value ?? null;
|
|
293
342
|
const value = Number(valueRaw);
|
|
294
343
|
if (!dateRaw || !Number.isFinite(value)) return null;
|
|
344
|
+
const parsedDate = parseApiDateTime(dateRaw, { assumeUtcForOffsetlessDateTime: true });
|
|
345
|
+
if (!parsedDate) return null;
|
|
295
346
|
return {
|
|
296
|
-
date:
|
|
347
|
+
date: parsedDate.toISOString(),
|
|
297
348
|
dateOnly: toIsoDate(dateRaw),
|
|
298
349
|
value,
|
|
299
350
|
};
|
|
@@ -328,9 +379,11 @@ function normalizeFitnessThresholds(raw) {
|
|
|
328
379
|
const valueRaw = item?.value ?? item?.Value ?? null;
|
|
329
380
|
const value = Number(valueRaw);
|
|
330
381
|
if (!dateRaw || !Number.isFinite(value)) return null;
|
|
382
|
+
const parsedDate = parseApiDateTime(dateRaw, { assumeUtcForOffsetlessDateTime: true });
|
|
383
|
+
if (!parsedDate) return null;
|
|
331
384
|
return {
|
|
332
385
|
id: item?.id ?? item?.Id ?? null,
|
|
333
|
-
date:
|
|
386
|
+
date: parsedDate.toISOString(),
|
|
334
387
|
dateOnly: toIsoDate(dateRaw),
|
|
335
388
|
value,
|
|
336
389
|
isApplied: Boolean(item?.isApplied ?? item?.IsApplied),
|
|
@@ -397,9 +450,21 @@ async function readPasswordFromStdin() {
|
|
|
397
450
|
return Buffer.concat(chunks).toString("utf8").trim();
|
|
398
451
|
}
|
|
399
452
|
|
|
453
|
+
function withTimeZoneMeta(payload) {
|
|
454
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
455
|
+
if (payload.timeZone != null) return payload;
|
|
456
|
+
return { ...payload, timeZone: ACTIVE_TIME_ZONE };
|
|
457
|
+
}
|
|
458
|
+
|
|
400
459
|
async function writeOutput(payload, flags, textRenderer = null) {
|
|
460
|
+
const payloadWithTimeZone = withTimeZoneMeta(payload);
|
|
461
|
+
|
|
401
462
|
if (flags.jsonl) {
|
|
402
|
-
const records = Array.isArray(
|
|
463
|
+
const records = Array.isArray(payloadWithTimeZone?.records)
|
|
464
|
+
? payloadWithTimeZone.records
|
|
465
|
+
: Array.isArray(payloadWithTimeZone)
|
|
466
|
+
? payloadWithTimeZone
|
|
467
|
+
: [];
|
|
403
468
|
const content = records.map((item) => JSON.stringify(item)).join("\n");
|
|
404
469
|
if (flags.output) {
|
|
405
470
|
await fs.writeFile(flags.output, `${content}${content ? "\n" : ""}`, "utf8");
|
|
@@ -410,9 +475,11 @@ async function writeOutput(payload, flags, textRenderer = null) {
|
|
|
410
475
|
return;
|
|
411
476
|
}
|
|
412
477
|
|
|
413
|
-
if (flags.json || typeof
|
|
478
|
+
if (flags.json || typeof payloadWithTimeZone !== "string") {
|
|
414
479
|
const content =
|
|
415
|
-
typeof
|
|
480
|
+
typeof payloadWithTimeZone === "string"
|
|
481
|
+
? payloadWithTimeZone
|
|
482
|
+
: `${JSON.stringify(payloadWithTimeZone, null, 2)}\n`;
|
|
416
483
|
if (flags.output) {
|
|
417
484
|
await fs.writeFile(flags.output, content, "utf8");
|
|
418
485
|
console.log(`Wrote JSON to ${flags.output}`);
|
|
@@ -422,7 +489,7 @@ async function writeOutput(payload, flags, textRenderer = null) {
|
|
|
422
489
|
return;
|
|
423
490
|
}
|
|
424
491
|
|
|
425
|
-
const text = textRenderer ? textRenderer(
|
|
492
|
+
const text = textRenderer ? textRenderer(payloadWithTimeZone) : String(payloadWithTimeZone);
|
|
426
493
|
if (flags.output) {
|
|
427
494
|
await fs.writeFile(flags.output, `${text}\n`, "utf8");
|
|
428
495
|
console.log(`Wrote text to ${flags.output}`);
|
|
@@ -532,6 +599,9 @@ async function main() {
|
|
|
532
599
|
process.exit(1);
|
|
533
600
|
}
|
|
534
601
|
|
|
602
|
+
ACTIVE_TIME_ZONE = normalizeTimeZone(flags.tz ?? null);
|
|
603
|
+
process.env.TR_TIMEZONE = ACTIVE_TIME_ZONE;
|
|
604
|
+
|
|
535
605
|
if (command === "help") {
|
|
536
606
|
if (positionals[0]) {
|
|
537
607
|
process.exit(printCommandHelp(positionals[0], flags));
|
|
@@ -550,6 +620,7 @@ async function main() {
|
|
|
550
620
|
}
|
|
551
621
|
|
|
552
622
|
const commandDeps = {
|
|
623
|
+
timeZone: ACTIVE_TIME_ZONE,
|
|
553
624
|
resolveQueryContext,
|
|
554
625
|
requirePrivateContext,
|
|
555
626
|
applyAgentRecordFilters,
|
|
@@ -562,11 +633,14 @@ async function main() {
|
|
|
562
633
|
normalizeDateOnlyInput,
|
|
563
634
|
isoDateShift,
|
|
564
635
|
filterFuturePlanned,
|
|
565
|
-
filterPastActivities,
|
|
636
|
+
filterPastActivities: (activities, fromDateIso, toDateIso) =>
|
|
637
|
+
filterPastActivities(activities, fromDateIso, toDateIso, ACTIVE_TIME_ZONE),
|
|
566
638
|
sortByDateAsc,
|
|
567
639
|
sortByDateDesc,
|
|
568
640
|
toIsoDateFromPlanned,
|
|
569
641
|
toIsoDate,
|
|
642
|
+
formatDateTime,
|
|
643
|
+
summarizeActivityTime,
|
|
570
644
|
withClient,
|
|
571
645
|
readPasswordFromStdin,
|
|
572
646
|
normalizeFtpHistory,
|
|
@@ -594,6 +668,9 @@ async function main() {
|
|
|
594
668
|
case "timeline":
|
|
595
669
|
await commandTimeline(flags, commandDeps);
|
|
596
670
|
return;
|
|
671
|
+
case "train-now":
|
|
672
|
+
await commandTrainNow(flags, commandDeps);
|
|
673
|
+
return;
|
|
597
674
|
case "events":
|
|
598
675
|
await commandEvents(flags, commandDeps);
|
|
599
676
|
return;
|
|
@@ -630,6 +707,33 @@ async function main() {
|
|
|
630
707
|
case "power-records":
|
|
631
708
|
await commandPowerRecords(flags, commandDeps);
|
|
632
709
|
return;
|
|
710
|
+
case "workout-library":
|
|
711
|
+
await commandWorkoutLibrary(flags, commandDeps);
|
|
712
|
+
return;
|
|
713
|
+
case "workout-recommend":
|
|
714
|
+
await commandWorkoutRecommend(flags, commandDeps);
|
|
715
|
+
return;
|
|
716
|
+
case "workout-details":
|
|
717
|
+
await commandWorkoutDetails(flags, commandDeps);
|
|
718
|
+
return;
|
|
719
|
+
case "add-workout":
|
|
720
|
+
await commandAddWorkout(flags, commandDeps);
|
|
721
|
+
return;
|
|
722
|
+
case "copy-workout":
|
|
723
|
+
await commandCopyWorkout(flags, commandDeps);
|
|
724
|
+
return;
|
|
725
|
+
case "workout-alternates":
|
|
726
|
+
await commandWorkoutAlternates(flags, commandDeps);
|
|
727
|
+
return;
|
|
728
|
+
case "move-workout":
|
|
729
|
+
await commandMoveWorkout(flags, commandDeps);
|
|
730
|
+
return;
|
|
731
|
+
case "replace-workout":
|
|
732
|
+
await commandReplaceWorkout(flags, commandDeps);
|
|
733
|
+
return;
|
|
734
|
+
case "switch-workout":
|
|
735
|
+
await commandSwitchWorkout(flags, commandDeps);
|
|
736
|
+
return;
|
|
633
737
|
case "logout":
|
|
634
738
|
await commandLogout(flags, commandDeps);
|
|
635
739
|
return;
|
|
@@ -667,6 +771,9 @@ main().catch((error) => {
|
|
|
667
771
|
if (message.includes('Invalid date "')) {
|
|
668
772
|
console.error("Tip: expected date format is YYYY-MM-DD");
|
|
669
773
|
}
|
|
774
|
+
if (message.includes('Invalid timezone "')) {
|
|
775
|
+
console.error("Tip: use an IANA timezone like America/New_York.");
|
|
776
|
+
}
|
|
670
777
|
console.error('Run "trainerroad-cli help" or "trainerroad-cli help <command>" for usage.');
|
|
671
778
|
|
|
672
779
|
if (process.env.TR_CLI_DEBUG === "1" && error?.stack) {
|
|
@@ -30,6 +30,7 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
30
30
|
"node src/cli.mjs capabilities --json",
|
|
31
31
|
"node src/cli.mjs whoami --json",
|
|
32
32
|
"node src/cli.mjs future --days 30 --json",
|
|
33
|
+
"node src/cli.mjs today --tz America/New_York --json",
|
|
33
34
|
"node src/cli.mjs help future --json",
|
|
34
35
|
],
|
|
35
36
|
commandCount: commandEntries.length,
|
|
@@ -152,6 +153,11 @@ export async function commandCapabilities(flags, deps) {
|
|
|
152
153
|
outputOptions: AGENT_OUTPUT_OPTIONS,
|
|
153
154
|
},
|
|
154
155
|
outputModes: ["text", "json", "jsonl"],
|
|
156
|
+
timezone: {
|
|
157
|
+
flag: "--tz <IANA timezone>",
|
|
158
|
+
environment: "TR_TIMEZONE",
|
|
159
|
+
example: "America/New_York",
|
|
160
|
+
},
|
|
155
161
|
};
|
|
156
162
|
|
|
157
163
|
await writeOutput(payload, flags, () => {
|
|
@@ -160,6 +166,7 @@ export async function commandCapabilities(flags, deps) {
|
|
|
160
166
|
"- Private mode (authenticated): full timeline + workout details.",
|
|
161
167
|
"- Public mode (unauthenticated): day-level TSS/ride/planned signals + FTP history.",
|
|
162
168
|
"- Commands support both via automatic mode selection and --target/--public flags.",
|
|
169
|
+
"- Timezone-aware date bucketing: use --tz or TR_TIMEZONE for local-day accuracy.",
|
|
163
170
|
].join("\n");
|
|
164
171
|
});
|
|
165
172
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
function stripHtml(value) {
|
|
2
|
+
return String(value ?? "")
|
|
3
|
+
.replace(/<[^>]+>/g, " ")
|
|
4
|
+
.replace(/ /gi, " ")
|
|
5
|
+
.replace(/&/gi, "&")
|
|
6
|
+
.replace(/"/gi, '"')
|
|
7
|
+
.replace(/'/gi, "'")
|
|
8
|
+
.replace(/\s+/g, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CATEGORY_NAME_MAP = {
|
|
13
|
+
climbing: "Climbing",
|
|
14
|
+
endurance: "Endurance",
|
|
15
|
+
attacking: "Attacking",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function normalizeCategoryName(value) {
|
|
19
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
20
|
+
return CATEGORY_NAME_MAP[normalized] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function summarizeWorkoutInfo(info) {
|
|
24
|
+
if (!info || typeof info !== "object") return null;
|
|
25
|
+
return {
|
|
26
|
+
workoutId: info.id ?? null,
|
|
27
|
+
workoutName: info.name ?? null,
|
|
28
|
+
duration: info.duration ?? null,
|
|
29
|
+
durationMinutes:
|
|
30
|
+
Number.isFinite(Number(info.durationInSeconds)) ? Math.round(Number(info.durationInSeconds) / 60) : null,
|
|
31
|
+
tss: info.tss ?? null,
|
|
32
|
+
intensityFactor: info.intensityFactor ?? null,
|
|
33
|
+
energyKj: info.kj ?? null,
|
|
34
|
+
zoneId: info.progressionId ?? null,
|
|
35
|
+
progressionLevel: info.progressionLevel ?? null,
|
|
36
|
+
workoutDifficultyRating: info.workoutDifficultyRating ?? null,
|
|
37
|
+
isOutside: info.isOutside ?? null,
|
|
38
|
+
profileId: info.profileId ?? null,
|
|
39
|
+
imageUrl: info.picUrl ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function summarizeSuggestion(categoryName, suggestion, workoutInfo) {
|
|
44
|
+
return {
|
|
45
|
+
category: categoryName,
|
|
46
|
+
source: suggestion?.source ?? null,
|
|
47
|
+
workoutId: suggestion?.workoutId ?? workoutInfo?.workoutId ?? null,
|
|
48
|
+
...workoutInfo,
|
|
49
|
+
predictionMetrics: suggestion?.predictionMetrics ?? {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function flattenSuggestions(suggestions, workoutInfoById, categoryFilter = null) {
|
|
54
|
+
const categories = suggestions && typeof suggestions === "object" ? Object.entries(suggestions) : [];
|
|
55
|
+
const rows = [];
|
|
56
|
+
for (const [categoryName, items] of categories) {
|
|
57
|
+
if (categoryFilter && categoryName !== categoryFilter) continue;
|
|
58
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
59
|
+
rows.push(
|
|
60
|
+
summarizeSuggestion(
|
|
61
|
+
categoryName,
|
|
62
|
+
item,
|
|
63
|
+
workoutInfoById.get(Number(item?.workoutId)) ?? null,
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function requirePrivateMember(flags, deps) {
|
|
72
|
+
const { withClient } = deps;
|
|
73
|
+
const client = await withClient(flags);
|
|
74
|
+
try {
|
|
75
|
+
const memberInfo = await client.getMemberInfo();
|
|
76
|
+
return { client, memberInfo };
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error("train-now requires private authenticated mode. Login first with trainerroad-cli login.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function commandTrainNow(flags, deps) {
|
|
83
|
+
const { isJsonMode, requirePositiveInteger, writeOutput } = deps;
|
|
84
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
85
|
+
|
|
86
|
+
const duration = requirePositiveInteger(flags.duration, 60);
|
|
87
|
+
const numSuggestions = requirePositiveInteger(flags["num-suggestions"], 10);
|
|
88
|
+
const categoryFilter = flags.category ? normalizeCategoryName(flags.category) : null;
|
|
89
|
+
if (flags.category && !categoryFilter) {
|
|
90
|
+
throw new Error('Invalid --category. Expected one of: climbing, endurance, attacking.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const [status, suggestionsPayload] = await Promise.all([
|
|
94
|
+
client.getTrainNowStatus(memberInfo.username),
|
|
95
|
+
client.getTrainNowSuggestions({ duration, numSuggestions }, memberInfo.username),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const suggestionIds = Array.from(
|
|
99
|
+
new Set(
|
|
100
|
+
Object.values(suggestionsPayload?.suggestions ?? {})
|
|
101
|
+
.flatMap((items) => (Array.isArray(items) ? items : []))
|
|
102
|
+
.map((item) => Number(item?.workoutId))
|
|
103
|
+
.filter((value) => Number.isFinite(value)),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
const workoutInfo = await client.getWorkoutInformation(suggestionIds, memberInfo.username);
|
|
107
|
+
const workoutInfoById = new Map(
|
|
108
|
+
(Array.isArray(workoutInfo) ? workoutInfo : [])
|
|
109
|
+
.map((item) => [Number(item?.id), summarizeWorkoutInfo(item)])
|
|
110
|
+
.filter(([id]) => Number.isFinite(id)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const records = flattenSuggestions(suggestionsPayload?.suggestions, workoutInfoById, categoryFilter);
|
|
114
|
+
const payload = {
|
|
115
|
+
generatedAt: new Date().toISOString(),
|
|
116
|
+
command: "train-now",
|
|
117
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
118
|
+
query: { duration, numSuggestions, category: categoryFilter },
|
|
119
|
+
status,
|
|
120
|
+
recommendedCategory: suggestionsPayload?.recommendedCategory ?? null,
|
|
121
|
+
hasRpePredictionServiceFailure: Boolean(suggestionsPayload?.hasRpePredictionServiceFailure),
|
|
122
|
+
count: records.length,
|
|
123
|
+
records,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (!isJsonMode(flags)) {
|
|
127
|
+
await writeOutput(payload, flags, (value) => {
|
|
128
|
+
const lines = [
|
|
129
|
+
`TrainNow suggestions (${value.count}) duration=${value.query.duration}m`,
|
|
130
|
+
];
|
|
131
|
+
for (const item of value.records) {
|
|
132
|
+
lines.push(
|
|
133
|
+
`- [${item.category}] ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"} | level=${item.progressionLevel ?? "?"}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
142
|
+
}
|