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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeTimeZone, toDateOnlyInTimeZone } from "./lib/timezone.mjs";
|
|
3
4
|
|
|
4
5
|
const BASE_URL = "https://www.trainerroad.com";
|
|
5
6
|
const APP_URL = `${BASE_URL}/app`;
|
|
@@ -11,9 +12,16 @@ function ensureLeadingSlash(value) {
|
|
|
11
12
|
return value;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
function toApiDateOnly(value) {
|
|
16
|
+
const match = String(value ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
17
|
+
if (!match) {
|
|
18
|
+
throw new Error(`Invalid date "${value}". Expected YYYY-MM-DD.`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
year: Number(match[1]),
|
|
22
|
+
month: Number(match[2]),
|
|
23
|
+
day: Number(match[3]),
|
|
24
|
+
};
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
function plannedDateToIso(item) {
|
|
@@ -38,10 +46,14 @@ export function filterFuturePlanned(plannedActivities, fromDateIso, toDateIso =
|
|
|
38
46
|
});
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
export function filterPastActivities(activities, fromDateIso = null, toDateIso = null) {
|
|
49
|
+
export function filterPastActivities(activities, fromDateIso = null, toDateIso = null, timeZone = null) {
|
|
50
|
+
const resolvedTimeZone = normalizeTimeZone(timeZone);
|
|
42
51
|
return activities
|
|
43
52
|
.filter((item) => {
|
|
44
|
-
const startedDate =
|
|
53
|
+
const startedDate = toDateOnlyInTimeZone(item.started, resolvedTimeZone, {
|
|
54
|
+
assumeUtcForOffsetlessDateTime: true,
|
|
55
|
+
});
|
|
56
|
+
if (!startedDate) return false;
|
|
45
57
|
if (fromDateIso && startedDate < fromDateIso) return false;
|
|
46
58
|
if (toDateIso && startedDate > toDateIso) return false;
|
|
47
59
|
return true;
|
|
@@ -445,6 +457,288 @@ export class TrainerRoadClient {
|
|
|
445
457
|
return results;
|
|
446
458
|
}
|
|
447
459
|
|
|
460
|
+
async getWorkoutProfilesByZone(usernameForReferer = null) {
|
|
461
|
+
return this.#requestJson("/app/api/workouts/workout-profiles-by-zone", {
|
|
462
|
+
headers: {
|
|
463
|
+
"trainerroad-jsonformat": "camel-case",
|
|
464
|
+
"tr-cache-control": "use-cache",
|
|
465
|
+
referer: `${APP_URL}/workouts/list`,
|
|
466
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async searchWorkoutLibrary(predicate, usernameForReferer = null) {
|
|
472
|
+
return this.#requestJson("/app/api/workouts", {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: {
|
|
475
|
+
"content-type": "application/json",
|
|
476
|
+
"trainerroad-jsonformat": "camel-case",
|
|
477
|
+
"tr-cache-control": "no-cache",
|
|
478
|
+
referer: `${APP_URL}/workouts/list`,
|
|
479
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
480
|
+
},
|
|
481
|
+
body: JSON.stringify(predicate),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async getWorkoutsByIds(workoutIds, usernameForReferer = null) {
|
|
486
|
+
return this.#requestJson("/app/api/workouts/by-id", {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: {
|
|
489
|
+
"content-type": "application/json",
|
|
490
|
+
"trainerroad-jsonformat": "camel-case",
|
|
491
|
+
"tr-cache-control": "use-cache",
|
|
492
|
+
referer: `${APP_URL}/workouts/list`,
|
|
493
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
494
|
+
},
|
|
495
|
+
body: JSON.stringify(workoutIds),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async getWorkoutSummary(workoutId, usernameForReferer = null) {
|
|
500
|
+
return this.#requestJson(`/app/api/workouts/${encodeURIComponent(workoutId)}/summary`, {
|
|
501
|
+
headers: {
|
|
502
|
+
"trainerroad-jsonformat": "camel-case",
|
|
503
|
+
"tr-cache-control": "use-cache",
|
|
504
|
+
referer: `${APP_URL}/workouts/list`,
|
|
505
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async getWorkoutLevels(workoutId, usernameForReferer = null) {
|
|
511
|
+
return this.#requestJson(`/app/api/workouts/${encodeURIComponent(workoutId)}/levels`, {
|
|
512
|
+
headers: {
|
|
513
|
+
"trainerroad-jsonformat": "camel-case",
|
|
514
|
+
"tr-cache-control": "use-cache",
|
|
515
|
+
referer: `${APP_URL}/workouts/list`,
|
|
516
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async getWorkoutChartData(workoutId, usernameForReferer = null) {
|
|
522
|
+
return this.#requestJson(`/app/api/workouts/${encodeURIComponent(workoutId)}/chart-data`, {
|
|
523
|
+
headers: {
|
|
524
|
+
"trainerroad-jsonformat": "camel-case",
|
|
525
|
+
"tr-cache-control": "use-cache",
|
|
526
|
+
referer: `${APP_URL}/workouts/list`,
|
|
527
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async getWorkoutInformation(workoutIds, usernameForReferer = null) {
|
|
533
|
+
const ids = (Array.isArray(workoutIds) ? workoutIds : [])
|
|
534
|
+
.map((value) => Number(value))
|
|
535
|
+
.filter((value) => Number.isFinite(value));
|
|
536
|
+
const params = new URLSearchParams({ ids: ids.join(",") });
|
|
537
|
+
return this.#requestJson(`/app/api/workout-information?${params.toString()}`, {
|
|
538
|
+
headers: {
|
|
539
|
+
"trainerroad-jsonformat": "camel-case",
|
|
540
|
+
"tr-cache-control": "use-cache",
|
|
541
|
+
referer: `${APP_URL}/cycling/train-now`,
|
|
542
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async getTrainNowStatus(usernameForReferer = null) {
|
|
548
|
+
return this.#requestJson("/app/api/train-now", {
|
|
549
|
+
headers: {
|
|
550
|
+
"trainerroad-jsonformat": "camel-case",
|
|
551
|
+
"tr-cache-control": "use-cache",
|
|
552
|
+
referer: `${APP_URL}/cycling/train-now`,
|
|
553
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async getTrainNowSuggestions({ duration, numSuggestions = 10 } = {}, usernameForReferer = null) {
|
|
559
|
+
return this.#requestJson("/app/api/train-now", {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: {
|
|
562
|
+
"content-type": "application/json",
|
|
563
|
+
"trainerroad-jsonformat": "camel-case",
|
|
564
|
+
"tr-cache-control": "no-cache",
|
|
565
|
+
referer: `${APP_URL}/cycling/train-now`,
|
|
566
|
+
...(usernameForReferer ? { "x-trainerroad-username": usernameForReferer } : {}),
|
|
567
|
+
},
|
|
568
|
+
body: JSON.stringify({ duration, numSuggestions }),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async tryAddWorkoutToCalendar(
|
|
573
|
+
workoutId,
|
|
574
|
+
dateIso,
|
|
575
|
+
{ timeOfDay = null, isManualComplete = false, outside = false, usernameForReferer } = {},
|
|
576
|
+
) {
|
|
577
|
+
const attempts = [
|
|
578
|
+
{
|
|
579
|
+
endpoint: "react-calendar",
|
|
580
|
+
path: "/app/api/react-calendar/planned-tr-workout",
|
|
581
|
+
body: {
|
|
582
|
+
date: toApiDateOnly(dateIso),
|
|
583
|
+
time: timeOfDay,
|
|
584
|
+
workoutId: Number(workoutId),
|
|
585
|
+
type: outside ? 1 : 0,
|
|
586
|
+
recommendationReason: null,
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
endpoint: "legacy-plannedactivities",
|
|
591
|
+
path: "/app/api/calendar/plannedactivities/workout",
|
|
592
|
+
body: {
|
|
593
|
+
id: Number(workoutId),
|
|
594
|
+
date: dateIso,
|
|
595
|
+
timeOfDay,
|
|
596
|
+
isManualComplete: Boolean(isManualComplete),
|
|
597
|
+
recommendationReason: null,
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
];
|
|
601
|
+
|
|
602
|
+
const results = [];
|
|
603
|
+
for (const attempt of attempts) {
|
|
604
|
+
const response = await this.#request(attempt.path, {
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: {
|
|
607
|
+
"content-type": "application/json",
|
|
608
|
+
"trainerroad-jsonformat": "camel-case",
|
|
609
|
+
"tr-cache-control": "no-cache",
|
|
610
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
611
|
+
},
|
|
612
|
+
body: JSON.stringify(attempt.body),
|
|
613
|
+
});
|
|
614
|
+
const text = await response.text();
|
|
615
|
+
let payload;
|
|
616
|
+
try {
|
|
617
|
+
payload = JSON.parse(text);
|
|
618
|
+
} catch {
|
|
619
|
+
payload = text;
|
|
620
|
+
}
|
|
621
|
+
results.push({
|
|
622
|
+
endpoint: attempt.endpoint,
|
|
623
|
+
status: response.status,
|
|
624
|
+
ok: response.ok,
|
|
625
|
+
requestBody: attempt.body,
|
|
626
|
+
response: payload,
|
|
627
|
+
});
|
|
628
|
+
if (response.ok) break;
|
|
629
|
+
}
|
|
630
|
+
return results;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async copyPlannedActivity(plannedActivityId, dateIso, usernameForReferer) {
|
|
634
|
+
const response = await this.#request(
|
|
635
|
+
`/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}/copy/${encodeURIComponent(dateIso)}`,
|
|
636
|
+
{
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: {
|
|
639
|
+
"trainerroad-jsonformat": "camel-case",
|
|
640
|
+
"tr-cache-control": "no-cache",
|
|
641
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
const text = await response.text();
|
|
648
|
+
throw new Error(
|
|
649
|
+
`Request failed: ${response.status} ${response.statusText} for copy planned activity -> ${text}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const text = await response.text();
|
|
654
|
+
if (!text) {
|
|
655
|
+
return { ok: true, status: response.status, empty: true };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
return JSON.parse(text);
|
|
660
|
+
} catch {
|
|
661
|
+
return { ok: true, status: response.status, raw: text };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async getPlannedActivity(plannedActivityId, usernameForReferer) {
|
|
666
|
+
return this.#requestJson(`/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}`, {
|
|
667
|
+
headers: {
|
|
668
|
+
"trainerroad-jsonformat": "camel-case",
|
|
669
|
+
"tr-cache-control": "use-cache",
|
|
670
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async getPlannedActivityAlternates(plannedActivityId, category, usernameForReferer) {
|
|
676
|
+
return this.#requestJson(
|
|
677
|
+
`/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}/alternates/${encodeURIComponent(category)}`,
|
|
678
|
+
{
|
|
679
|
+
headers: {
|
|
680
|
+
"trainerroad-jsonformat": "camel-case",
|
|
681
|
+
"tr-cache-control": "use-cache",
|
|
682
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async movePlannedActivity(plannedActivityId, newDateIso, usernameForReferer) {
|
|
689
|
+
return this.#requestJson(`/app/api/react-calendar/planned-activity/${encodeURIComponent(plannedActivityId)}/move`, {
|
|
690
|
+
method: "PUT",
|
|
691
|
+
headers: {
|
|
692
|
+
"content-type": "application/json",
|
|
693
|
+
"trainerroad-jsonformat": "camel-case",
|
|
694
|
+
"tr-cache-control": "no-cache",
|
|
695
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
696
|
+
},
|
|
697
|
+
body: JSON.stringify({ newDate: toApiDateOnly(newDateIso) }),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async replacePlannedActivityWithAlternate(
|
|
702
|
+
plannedActivityId,
|
|
703
|
+
alternateWorkoutId,
|
|
704
|
+
{ updateDuration = false, usernameForReferer } = {},
|
|
705
|
+
) {
|
|
706
|
+
return this.#requestJson(
|
|
707
|
+
`/app/api/react-calendar/planned-activity/${encodeURIComponent(plannedActivityId)}/replace-with-alternate`,
|
|
708
|
+
{
|
|
709
|
+
method: "PUT",
|
|
710
|
+
headers: {
|
|
711
|
+
"content-type": "application/json",
|
|
712
|
+
"trainerroad-jsonformat": "camel-case",
|
|
713
|
+
"tr-cache-control": "no-cache",
|
|
714
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
715
|
+
},
|
|
716
|
+
body: JSON.stringify({
|
|
717
|
+
alternateWorkoutId: Number(alternateWorkoutId),
|
|
718
|
+
updateDuration: Boolean(updateDuration),
|
|
719
|
+
}),
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async switchPlannedActivityMode(plannedActivityId, mode, usernameForReferer) {
|
|
725
|
+
const normalizedMode = String(mode ?? "").trim().toLowerCase();
|
|
726
|
+
if (!["inside", "outside"].includes(normalizedMode)) {
|
|
727
|
+
throw new Error(`Invalid mode "${mode}". Expected "inside" or "outside".`);
|
|
728
|
+
}
|
|
729
|
+
return this.#requestJson(
|
|
730
|
+
`/app/api/react-calendar/planned-activity/${encodeURIComponent(plannedActivityId)}/switch-to-${normalizedMode}`,
|
|
731
|
+
{
|
|
732
|
+
method: "PUT",
|
|
733
|
+
headers: {
|
|
734
|
+
"trainerroad-jsonformat": "camel-case",
|
|
735
|
+
"tr-cache-control": "no-cache",
|
|
736
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
448
742
|
async getPersonalRecordsByActivityIds(memberId, usernameForReferer, activityIds) {
|
|
449
743
|
if (activityIds.length === 0) return {};
|
|
450
744
|
const batches = chunk(activityIds, 100);
|