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.
@@ -37,6 +37,12 @@ export const COMMANDS = {
37
37
  "node src/cli.mjs timeline --target quinnsprouse --public --json",
38
38
  ],
39
39
  },
40
+ "train-now": {
41
+ summary: "Fetch TrainerRoad AI suggested workouts (TrainNow) for a target duration (private mode).",
42
+ usage: [
43
+ "node src/cli.mjs train-now [--duration <minutes>] [--num-suggestions <n>] [--category climbing|endurance|attacking] [--json|--jsonl]",
44
+ ],
45
+ },
40
46
  events: {
41
47
  summary: "Show calendar events/races from timeline (private mode).",
42
48
  usage: [
@@ -105,6 +111,60 @@ export const COMMANDS = {
105
111
  "node src/cli.mjs power-records [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--row-type 100|101] [--indoor-only true|false] [--limit <n>] [--full] [--json|--jsonl]",
106
112
  ],
107
113
  },
114
+ "workout-library": {
115
+ summary: "Search the TrainerRoad workout library with agent-friendly filters (private mode).",
116
+ usage: [
117
+ 'node src/cli.mjs workout-library [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--limit <n>] [--page-size <n>] [--json|--jsonl]',
118
+ ],
119
+ },
120
+ "workout-recommend": {
121
+ summary: "Recommend library workouts by ranking candidates against target duration/level/TSS (private mode).",
122
+ usage: [
123
+ 'node src/cli.mjs workout-recommend [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--target-duration <minutes>] [--target-tss <n>] [--target-level <n>] [--count <n>] [--candidate-limit <n>] [--page-size <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--json|--jsonl]',
124
+ ],
125
+ },
126
+ "workout-details": {
127
+ summary: "Fetch detailed workout-library metadata for a workout ID (private mode).",
128
+ usage: [
129
+ "node src/cli.mjs workout-details --id <workout-id> [--include-chart true|false] [--chart-point-limit <n>] [--json|--jsonl]",
130
+ ],
131
+ },
132
+ "add-workout": {
133
+ summary: "Add a library workout to the calendar on a target date (private mode; reconciles flaky API responses).",
134
+ usage: [
135
+ "node src/cli.mjs add-workout --workout-id <workout-id> --date YYYY-MM-DD [--outside true|false] [--json|--jsonl]",
136
+ ],
137
+ },
138
+ "copy-workout": {
139
+ summary: "Copy an existing planned workout to another date (private mode).",
140
+ usage: [
141
+ "node src/cli.mjs copy-workout --id <planned-activity-id> --date YYYY-MM-DD [--json|--jsonl]",
142
+ ],
143
+ },
144
+ "workout-alternates": {
145
+ summary: "List alternate workout options for a planned workout (private mode).",
146
+ usage: [
147
+ "node src/cli.mjs workout-alternates --id <planned-activity-id> [--category similar|easier|harder|longer|shorter] [--json|--jsonl]",
148
+ ],
149
+ },
150
+ "move-workout": {
151
+ summary: "Move a planned workout to a different date (private mode).",
152
+ usage: [
153
+ "node src/cli.mjs move-workout --id <planned-activity-id> --to YYYY-MM-DD [--json|--jsonl]",
154
+ ],
155
+ },
156
+ "replace-workout": {
157
+ summary: "Replace a planned workout with a specific alternate workout ID (private mode).",
158
+ usage: [
159
+ "node src/cli.mjs replace-workout --id <planned-activity-id> --alternate-id <workout-id> [--update-duration true|false] [--json|--jsonl]",
160
+ ],
161
+ },
162
+ "switch-workout": {
163
+ summary: "Switch a planned workout between inside and outside variants (private mode).",
164
+ usage: [
165
+ "node src/cli.mjs switch-workout --id <planned-activity-id> --mode inside|outside [--json|--jsonl]",
166
+ ],
167
+ },
108
168
  logout: {
109
169
  summary: "Clear local persisted session.",
110
170
  usage: ["node src/cli.mjs logout"],
@@ -115,7 +175,8 @@ export const PROJECT_NOTICE = "Unofficial tool. Not affiliated with or endorsed
115
175
 
116
176
  export const GLOBAL_NOTES = [
117
177
  PROJECT_NOTICE,
118
- "Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE",
178
+ "Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE, TR_TIMEZONE",
179
+ "Timezone: --tz <IANA timezone> (for example America/New_York).",
119
180
  "Output modes: default JSON, --json, --jsonl, --output <path>",
120
181
  "Session file default: .trainerroad/session.json",
121
182
  "Private mode: authenticated cookie session + full workout endpoints.",
@@ -161,8 +222,7 @@ function trimFlagPrefix(flag) {
161
222
 
162
223
  function mergeFlagGroups(...groups) {
163
224
  return new Set(
164
- groups
165
- .flat()
225
+ [...groups.flat(), "tz"]
166
226
  .map((flag) => trimFlagPrefix(flag))
167
227
  .filter(Boolean),
168
228
  );
@@ -212,6 +272,14 @@ export const COMMAND_FLAG_ALLOWLIST = {
212
272
  SHARED_FLAGS.publicProfile,
213
273
  ["full"],
214
274
  ),
275
+ "train-now": mergeFlagGroups(
276
+ SHARED_FLAGS.help,
277
+ SHARED_FLAGS.output,
278
+ SHARED_FLAGS.jsonAndJsonl,
279
+ SHARED_FLAGS.session,
280
+ SHARED_FLAGS.credentials,
281
+ ["duration", "num-suggestions", "category"],
282
+ ),
215
283
  events: mergeFlagGroups(
216
284
  SHARED_FLAGS.help,
217
285
  SHARED_FLAGS.output,
@@ -324,5 +392,115 @@ export const COMMAND_FLAG_ALLOWLIST = {
324
392
  SHARED_FLAGS.credentials,
325
393
  ["start-date", "end-date", "row-type", "indoor-only", "slot", "limit", "full"],
326
394
  ),
395
+ "workout-library": mergeFlagGroups(
396
+ SHARED_FLAGS.help,
397
+ SHARED_FLAGS.output,
398
+ SHARED_FLAGS.jsonAndJsonl,
399
+ SHARED_FLAGS.session,
400
+ SHARED_FLAGS.credentials,
401
+ [
402
+ "search",
403
+ "zone",
404
+ "zone-id",
405
+ "profile",
406
+ "profile-id",
407
+ "outside",
408
+ "has-instructions",
409
+ "min-duration",
410
+ "max-duration",
411
+ "min-tss",
412
+ "max-tss",
413
+ "min-level",
414
+ "max-level",
415
+ "sort",
416
+ "limit",
417
+ "page-size",
418
+ ],
419
+ ),
420
+ "workout-recommend": mergeFlagGroups(
421
+ SHARED_FLAGS.help,
422
+ SHARED_FLAGS.output,
423
+ SHARED_FLAGS.jsonAndJsonl,
424
+ SHARED_FLAGS.session,
425
+ SHARED_FLAGS.credentials,
426
+ [
427
+ "search",
428
+ "zone",
429
+ "zone-id",
430
+ "profile",
431
+ "profile-id",
432
+ "outside",
433
+ "has-instructions",
434
+ "min-duration",
435
+ "max-duration",
436
+ "min-tss",
437
+ "max-tss",
438
+ "min-level",
439
+ "max-level",
440
+ "target-duration",
441
+ "target-tss",
442
+ "target-level",
443
+ "count",
444
+ "candidate-limit",
445
+ "page-size",
446
+ "sort",
447
+ ],
448
+ ),
449
+ "workout-details": mergeFlagGroups(
450
+ SHARED_FLAGS.help,
451
+ SHARED_FLAGS.output,
452
+ SHARED_FLAGS.jsonAndJsonl,
453
+ SHARED_FLAGS.session,
454
+ SHARED_FLAGS.credentials,
455
+ ["id", "include-chart", "chart-point-limit"],
456
+ ),
457
+ "add-workout": mergeFlagGroups(
458
+ SHARED_FLAGS.help,
459
+ SHARED_FLAGS.output,
460
+ SHARED_FLAGS.jsonAndJsonl,
461
+ SHARED_FLAGS.session,
462
+ SHARED_FLAGS.credentials,
463
+ ["workout-id", "date", "outside"],
464
+ ),
465
+ "copy-workout": mergeFlagGroups(
466
+ SHARED_FLAGS.help,
467
+ SHARED_FLAGS.output,
468
+ SHARED_FLAGS.jsonAndJsonl,
469
+ SHARED_FLAGS.session,
470
+ SHARED_FLAGS.credentials,
471
+ ["id", "date"],
472
+ ),
473
+ "workout-alternates": mergeFlagGroups(
474
+ SHARED_FLAGS.help,
475
+ SHARED_FLAGS.output,
476
+ SHARED_FLAGS.jsonAndJsonl,
477
+ SHARED_FLAGS.session,
478
+ SHARED_FLAGS.credentials,
479
+ ["id", "category"],
480
+ ),
481
+ "move-workout": mergeFlagGroups(
482
+ SHARED_FLAGS.help,
483
+ SHARED_FLAGS.output,
484
+ SHARED_FLAGS.jsonAndJsonl,
485
+ SHARED_FLAGS.session,
486
+ SHARED_FLAGS.credentials,
487
+ ["id", "to"],
488
+ ),
489
+ "replace-workout": mergeFlagGroups(
490
+ SHARED_FLAGS.help,
491
+ SHARED_FLAGS.output,
492
+ SHARED_FLAGS.jsonAndJsonl,
493
+ SHARED_FLAGS.session,
494
+ SHARED_FLAGS.credentials,
495
+ ["id", "alternate-id", "update-duration"],
496
+ ),
497
+ "switch-workout": mergeFlagGroups(
498
+ SHARED_FLAGS.help,
499
+ SHARED_FLAGS.output,
500
+ SHARED_FLAGS.jsonAndJsonl,
501
+ SHARED_FLAGS.session,
502
+ SHARED_FLAGS.credentials,
503
+ ["id", "mode"],
504
+ ),
327
505
  logout: mergeFlagGroups(SHARED_FLAGS.help, SHARED_FLAGS.output, SHARED_FLAGS.session),
328
506
  };
@@ -1,3 +1,5 @@
1
+ import { normalizeTimeZone, toDateOnlyInTimeZone } from "./timezone.mjs";
2
+
1
3
  const PROGRESSION_ZONE_META = {
2
4
  33: { zoneKey: "endurance", zoneLabel: "Endurance", sortOrder: 1 },
3
5
  16: { zoneKey: "tempo", zoneLabel: "Tempo", sortOrder: 2 },
@@ -20,8 +22,13 @@ function toIsoDateFromPlanned(item) {
20
22
  }
21
23
 
22
24
  export function toIsoDate(value) {
23
- if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
24
- return new Date(value).toISOString().slice(0, 10);
25
+ if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
26
+ return value.slice(0, 10);
27
+ }
28
+ return (
29
+ toDateOnlyInTimeZone(value, normalizeTimeZone(), { assumeUtcForOffsetlessDateTime: true }) ??
30
+ new Date(value).toISOString().slice(0, 10)
31
+ );
25
32
  }
26
33
 
27
34
  function toIsoDateFromCalendarDate(dateValue) {
@@ -0,0 +1,187 @@
1
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
2
+ const DATE_TIME_WITH_OFFSET_PATTERN = /(Z|[+\-]\d{2}:\d{2})$/i;
3
+
4
+ function isFiniteDate(date) {
5
+ return date instanceof Date && Number.isFinite(date.getTime());
6
+ }
7
+
8
+ function pickPart(parts, type) {
9
+ const found = parts.find((part) => part.type === type);
10
+ return found ? found.value : null;
11
+ }
12
+
13
+ function toDateTimeParts(date, timeZone) {
14
+ const formatter = new Intl.DateTimeFormat("en-US", {
15
+ timeZone,
16
+ year: "numeric",
17
+ month: "2-digit",
18
+ day: "2-digit",
19
+ hour: "2-digit",
20
+ minute: "2-digit",
21
+ second: "2-digit",
22
+ hour12: false,
23
+ timeZoneName: "shortOffset",
24
+ });
25
+ return formatter.formatToParts(date);
26
+ }
27
+
28
+ function toDateOnlyParts(date, timeZone) {
29
+ const formatter = new Intl.DateTimeFormat("en-US", {
30
+ timeZone,
31
+ year: "numeric",
32
+ month: "2-digit",
33
+ day: "2-digit",
34
+ });
35
+ return formatter.formatToParts(date);
36
+ }
37
+
38
+ export function normalizeTimeZone(value = null, fallback = null) {
39
+ const fromArg = typeof value === "string" ? value.trim() : "";
40
+ const fromEnv = typeof process.env.TR_TIMEZONE === "string" ? process.env.TR_TIMEZONE.trim() : "";
41
+ const fromIntl = Intl.DateTimeFormat().resolvedOptions().timeZone;
42
+ const candidate = fromArg || fromEnv || fallback || fromIntl || "UTC";
43
+
44
+ try {
45
+ new Intl.DateTimeFormat("en-US", { timeZone: candidate });
46
+ return candidate;
47
+ } catch {
48
+ throw new Error(`Invalid timezone "${candidate}". Use an IANA timezone like "America/New_York".`);
49
+ }
50
+ }
51
+
52
+ export function parseApiDateTime(value, { assumeUtcForOffsetlessDateTime = true } = {}) {
53
+ if (value == null) return null;
54
+
55
+ if (value instanceof Date) {
56
+ return isFiniteDate(value) ? new Date(value.getTime()) : null;
57
+ }
58
+
59
+ if (typeof value === "number") {
60
+ const parsed = new Date(value);
61
+ return isFiniteDate(parsed) ? parsed : null;
62
+ }
63
+
64
+ if (typeof value !== "string") return null;
65
+ const raw = value.trim();
66
+ if (!raw) return null;
67
+
68
+ if (DATE_ONLY_PATTERN.test(raw)) {
69
+ const parsedDateOnly = new Date(`${raw}T00:00:00Z`);
70
+ return isFiniteDate(parsedDateOnly) ? parsedDateOnly : null;
71
+ }
72
+
73
+ let normalized = raw;
74
+ if (
75
+ assumeUtcForOffsetlessDateTime &&
76
+ /^\d{4}-\d{2}-\d{2}T/.test(raw) &&
77
+ !DATE_TIME_WITH_OFFSET_PATTERN.test(raw)
78
+ ) {
79
+ normalized = `${raw}Z`;
80
+ }
81
+
82
+ const parsed = new Date(normalized);
83
+ return isFiniteDate(parsed) ? parsed : null;
84
+ }
85
+
86
+ export function toDateOnlyInTimeZone(
87
+ value,
88
+ timeZone,
89
+ { assumeUtcForOffsetlessDateTime = true } = {},
90
+ ) {
91
+ if (typeof value === "string" && DATE_ONLY_PATTERN.test(value.trim())) {
92
+ return value.trim();
93
+ }
94
+
95
+ const parsed = parseApiDateTime(value, { assumeUtcForOffsetlessDateTime });
96
+ if (!parsed) return null;
97
+
98
+ const parts = toDateOnlyParts(parsed, normalizeTimeZone(timeZone));
99
+ const year = pickPart(parts, "year");
100
+ const month = pickPart(parts, "month");
101
+ const day = pickPart(parts, "day");
102
+ if (!year || !month || !day) return null;
103
+ return `${year}-${month}-${day}`;
104
+ }
105
+
106
+ export function formatDateTimeInTimeZone(
107
+ value,
108
+ timeZone,
109
+ { assumeUtcForOffsetlessDateTime = true } = {},
110
+ ) {
111
+ const parsed = parseApiDateTime(value, { assumeUtcForOffsetlessDateTime });
112
+ if (!parsed) return null;
113
+
114
+ const parts = toDateTimeParts(parsed, normalizeTimeZone(timeZone));
115
+ const year = pickPart(parts, "year");
116
+ const month = pickPart(parts, "month");
117
+ const day = pickPart(parts, "day");
118
+ const hour = pickPart(parts, "hour");
119
+ const minute = pickPart(parts, "minute");
120
+ const second = pickPart(parts, "second");
121
+ const timeZoneName = pickPart(parts, "timeZoneName");
122
+ if (!year || !month || !day || !hour || !minute || !second) return null;
123
+
124
+ const suffix = timeZoneName ? ` ${timeZoneName}` : "";
125
+ return `${year}-${month}-${day}T${hour}:${minute}:${second}${suffix}`;
126
+ }
127
+
128
+ export function shiftDateOnly(dateOnly, days) {
129
+ const parsed = parseApiDateTime(dateOnly, { assumeUtcForOffsetlessDateTime: false });
130
+ if (!parsed) return null;
131
+ parsed.setUTCDate(parsed.getUTCDate() + Number(days));
132
+ return parsed.toISOString().slice(0, 10);
133
+ }
134
+
135
+ export function dateOnlyNowInTimeZone(timeZone) {
136
+ return toDateOnlyInTimeZone(new Date(), normalizeTimeZone(timeZone), {
137
+ assumeUtcForOffsetlessDateTime: false,
138
+ });
139
+ }
140
+
141
+ export function isoDateShiftInTimeZone(days, timeZone) {
142
+ const today = dateOnlyNowInTimeZone(timeZone);
143
+ return shiftDateOnly(today, days);
144
+ }
145
+
146
+ export function summarizeActivityTimeWindow(
147
+ started,
148
+ durationInSeconds,
149
+ timeZone,
150
+ { assumeUtcForOffsetlessDateTime = true } = {},
151
+ ) {
152
+ const startDate = parseApiDateTime(started, { assumeUtcForOffsetlessDateTime });
153
+ if (!startDate) return null;
154
+
155
+ const duration =
156
+ durationInSeconds != null && Number.isFinite(Number(durationInSeconds))
157
+ ? Number(durationInSeconds)
158
+ : null;
159
+ const endDate = duration != null ? new Date(startDate.getTime() + duration * 1000) : null;
160
+ const normalizedTimeZone = normalizeTimeZone(timeZone);
161
+
162
+ const startedDateOnly = toDateOnlyInTimeZone(startDate, normalizedTimeZone, {
163
+ assumeUtcForOffsetlessDateTime: false,
164
+ });
165
+ const endedDateOnly = endDate
166
+ ? toDateOnlyInTimeZone(endDate, normalizedTimeZone, {
167
+ assumeUtcForOffsetlessDateTime: false,
168
+ })
169
+ : null;
170
+
171
+ return {
172
+ startedAtUtc: startDate.toISOString(),
173
+ startedAtLocal: formatDateTimeInTimeZone(startDate, normalizedTimeZone, {
174
+ assumeUtcForOffsetlessDateTime: false,
175
+ }),
176
+ endedAtUtc: endDate ? endDate.toISOString() : null,
177
+ endedAtLocal: endDate
178
+ ? formatDateTimeInTimeZone(endDate, normalizedTimeZone, {
179
+ assumeUtcForOffsetlessDateTime: false,
180
+ })
181
+ : null,
182
+ localDate: startedDateOnly,
183
+ endLocalDate: endedDateOnly,
184
+ crossesMidnightLocal:
185
+ Boolean(startedDateOnly) && Boolean(endedDateOnly) && startedDateOnly !== endedDateOnly,
186
+ };
187
+ }