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.
@@ -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 toIsoDateOnly(value) {
15
- if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
16
- return new Date(value).toISOString().slice(0, 10);
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 = toIsoDateOnly(item.started);
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);