trainerroad-cli 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-09-02
4
+
5
+ - Added `add-annotation`, `remove-annotation`, and `annotation-details`: create time off, illness, injury, and note entries (single or multi-day via `--days` or `--end-date`), remove them by id (no-op when already gone), and read the title and notes that the timeline rows omit. Uses `POST/DELETE /app/api/calendar/annotations` and `GET /app/api/react-calendar/annotation/{id}`, confirmed live.
6
+ - Added `add-event`: creates a race or event via `POST /app/api/calendar/plannedactivities/event` with discipline (by name or id), A/B/C priority, duration, and either a TSS or a 1-10 intensity estimate. Confirmed live. Events are removed with `remove-workout`.
7
+ - Added `remove-workout`: deletes a planned workout or event by planned-activity id via `DELETE /app/api/calendar/plannedactivities/{id}` (confirmed live), with `--dry-run` and a no-op when the record is already gone.
8
+ - Fixed swapped annotation type labels: typeId 2 is illness and typeId 4 is time off (checked against the web app's enum and real calendar entries). Plan-marker ids 5 to 10 are now labelled too.
9
+ - Added `workout-image`: saves a workout's power-profile chart as PNG (default, via the optional `@resvg/resvg-js` package) or SVG. Workout records from `workout-library` and `workout-details` now include `chartUrl`.
10
+ - Fixed `power-records`: the web app moved to `POST /app/api/personal-records/{memberId}`; the old `/for-date-range` path stays as a 404 fallback.
11
+ - Key normalisation now runs per object at every depth, because personal-records nests PascalCase rows inside a camelCase envelope.
12
+ - Help output uses per-command flag descriptions, so shared names like `--type` and `--days` read correctly for each command.
13
+ - Write commands report an `adaptiveTraining` note, and the README explains how Adaptive Training reacts to calendar changes and how an agent should read an athlete's situation.
14
+ - Fixed `login`: TrainerRoad replaced the server-rendered login form with a React app, so the token scrape failed. `login` now posts JSON to `/app/api/login/login` the way the web app does, and falls back to the old form flow if that route ever disappears. A bad password now fails with "TrainerRoad rejected the username or password" instead of a redirect error.
15
+ - Fixed `plan` returning 404s: plan-builder endpoints are requested by numeric `memberId` instead of username (the username stays in the referer). The career-summary endpoint had the same problem and now takes a `memberId` too.
16
+ - Made a missing `current-custom-plan` (HTTP 404) non-fatal. The web app no longer calls that endpoint at all, so when it is absent `currentPlan` is derived from `all-user-plans` (the plan whose start/end window contains today) and its matching `plan-phases` rows. `currentPlan.source` reports which path produced it, and `currentPhaseName` names the active phase.
17
+ - Every API request now sends `trainerroad-jsonformat: camel-case` by default, and a PascalCase payload is normalised to camelCase if the header is ever ignored, so a dropped header or a casing change cannot blank the output.
18
+ - Request failures throw `HttpError` with `status`, `statusText`, `path`, and `payload` (message unchanged).
19
+ - Added tests for the JSON login flow and its fallback, memberId-keyed URLs, the format header, PascalCase normalisation, non-fatal 404 handling, and date-window current-plan derivation.
20
+
3
21
  ## 0.3.0 - 2026-03-25
4
22
 
5
23
  - Added richer command help with examples, required-flag metadata, and machine-readable help payloads.
package/README.md CHANGED
@@ -26,6 +26,10 @@ It can also perform a small set of verified calendar writes for planned workouts
26
26
  - list TrainerRoad alternate workout options
27
27
  - replace a workout with a specific alternate
28
28
  - switch a workout between inside and outside
29
+ - remove a planned workout or event
30
+ - add a race or event with discipline, priority, and a TSS or intensity estimate
31
+ - add and remove calendar annotations: time off, illness, injury, notes
32
+ - save a workout's power-profile chart as PNG or SVG
29
33
 
30
34
  ## Agent-Friendly Behavior
31
35
 
@@ -34,6 +38,30 @@ It can also perform a small set of verified calendar writes for planned workouts
34
38
  - Command help includes concrete examples and flag descriptions.
35
39
  - Write commands support `--dry-run` previews.
36
40
  - Common retry cases are idempotent no-ops instead of duplicate calendar writes.
41
+ - Every write returns `before`/`after` (or the created record) so the caller can verify without a second call.
42
+
43
+ ## How TrainerRoad Reacts To Calendar Changes
44
+
45
+ TrainerRoad's Adaptive Training treats the calendar as input. Agents managing an athlete's plan should expect:
46
+
47
+ - Removing or skipping a planned workout can cause TrainerRoad to rebuild the upcoming plan around the gap.
48
+ - Adding a workout, or marking time off, illness, or injury, can likewise shift the surrounding planned workouts.
49
+ - Planned workouts carry `recommendationReason`, `adaptationLocked`, and `adaptationAltered` fields that show whether TrainerRoad chose or altered them.
50
+ - After any write, re-read `future --days 14 --details` before deciding the next step. Do not assume the calendar looks the way it did before the write.
51
+
52
+ Write commands echo this in an `adaptiveTraining` field so it is visible in machine-readable output.
53
+
54
+ ## Reading The Athlete's Situation
55
+
56
+ A useful order for an agent building a picture of how training is going:
57
+
58
+ 1. `whoami` for FTP, weight, and timezone.
59
+ 2. `plan --view current` for the plan, its phases, and the phase the athlete is in today.
60
+ 3. `past --days 28 --details` and `future --days 14 --details` for what happened and what is scheduled.
61
+ 4. `levels` for progression levels by zone, and `ftp-prediction` for where FTP is heading.
62
+ 5. `annotations --from <date>` for time off, illness, and injury, and `annotation-details --id` for the notes behind them.
63
+ 6. `events` for upcoming races.
64
+ 7. `workout-image --id <workout-id>` when a picture of a workout's intervals helps explain it.
37
65
 
38
66
  ## Install
39
67
 
@@ -111,12 +139,29 @@ trainerroad-cli move-workout --id <planned-activity-id> --to 2026-03-13 --json
111
139
  trainerroad-cli replace-workout --id <planned-activity-id> --alternate-id <workout-id> --json
112
140
  trainerroad-cli switch-workout --id <planned-activity-id> --mode outside --json
113
141
  trainerroad-cli copy-workout --id <planned-activity-id> --date 2026-03-16 --json
142
+ trainerroad-cli remove-workout --id <planned-activity-id> --dry-run
114
143
  ```
115
144
 
116
145
  `copy-workout` is the reliable way to place an existing planned workout on another date.
117
146
  `add-workout` exists, but TrainerRoad's add endpoints are still inconsistent and may fail even after retry/reconciliation.
118
147
 
119
- 4. Discover all commands
148
+ 4. Events, annotations, and images
149
+
150
+ ```bash
151
+ trainerroad-cli add-event --name "Black Fork" --date 2027-05-01 --discipline gravel --priority A --duration 300 --tss 340 --dry-run
152
+ trainerroad-cli add-event --name "Tuesday crit" --date 2026-10-06 --discipline criterium --priority C --duration 60 --intensity 9 --json
153
+ trainerroad-cli remove-workout --id <planned-activity-id> --json # events and workouts share this
154
+ trainerroad-cli add-annotation --type time-off --date 2026-09-21 --days 3 --title "Travel" --dry-run
155
+ trainerroad-cli add-annotation --type illness --date 2026-09-21 --end-date 2026-09-23 --notes "Head cold" --json
156
+ trainerroad-cli annotation-details --id <annotation-id> --json
157
+ trainerroad-cli remove-annotation --id <annotation-id> --json
158
+ trainerroad-cli workout-image --id 1592808 --file fishers.png
159
+ trainerroad-cli workout-image --id 1592808 --format svg --file fishers.svg
160
+ ```
161
+
162
+ PNG output uses the optional `@resvg/resvg-js` package. If it is not installed, use `--format svg`.
163
+
164
+ 5. Discover all commands
120
165
 
121
166
  ```bash
122
167
  trainerroad-cli help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trainerroad-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Unofficial CLI for authenticating with TrainerRoad and querying timeline/workout data",
5
5
  "main": "src/cli.mjs",
6
6
  "bin": {
@@ -45,7 +45,11 @@
45
45
  "move-workout": "node src/cli.mjs move-workout",
46
46
  "replace-workout": "node src/cli.mjs replace-workout",
47
47
  "switch-workout": "node src/cli.mjs switch-workout",
48
- "logout": "node src/cli.mjs logout"
48
+ "logout": "node src/cli.mjs logout",
49
+ "workout-image": "node src/cli.mjs workout-image",
50
+ "add-annotation": "node src/cli.mjs add-annotation",
51
+ "remove-annotation": "node src/cli.mjs remove-annotation",
52
+ "annotation-details": "node src/cli.mjs annotation-details"
49
53
  },
50
54
  "keywords": [
51
55
  "trainerroad",
@@ -60,5 +64,8 @@
60
64
  "type": "module",
61
65
  "dependencies": {
62
66
  "playwright": "^1.58.2"
67
+ },
68
+ "optionalDependencies": {
69
+ "@resvg/resvg-js": "^2.6.2"
63
70
  }
64
71
  }
package/src/cli.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  COMMAND_REQUIRED_FLAGS,
21
21
  COMMANDS,
22
22
  FLAG_DETAILS,
23
+ COMMAND_FLAG_DETAILS,
23
24
  FILTERABLE_COMMANDS,
24
25
  GLOBAL_NOTES,
25
26
  PROJECT_NOTICE,
@@ -43,7 +44,15 @@ import {
43
44
  commandReplaceWorkout,
44
45
  commandSwitchWorkout,
45
46
  commandWorkoutAlternates,
47
+ commandRemoveWorkout,
46
48
  } from "./commands/workout-mutations.mjs";
49
+ import {
50
+ commandAddAnnotation,
51
+ commandAnnotationDetails,
52
+ commandRemoveAnnotation,
53
+ } from "./commands/annotation-mutations.mjs";
54
+ import { commandWorkoutImage } from "./commands/workout-image.mjs";
55
+ import { commandAddEvent } from "./commands/event-mutations.mjs";
47
56
  import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
48
57
  import {
49
58
  formatDateTimeInTimeZone,
@@ -201,8 +210,12 @@ function validateCommandFlags(command, flags) {
201
210
  return { unknownFlags, allowlist: Array.from(allowlist) };
202
211
  }
203
212
 
204
- function formatFlagLabel(name) {
205
- const detail = FLAG_DETAILS[name] ?? {};
213
+ function flagDetail(command, name) {
214
+ return COMMAND_FLAG_DETAILS[command]?.[name] ?? FLAG_DETAILS[name] ?? {};
215
+ }
216
+
217
+ function formatFlagLabel(command, name) {
218
+ const detail = flagDetail(command, name);
206
219
  return `--${name}${detail.placeholder ? ` ${detail.placeholder}` : ""}`;
207
220
  }
208
221
 
@@ -211,8 +224,8 @@ function getCommandHelpOptions(command) {
211
224
  const requiredFlags = new Set(COMMAND_REQUIRED_FLAGS[command] ?? []);
212
225
  return allowlist.map((name) => ({
213
226
  name,
214
- label: formatFlagLabel(name),
215
- description: FLAG_DETAILS[name]?.description ?? "No description available.",
227
+ label: formatFlagLabel(command, name),
228
+ description: flagDetail(command, name).description ?? "No description available.",
216
229
  required: requiredFlags.has(name),
217
230
  }));
218
231
  }
@@ -405,14 +418,14 @@ function getLastItem(values) {
405
418
 
406
419
  function compactPersonalRecord(record) {
407
420
  return {
408
- seconds: record?.Seconds ?? null,
409
- watts: record?.Watts ?? null,
410
- workoutDate: record?.WorkoutDate ?? null,
411
- workoutSeconds: record?.WorkoutSeconds ?? null,
412
- workoutGuid: record?.WorkoutGuid ?? null,
413
- workoutRecordId: record?.WorkoutRecordId ?? null,
414
- workoutRecordName: record?.WorkoutRecordName ?? null,
415
- surveyResponse: record?.SurveyResponseTranslated ?? null,
421
+ seconds: record?.seconds ?? record?.Seconds ?? null,
422
+ watts: record?.watts ?? record?.Watts ?? null,
423
+ workoutDate: record?.workoutDate ?? record?.WorkoutDate ?? null,
424
+ workoutSeconds: record?.workoutSeconds ?? record?.WorkoutSeconds ?? null,
425
+ workoutGuid: record?.workoutGuid ?? record?.WorkoutGuid ?? null,
426
+ workoutRecordId: record?.workoutRecordId ?? record?.WorkoutRecordId ?? null,
427
+ workoutRecordName: record?.workoutRecordName ?? record?.WorkoutRecordName ?? null,
428
+ surveyResponse: record?.surveyResponseTranslated ?? record?.SurveyResponseTranslated ?? null,
416
429
  };
417
430
  }
418
431
 
@@ -786,6 +799,24 @@ async function main() {
786
799
  case "switch-workout":
787
800
  await commandSwitchWorkout(flags, commandDeps);
788
801
  return;
802
+ case "add-event":
803
+ await commandAddEvent(flags, commandDeps);
804
+ return;
805
+ case "remove-workout":
806
+ await commandRemoveWorkout(flags, commandDeps);
807
+ return;
808
+ case "workout-image":
809
+ await commandWorkoutImage(flags, commandDeps);
810
+ return;
811
+ case "annotation-details":
812
+ await commandAnnotationDetails(flags, commandDeps);
813
+ return;
814
+ case "add-annotation":
815
+ await commandAddAnnotation(flags, commandDeps);
816
+ return;
817
+ case "remove-annotation":
818
+ await commandRemoveAnnotation(flags, commandDeps);
819
+ return;
789
820
  case "logout":
790
821
  await commandLogout(flags, commandDeps);
791
822
  return;
@@ -0,0 +1,253 @@
1
+ import {
2
+ ANNOTATION_TYPE_IDS,
3
+ ANNOTATION_TYPE_LABELS,
4
+ compactAnnotationDetail,
5
+ } from "../lib/planning-normalizers.mjs";
6
+ import { shiftDateOnly } from "../lib/timezone.mjs";
7
+ import { isHttpStatus } from "../trainerroad-client.mjs";
8
+
9
+ const DAY_SECONDS = 86_400;
10
+
11
+ function dateOnlyDiffDays(fromDateOnly, toDateOnly) {
12
+ const [fy, fm, fd] = fromDateOnly.split("-").map(Number);
13
+ const [ty, tm, td] = toDateOnly.split("-").map(Number);
14
+ return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / DAY_SECONDS / 1000);
15
+ }
16
+
17
+ // TrainerRoad's Adaptive Training reacts to calendar changes: time off, illness, and injury
18
+ // entries can prompt it to adjust the surrounding planned workouts. Surface that to agents.
19
+ const ADAPTIVE_NOTE =
20
+ "TrainerRoad may adapt nearby planned workouts in response to this change. Re-read `future` afterwards.";
21
+
22
+ function resolveAnnotationType(value) {
23
+ if (value === undefined || value === null || value === "") return null;
24
+ const raw = String(value).trim().toLowerCase();
25
+ if (/^\d+$/.test(raw)) {
26
+ const typeId = Number(raw);
27
+ return { typeId, typeLabel: ANNOTATION_TYPE_LABELS[typeId] ?? `type-${typeId}` };
28
+ }
29
+ const typeId = ANNOTATION_TYPE_IDS[raw] ?? ANNOTATION_TYPE_IDS[raw.replace(/[\s_]+/g, "-")];
30
+ if (typeId === undefined) return null;
31
+ return { typeId, typeLabel: ANNOTATION_TYPE_LABELS[typeId] };
32
+ }
33
+
34
+ function validTypeNames() {
35
+ return Object.keys(ANNOTATION_TYPE_IDS).join(", ");
36
+ }
37
+
38
+ function annotationTitleFor(typeLabel) {
39
+ return typeLabel
40
+ .split("-")
41
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
42
+ .join(" ");
43
+ }
44
+
45
+ async function requirePrivateMember(flags, deps) {
46
+ const { withClient } = deps;
47
+ const client = await withClient(flags);
48
+ let memberInfo;
49
+ try {
50
+ memberInfo = await client.getMemberInfo();
51
+ } catch {
52
+ throw new Error(
53
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
54
+ );
55
+ }
56
+ return { client, memberInfo };
57
+ }
58
+
59
+ async function annotationIdsOnCalendar(client, memberInfo) {
60
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
61
+ const rows = Array.isArray(timeline?.annotations) ? timeline.annotations : [];
62
+ return new Set(rows.map((row) => String(row?.id)).filter((id) => id && id !== "undefined"));
63
+ }
64
+
65
+ export async function commandAnnotationDetails(flags, deps) {
66
+ const { isJsonMode, requireFlag, writeOutput } = deps;
67
+ const annotationId = String(requireFlag("annotation-details", flags, "id"));
68
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
69
+ const raw = await client.getAnnotation(annotationId, memberInfo.username);
70
+ const annotation = compactAnnotationDetail(raw);
71
+
72
+ const payload = {
73
+ generatedAt: new Date().toISOString(),
74
+ command: "annotation-details",
75
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
76
+ query: { annotationId },
77
+ annotation,
78
+ raw: flags.full ? raw : undefined,
79
+ };
80
+
81
+ if (!isJsonMode(flags)) {
82
+ await writeOutput(payload, flags, (value) => {
83
+ const a = value.annotation;
84
+ return [
85
+ `${a.typeLabel} ${a.dateOnly}..${a.endDateOnly} (${a.durationDays}d) | id=${a.id}`,
86
+ `title: ${a.title ?? "(none)"}`,
87
+ `notes: ${a.text ?? "(none)"}`,
88
+ ].join("\n");
89
+ });
90
+ return;
91
+ }
92
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
93
+ }
94
+
95
+ export async function commandAddAnnotation(flags, deps) {
96
+ const { isJsonMode, requireFlag, toBoolean, normalizeDateOnlyInput, requirePositiveInteger, writeOutput } = deps;
97
+ const dryRun = toBoolean(flags["dry-run"], false);
98
+ const type = resolveAnnotationType(requireFlag("add-annotation", flags, "type"));
99
+ if (!type) {
100
+ throw new Error(`Invalid --type "${flags.type}". Expected one of: ${validTypeNames()}, or a numeric typeId.`);
101
+ }
102
+ const date = normalizeDateOnlyInput(requireFlag("add-annotation", flags, "date"), null);
103
+ if (!date) throw new Error(`Invalid --date "${flags.date}". Expected YYYY-MM-DD.`);
104
+
105
+ let durationDays = requirePositiveInteger(flags.days, 1);
106
+ if (flags["end-date"] !== undefined && flags["end-date"] !== null && flags["end-date"] !== "") {
107
+ const endDate = normalizeDateOnlyInput(flags["end-date"], null);
108
+ if (!endDate) throw new Error(`Invalid --end-date "${flags["end-date"]}". Expected YYYY-MM-DD.`);
109
+ const diff = dateOnlyDiffDays(date, endDate);
110
+ if (diff === null || diff < 0) {
111
+ throw new Error(`--end-date ${endDate} is before --date ${date}.`);
112
+ }
113
+ durationDays = diff + 1;
114
+ }
115
+ const endDateOnly = shiftDateOnly(date, durationDays - 1);
116
+
117
+ const title = flags.title !== undefined && flags.title !== null && String(flags.title) !== ""
118
+ ? String(flags.title)
119
+ : annotationTitleFor(type.typeLabel);
120
+ const text = flags.notes !== undefined && flags.notes !== null ? String(flags.notes) : "";
121
+ const colorId = requirePositiveInteger(flags["color-id"], 2);
122
+
123
+ const request = {
124
+ date,
125
+ timeOfDay: null,
126
+ duration: durationDays * DAY_SECONDS,
127
+ title,
128
+ text,
129
+ typeId: type.typeId,
130
+ colorId,
131
+ };
132
+ const preview = {
133
+ typeId: type.typeId,
134
+ typeLabel: type.typeLabel,
135
+ dateOnly: date,
136
+ endDateOnly,
137
+ durationDays,
138
+ title,
139
+ text,
140
+ colorId,
141
+ };
142
+
143
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
144
+
145
+ const base = {
146
+ generatedAt: new Date().toISOString(),
147
+ command: "add-annotation",
148
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
149
+ query: preview,
150
+ adaptiveTraining: ADAPTIVE_NOTE,
151
+ };
152
+
153
+ if (dryRun) {
154
+ const payload = {
155
+ ...base,
156
+ dryRun: true,
157
+ annotation: null,
158
+ request,
159
+ message: `Would add ${type.typeLabel} "${title}" on ${date}${durationDays > 1 ? ` through ${endDateOnly}` : ""}.`,
160
+ };
161
+ if (!isJsonMode(flags)) {
162
+ await writeOutput(payload, flags, (value) => `${value.message}\nNo changes made.`);
163
+ return;
164
+ }
165
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
166
+ return;
167
+ }
168
+
169
+ const before = await annotationIdsOnCalendar(client, memberInfo);
170
+ await client.createAnnotation(request, memberInfo.username);
171
+ const after = await annotationIdsOnCalendar(client, memberInfo);
172
+ const createdIds = [...after].filter((id) => !before.has(id));
173
+
174
+ let annotation = null;
175
+ for (const id of createdIds) {
176
+ const detail = compactAnnotationDetail(await client.getAnnotation(id, memberInfo.username));
177
+ if (detail.typeId === type.typeId && detail.dateOnly === date) {
178
+ annotation = detail;
179
+ break;
180
+ }
181
+ }
182
+
183
+ const payload = {
184
+ ...base,
185
+ dryRun: false,
186
+ annotation,
187
+ request,
188
+ message: annotation
189
+ ? `Added ${annotation.typeLabel} "${annotation.title}" on ${annotation.dateOnly}${annotation.durationDays > 1 ? ` through ${annotation.endDateOnly}` : ""} (id=${annotation.id}).`
190
+ : "TrainerRoad accepted the annotation but it could not be located on the calendar afterwards. Run `annotations` to inspect.",
191
+ };
192
+
193
+ if (!isJsonMode(flags)) {
194
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
195
+ return;
196
+ }
197
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
198
+ }
199
+
200
+ export async function commandRemoveAnnotation(flags, deps) {
201
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
202
+ const dryRun = toBoolean(flags["dry-run"], false);
203
+ const annotationId = String(requireFlag("remove-annotation", flags, "id"));
204
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
205
+
206
+ let before = null;
207
+ try {
208
+ before = compactAnnotationDetail(await client.getAnnotation(annotationId, memberInfo.username));
209
+ } catch (error) {
210
+ if (!isHttpStatus(error, 404)) throw error;
211
+ }
212
+ const noop = before === null;
213
+
214
+ const base = {
215
+ generatedAt: new Date().toISOString(),
216
+ command: "remove-annotation",
217
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
218
+ query: { annotationId },
219
+ before,
220
+ adaptiveTraining: ADAPTIVE_NOTE,
221
+ };
222
+
223
+ if (dryRun || noop) {
224
+ const payload = {
225
+ ...base,
226
+ dryRun,
227
+ noop,
228
+ message: noop
229
+ ? `No annotation with id ${annotationId} exists on the calendar.`
230
+ : `Would remove ${before.typeLabel} "${before.title}" on ${before.dateOnly}.`,
231
+ };
232
+ if (!isJsonMode(flags)) {
233
+ await writeOutput(payload, flags, (value) => (value.noop ? value.message : `${value.message}\nNo changes made.`));
234
+ return;
235
+ }
236
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
237
+ return;
238
+ }
239
+
240
+ await client.deleteAnnotation(annotationId, memberInfo.username);
241
+ const payload = {
242
+ ...base,
243
+ dryRun: false,
244
+ noop: false,
245
+ message: `Removed ${before.typeLabel} "${before.title}" on ${before.dateOnly} (id=${annotationId}).`,
246
+ };
247
+
248
+ if (!isJsonMode(flags)) {
249
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
250
+ return;
251
+ }
252
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
253
+ }
@@ -0,0 +1,174 @@
1
+ import { compactEventRecord } from "../lib/planning-normalizers.mjs";
2
+
3
+ // Discipline ids from the web app's event picker (2026-09-02).
4
+ export const EVENT_DISCIPLINES = {
5
+ "climbing-road-race": 0,
6
+ "rolling-road-race": 1,
7
+ "time-trial": 2,
8
+ criterium: 3,
9
+ "gran-fondo": 4,
10
+ cyclocross: 5,
11
+ "sprint-triathlon": 6,
12
+ "olympic-triathlon": 7,
13
+ "half-triathlon": 8,
14
+ "full-triathlon": 9,
15
+ "off-road-triathlon": 10,
16
+ "xc-olympic": 11,
17
+ "xc-marathon": 12,
18
+ "short-track": 13,
19
+ gravity: 14,
20
+ enduro: 15,
21
+ gravel: 16,
22
+ };
23
+
24
+ const RACE_PRIORITIES = { a: 3, b: 2, c: 1 };
25
+ const STRESS_ESTIMATE_TSS = 1;
26
+ const STRESS_ESTIMATE_INTENSITY = 2;
27
+
28
+ const ADAPTIVE_NOTE =
29
+ "TrainerRoad builds and adapts the plan around A and B events, so adding one can reshape upcoming workouts. Re-read `future` afterwards.";
30
+
31
+ function disciplineLabel(id) {
32
+ return Object.entries(EVENT_DISCIPLINES).find(([, value]) => value === id)?.[0] ?? `discipline-${id}`;
33
+ }
34
+
35
+ function resolveDiscipline(value) {
36
+ if (value === undefined || value === null || value === "") return null;
37
+ const raw = String(value).trim().toLowerCase();
38
+ if (/^\d+$/.test(raw)) {
39
+ const id = Number(raw);
40
+ return { id, label: disciplineLabel(id) };
41
+ }
42
+ const key = raw.replace(/[\s_]+/g, "-");
43
+ if (EVENT_DISCIPLINES[key] === undefined) return null;
44
+ return { id: EVENT_DISCIPLINES[key], label: key };
45
+ }
46
+
47
+ function resolvePriority(value) {
48
+ const raw = String(value ?? "b").trim().toLowerCase();
49
+ if (/^[123]$/.test(raw)) return Number(raw);
50
+ return RACE_PRIORITIES[raw] ?? null;
51
+ }
52
+
53
+ async function requirePrivateMember(flags, deps) {
54
+ const { withClient } = deps;
55
+ const client = await withClient(flags);
56
+ try {
57
+ const memberInfo = await client.getMemberInfo();
58
+ return { client, memberInfo };
59
+ } catch {
60
+ throw new Error(
61
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
62
+ );
63
+ }
64
+ }
65
+
66
+ async function eventIdsOnCalendar(client, memberInfo) {
67
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
68
+ const rows = Array.isArray(timeline?.events) ? timeline.events : [];
69
+ return new Map(rows.filter((row) => row?.id).map((row) => [String(row.id), row]));
70
+ }
71
+
72
+ export async function commandAddEvent(flags, deps) {
73
+ const { isJsonMode, requireFlag, toBoolean, normalizeDateOnlyInput, requirePositiveInteger, requireNumber, writeOutput } = deps;
74
+ const dryRun = toBoolean(flags["dry-run"], false);
75
+ const name = String(requireFlag("add-event", flags, "name"));
76
+ const date = normalizeDateOnlyInput(requireFlag("add-event", flags, "date"), null);
77
+ if (!date) throw new Error(`Invalid --date "${flags.date}". Expected YYYY-MM-DD.`);
78
+ const discipline = resolveDiscipline(requireFlag("add-event", flags, "discipline"));
79
+ if (!discipline) {
80
+ throw new Error(
81
+ `Invalid --discipline "${flags.discipline}". Expected one of: ${Object.keys(EVENT_DISCIPLINES).join(", ")}, or a numeric id.`,
82
+ );
83
+ }
84
+ const priority = resolvePriority(flags.priority);
85
+ if (priority === null) throw new Error(`Invalid --priority "${flags.priority}". Expected A, B, or C.`);
86
+ const durationMinutes = requirePositiveInteger(flags.duration, null);
87
+ if (!durationMinutes) throw new Error("--duration <minutes> is required for add-event.");
88
+
89
+ const tss = flags.tss !== undefined && flags.tss !== null && flags.tss !== "" ? requireNumber(flags.tss, null) : null;
90
+ const intensity =
91
+ flags.intensity !== undefined && flags.intensity !== null && flags.intensity !== ""
92
+ ? requireNumber(flags.intensity, null)
93
+ : null;
94
+ if (tss === null && intensity === null) {
95
+ throw new Error("add-event needs either --tss <number> or --intensity <1-10> so TrainerRoad can estimate the event's stress.");
96
+ }
97
+ const notes = flags.notes !== undefined && flags.notes !== null ? String(flags.notes) : "";
98
+
99
+ const request = {
100
+ customPlanId: null,
101
+ name,
102
+ date,
103
+ time: null,
104
+ discipline: discipline.id,
105
+ duration: durationMinutes * 60,
106
+ notes,
107
+ racePriority: priority,
108
+ stressEstimateType: tss !== null ? STRESS_ESTIMATE_TSS : STRESS_ESTIMATE_INTENSITY,
109
+ stressEstimateValue: tss !== null ? null : intensity,
110
+ tss: tss !== null ? tss : null,
111
+ manuallyCompleted: false,
112
+ };
113
+ const priorityLabel = Object.entries(RACE_PRIORITIES).find(([, value]) => value === priority)[0].toUpperCase();
114
+ const preview = {
115
+ name,
116
+ date,
117
+ discipline: discipline.label,
118
+ disciplineId: discipline.id,
119
+ priority: priorityLabel,
120
+ racePriority: priority,
121
+ durationMinutes,
122
+ tss,
123
+ intensity,
124
+ notes,
125
+ };
126
+
127
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
128
+ const base = {
129
+ generatedAt: new Date().toISOString(),
130
+ command: "add-event",
131
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
132
+ query: preview,
133
+ adaptiveTraining: ADAPTIVE_NOTE,
134
+ };
135
+
136
+ if (dryRun) {
137
+ const payload = {
138
+ ...base,
139
+ dryRun: true,
140
+ event: null,
141
+ request,
142
+ message: `Would add ${priorityLabel} event "${name}" (${discipline.label}) on ${date}.`,
143
+ };
144
+ if (!isJsonMode(flags)) {
145
+ await writeOutput(payload, flags, (value) => `${value.message}\nNo changes made.`);
146
+ return;
147
+ }
148
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
149
+ return;
150
+ }
151
+
152
+ const before = await eventIdsOnCalendar(client, memberInfo);
153
+ const response = await client.createEvent(request, memberInfo.username);
154
+ const after = await eventIdsOnCalendar(client, memberInfo);
155
+ const createdRow = [...after.entries()].find(([id]) => !before.has(id))?.[1] ?? null;
156
+ const event = createdRow ? compactEventRecord(createdRow) : null;
157
+
158
+ const payload = {
159
+ ...base,
160
+ dryRun: false,
161
+ event,
162
+ request,
163
+ response: flags.full ? response : undefined,
164
+ message: event
165
+ ? `Added ${priorityLabel} event "${event.name}" on ${event.dateOnly} (plannedActivityId=${event.id}). Remove it with remove-workout --id ${event.id}.`
166
+ : "TrainerRoad accepted the event but it could not be located on the calendar afterwards. Run `events` to inspect.",
167
+ };
168
+
169
+ if (!isJsonMode(flags)) {
170
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
171
+ return;
172
+ }
173
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
174
+ }