trainerroad-cli 0.2.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.
@@ -12,6 +12,7 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
12
12
  name,
13
13
  summary: def.summary,
14
14
  usage: level >= 2 ? def.usage : undefined,
15
+ examples: level >= 2 ? def.examples : undefined,
15
16
  supportsAgentFilters: FILTERABLE_COMMANDS.has(name),
16
17
  agentFilters: level >= 3 && FILTERABLE_COMMANDS.has(name) ? AGENT_FILTER_OPTIONS : undefined,
17
18
  agentOutputOptions:
@@ -27,11 +28,11 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
27
28
  level3: "Apply --from/--to/--type/--contains/--min-tss/--max-tss/--sort/--result-limit/--fields.",
28
29
  },
29
30
  firstSteps: [
30
- "node src/cli.mjs capabilities --json",
31
- "node src/cli.mjs whoami --json",
32
- "node src/cli.mjs future --days 30 --json",
33
- "node src/cli.mjs today --tz America/New_York --json",
34
- "node src/cli.mjs help future --json",
31
+ "trainerroad-cli capabilities --json",
32
+ "trainerroad-cli whoami --json",
33
+ "trainerroad-cli future --days 30 --json",
34
+ "trainerroad-cli today --tz America/New_York --json",
35
+ "trainerroad-cli help future --json",
35
36
  ],
36
37
  commandCount: commandEntries.length,
37
38
  commands: commandEntries,
@@ -42,17 +43,17 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
42
43
  {
43
44
  pattern: "Summarize future workouts in a date window",
44
45
  command:
45
- "node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --sort date --json",
46
+ "trainerroad-cli future --from 2026-03-01 --to 2026-03-31 --fields id,title,tss,date --sort date --json",
46
47
  },
47
48
  {
48
49
  pattern: "Find hard completed rides",
49
50
  command:
50
- "node src/cli.mjs past --days 90 --details --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
51
+ "trainerroad-cli past --days 90 --details --min-tss 80 --sort tss-desc --result-limit 20 --jsonl",
51
52
  },
52
53
  {
53
54
  pattern: "Extract only fields for downstream tools",
54
55
  command:
55
- "node src/cli.mjs today --details --fields recordType,name,started,tss --json",
56
+ "trainerroad-cli today --details --fields recordType,name,started,tss --json",
56
57
  },
57
58
  ];
58
59
  }
@@ -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
+ }
@@ -2,8 +2,21 @@ import {
2
2
  compactCurrentPlan,
3
3
  compactPlanPhase,
4
4
  compactPlanSummary,
5
+ deriveCurrentPlanFromPlans,
5
6
  toIsoDate,
6
7
  } from "../lib/planning-normalizers.mjs";
8
+ import { isHttpStatus } from "../trainerroad-client.mjs";
9
+ import { dateOnlyNowInTimeZone } from "../lib/timezone.mjs";
10
+
11
+ // current-custom-plan 404s for plan-builder plans; the web app no longer calls it either.
12
+ async function fetchCurrentCustomPlanOrNull(client, memberId, username) {
13
+ try {
14
+ return await client.getCurrentCustomPlan(memberId, username);
15
+ } catch (error) {
16
+ if (isHttpStatus(error, 404)) return null;
17
+ throw error;
18
+ }
19
+ }
7
20
 
8
21
  export async function commandPlan(flags, deps) {
9
22
  const {
@@ -25,11 +38,16 @@ export async function commandPlan(flags, deps) {
25
38
  throw new Error(`Invalid --view "${view}". Expected one of: current, phases, plans.`);
26
39
  }
27
40
 
28
- const [currentPlanRaw, allPlansRaw, phasesRaw] = await Promise.all([
29
- context.client.getCurrentCustomPlan(context.memberInfo.username),
30
- context.client.getAllUserPlans(context.memberInfo.username),
31
- context.client.getPlanPhases(context.memberInfo.username),
41
+ const { memberId, username } = context.memberInfo;
42
+ const [explicitCurrentPlanRaw, allPlansRaw, phasesRaw] = await Promise.all([
43
+ fetchCurrentCustomPlanOrNull(context.client, memberId, username),
44
+ context.client.getAllUserPlans(memberId, username),
45
+ context.client.getPlanPhases(memberId, username),
32
46
  ]);
47
+ const todayDateOnly = deps.todayDateOnly ?? dateOnlyNowInTimeZone(flags.tz ?? null);
48
+ const currentPlanRaw =
49
+ explicitCurrentPlanRaw ??
50
+ deriveCurrentPlanFromPlans(allPlansRaw, phasesRaw, todayDateOnly, { memberId });
33
51
  const currentPlan = compactCurrentPlan(currentPlanRaw);
34
52
  const plans = (Array.isArray(allPlansRaw) ? allPlansRaw : []).map((item) => compactPlanSummary(item));
35
53
  const phases = (Array.isArray(phasesRaw) ? phasesRaw : []).map((item) => compactPlanPhase(item));
@@ -50,7 +68,7 @@ export async function commandPlan(flags, deps) {
50
68
  phases: phases.length,
51
69
  currentPlan: currentPlan ? 1 : 0,
52
70
  },
53
- currentPlan: flags.full || view === "current" ? currentPlan : compactCurrentPlan(currentPlanRaw),
71
+ currentPlan,
54
72
  plans: flags.full || view === "plans" ? plans : undefined,
55
73
  phases: flags.full || view === "phases" ? phases : undefined,
56
74
  count: filteredRecords.length,
@@ -68,7 +68,8 @@ export async function commandPowerRecords(flags, deps) {
68
68
  const allRecords = Array.isArray(raw?.results?.[0]?.personalRecords)
69
69
  ? raw.results[0].personalRecords
70
70
  : [];
71
- const rankedByWatts = [...allRecords].sort((a, b) => (b?.Watts ?? 0) - (a?.Watts ?? 0));
71
+ const wattsOf = (item) => item?.watts ?? item?.Watts ?? 0;
72
+ const rankedByWatts = [...allRecords].sort((a, b) => wattsOf(b) - wattsOf(a));
72
73
  const selectedRaw = full ? allRecords : rankedByWatts.slice(0, limit);
73
74
  const records = full ? selectedRaw : selectedRaw.map((item) => compactPersonalRecord(item));
74
75
 
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const FORMATS = new Set(["png", "svg"]);
5
+
6
+ async function requirePrivateMember(flags, deps) {
7
+ const { withClient } = deps;
8
+ const client = await withClient(flags);
9
+ try {
10
+ const memberInfo = await client.getMemberInfo();
11
+ return { client, memberInfo };
12
+ } catch {
13
+ throw new Error(
14
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
15
+ );
16
+ }
17
+ }
18
+
19
+ async function rasterize(svg, { width, background }) {
20
+ let Resvg;
21
+ try {
22
+ ({ Resvg } = await import("@resvg/resvg-js"));
23
+ } catch {
24
+ throw new Error(
25
+ "PNG output needs the optional @resvg/resvg-js package. Install it (npm install @resvg/resvg-js) or use --format svg.",
26
+ );
27
+ }
28
+ const renderer = new Resvg(svg, {
29
+ fitTo: { mode: "width", value: width },
30
+ background,
31
+ });
32
+ const image = renderer.render();
33
+ return { bytes: image.asPng(), width: image.width, height: image.height };
34
+ }
35
+
36
+ export async function commandWorkoutImage(flags, deps) {
37
+ const { isJsonMode, requireFlag, requirePositiveInteger, writeOutput } = deps;
38
+ const workoutId = Number(requireFlag("workout-image", flags, "id"));
39
+ if (!Number.isFinite(workoutId)) {
40
+ throw new Error(`Invalid --id "${flags.id}". Expected a numeric workout ID.`);
41
+ }
42
+
43
+ const explicitFormat = flags.format ? String(flags.format).toLowerCase() : null;
44
+ if (explicitFormat && !FORMATS.has(explicitFormat)) {
45
+ throw new Error(`Invalid --format "${flags.format}". Expected png or svg.`);
46
+ }
47
+ const requestedFile = flags.file ? String(flags.file) : null;
48
+ const extension = requestedFile ? path.extname(requestedFile).slice(1).toLowerCase() : null;
49
+ const format = explicitFormat ?? (FORMATS.has(extension) ? extension : "png");
50
+ const file = requestedFile ?? `workout-${workoutId}.${format}`;
51
+ const width = requirePositiveInteger(flags.width, 1200);
52
+ const background = flags.background ? String(flags.background) : "#1c1c1c";
53
+
54
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
55
+ const summaryPayload = await client.getWorkoutSummary(workoutId, memberInfo.username);
56
+ const summary = summaryPayload?.summary ?? null;
57
+ const chartUrl = summary?.picUrl ?? null;
58
+ if (!chartUrl) {
59
+ throw new Error(`Workout ${workoutId} has no chart image (summary.picUrl was empty).`);
60
+ }
61
+
62
+ const svg = await client.fetchText(chartUrl);
63
+ let bytes;
64
+ let size = null;
65
+ if (format === "svg") {
66
+ bytes = Buffer.from(svg, "utf8");
67
+ } else {
68
+ const rendered = await rasterize(svg, { width, background });
69
+ bytes = rendered.bytes;
70
+ size = { width: rendered.width, height: rendered.height };
71
+ }
72
+ await fs.mkdir(path.dirname(path.resolve(file)), { recursive: true });
73
+ await fs.writeFile(file, bytes);
74
+
75
+ const payload = {
76
+ generatedAt: new Date().toISOString(),
77
+ command: "workout-image",
78
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
79
+ query: { workoutId, format, width: format === "png" ? width : undefined, background: format === "png" ? background : undefined },
80
+ workout: {
81
+ workoutId,
82
+ workoutName: summary?.workoutName ?? summary?.name ?? null,
83
+ durationMinutes: summary?.duration ?? null,
84
+ tss: summary?.tss ?? null,
85
+ intensityFactor: summary?.intensityFactor ?? null,
86
+ },
87
+ chartUrl,
88
+ file: path.resolve(file),
89
+ bytes: bytes.length,
90
+ size,
91
+ message: `Wrote ${format.toUpperCase()} chart for ${summary?.workoutName ?? `workout ${workoutId}`} to ${path.resolve(file)}.`,
92
+ };
93
+
94
+ if (!isJsonMode(flags)) {
95
+ await writeOutput(payload, flags, (value) => value.message);
96
+ return;
97
+ }
98
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
99
+ }
@@ -115,6 +115,7 @@ export function summarizeWorkout(workout) {
115
115
  firstPublishDate: workout.firstPublishDate ?? null,
116
116
  indoorAlternativeId: workout.indoorAlternativeId ?? null,
117
117
  energyKj: workout.kj ?? null,
118
+ chartUrl: workout.picUrl ?? null,
118
119
  goal: stripHtml(workout.goalDescription),
119
120
  description: stripHtml(workout.workoutDescription),
120
121
  };
@@ -1,14 +1,8 @@
1
+ import { isHttpStatus } from "../trainerroad-client.mjs";
2
+
1
3
  const ALTERNATE_CATEGORIES = new Set(["similar", "easier", "harder", "longer", "shorter"]);
2
4
  const SWITCH_MODES = new Set(["inside", "outside"]);
3
5
 
4
- function requireFlag(flags, name) {
5
- const value = flags[name];
6
- if (value === undefined || value === null || value === "") {
7
- throw new Error(`Missing required flag --${name}.`);
8
- }
9
- return value;
10
- }
11
-
12
6
  function toIsoDateFromApiDate(date) {
13
7
  if (!date || typeof date !== "object") return null;
14
8
  const year = String(date.year ?? "").padStart(4, "0");
@@ -69,18 +63,10 @@ async function requirePrivateMember(flags, deps) {
69
63
  return { client, memberInfo };
70
64
  }
71
65
 
72
- async function fetchBeforeAfter(flags, deps, mutate) {
73
- const { client, memberInfo } = await requirePrivateMember(flags, deps);
74
- const plannedActivityId = String(requireFlag(flags, "id"));
75
- const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
76
- const mutation = await mutate({ client, memberInfo, plannedActivityId, before });
77
- const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
78
- return { client, memberInfo, plannedActivityId, before, after, mutation };
79
- }
80
-
81
66
  export async function commandWorkoutAlternates(flags, deps) {
82
- const { isJsonMode, writeOutput } = deps;
67
+ const { isJsonMode, requireFlag, writeOutput } = deps;
83
68
  const category = String(flags.category ?? "similar").toLowerCase();
69
+ const plannedActivityId = String(requireFlag("workout-alternates", flags, "id"));
84
70
  if (!ALTERNATE_CATEGORIES.has(category)) {
85
71
  throw new Error(
86
72
  `Invalid --category "${category}". Expected one of: ${Array.from(ALTERNATE_CATEGORIES).join(", ")}.`,
@@ -88,7 +74,6 @@ export async function commandWorkoutAlternates(flags, deps) {
88
74
  }
89
75
 
90
76
  const { client, memberInfo } = await requirePrivateMember(flags, deps);
91
- const plannedActivityId = String(requireFlag(flags, "id"));
92
77
  const plannedActivity = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
93
78
  const alternates = await client.getPlannedActivityAlternates(
94
79
  plannedActivityId,
@@ -128,14 +113,47 @@ export async function commandWorkoutAlternates(flags, deps) {
128
113
  }
129
114
 
130
115
  export async function commandMoveWorkout(flags, deps) {
131
- const { isJsonMode, writeOutput } = deps;
132
- const newDate = String(requireFlag(flags, "to"));
133
- const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
134
- flags,
135
- deps,
136
- async ({ client, memberInfo, plannedActivityId }) =>
137
- client.movePlannedActivity(plannedActivityId, newDate, memberInfo.username),
138
- );
116
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
117
+ const dryRun = toBoolean(flags["dry-run"], false);
118
+ const plannedActivityId = String(requireFlag("move-workout", flags, "id"));
119
+ const newDate = String(requireFlag("move-workout", flags, "to"));
120
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
121
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
122
+ const beforeSummary = summarizePlannedActivity(before);
123
+ const noop = beforeSummary?.date === newDate;
124
+
125
+ if (dryRun || noop) {
126
+ const payload = {
127
+ generatedAt: new Date().toISOString(),
128
+ command: "move-workout",
129
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
130
+ query: { plannedActivityId, to: newDate },
131
+ dryRun,
132
+ noop,
133
+ before: beforeSummary,
134
+ after: noop ? beforeSummary : { ...beforeSummary, date: newDate },
135
+ mutation: null,
136
+ message: noop
137
+ ? `Workout is already scheduled on ${newDate}.`
138
+ : `Would move ${beforeSummary?.workoutName ?? "workout"} to ${newDate}.`,
139
+ };
140
+
141
+ if (!isJsonMode(flags)) {
142
+ await writeOutput(payload, flags, (value) => {
143
+ if (value.noop) {
144
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} is already on ${value.query.to}`;
145
+ }
146
+ return `Would move ${value.before?.workoutName ?? "workout"} | plannedActivityId=${value.query.plannedActivityId} | ${value.before?.date ?? "?"} -> ${value.query.to}\nNo changes made.`;
147
+ });
148
+ return;
149
+ }
150
+
151
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
152
+ return;
153
+ }
154
+
155
+ const mutation = await client.movePlannedActivity(plannedActivityId, newDate, memberInfo.username);
156
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
139
157
 
140
158
  const payload = {
141
159
  generatedAt: new Date().toISOString(),
@@ -158,22 +176,54 @@ export async function commandMoveWorkout(flags, deps) {
158
176
  }
159
177
 
160
178
  export async function commandReplaceWorkout(flags, deps) {
161
- const { isJsonMode, toBoolean, writeOutput } = deps;
162
- const alternateWorkoutId = Number(requireFlag(flags, "alternate-id"));
179
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
180
+ const dryRun = toBoolean(flags["dry-run"], false);
181
+ const plannedActivityId = String(requireFlag("replace-workout", flags, "id"));
182
+ const alternateWorkoutId = Number(requireFlag("replace-workout", flags, "alternate-id"));
163
183
  if (!Number.isFinite(alternateWorkoutId)) {
164
184
  throw new Error(`Invalid --alternate-id "${flags["alternate-id"]}". Expected a numeric workout ID.`);
165
185
  }
166
186
  const updateDuration = toBoolean(flags["update-duration"], false);
187
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
188
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
189
+ const beforeSummary = summarizePlannedActivity(before);
190
+ const noop = Number(beforeSummary?.workoutId) === alternateWorkoutId;
167
191
 
168
- const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
169
- flags,
170
- deps,
171
- async ({ client, memberInfo, plannedActivityId }) =>
172
- client.replacePlannedActivityWithAlternate(plannedActivityId, alternateWorkoutId, {
173
- updateDuration,
174
- usernameForReferer: memberInfo.username,
175
- }),
176
- );
192
+ if (dryRun || noop) {
193
+ const payload = {
194
+ generatedAt: new Date().toISOString(),
195
+ command: "replace-workout",
196
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
197
+ query: { plannedActivityId, alternateWorkoutId, updateDuration },
198
+ dryRun,
199
+ noop,
200
+ before: beforeSummary,
201
+ after: noop ? beforeSummary : null,
202
+ mutation: null,
203
+ message: noop
204
+ ? `Workout already uses alternate workout ${alternateWorkoutId}.`
205
+ : `Would replace workout ${beforeSummary?.workoutId ?? "?"} with ${alternateWorkoutId}.`,
206
+ };
207
+
208
+ if (!isJsonMode(flags)) {
209
+ await writeOutput(payload, flags, (value) => {
210
+ if (value.noop) {
211
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} already uses workoutId=${value.query.alternateWorkoutId}`;
212
+ }
213
+ return `Would replace workout | plannedActivityId=${value.query.plannedActivityId} | workoutId=${value.before?.workoutId ?? "?"} -> alternateWorkoutId=${value.query.alternateWorkoutId}\nNo changes made.`;
214
+ });
215
+ return;
216
+ }
217
+
218
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
219
+ return;
220
+ }
221
+
222
+ const mutation = await client.replacePlannedActivityWithAlternate(plannedActivityId, alternateWorkoutId, {
223
+ updateDuration,
224
+ usernameForReferer: memberInfo.username,
225
+ });
226
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
177
227
 
178
228
  const payload = {
179
229
  generatedAt: new Date().toISOString(),
@@ -196,18 +246,50 @@ export async function commandReplaceWorkout(flags, deps) {
196
246
  }
197
247
 
198
248
  export async function commandSwitchWorkout(flags, deps) {
199
- const { isJsonMode, writeOutput } = deps;
200
- const mode = String(requireFlag(flags, "mode")).toLowerCase();
249
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
250
+ const dryRun = toBoolean(flags["dry-run"], false);
251
+ const plannedActivityId = String(requireFlag("switch-workout", flags, "id"));
252
+ const mode = String(requireFlag("switch-workout", flags, "mode")).toLowerCase();
201
253
  if (!SWITCH_MODES.has(mode)) {
202
254
  throw new Error(`Invalid --mode "${mode}". Expected one of: ${Array.from(SWITCH_MODES).join(", ")}.`);
203
255
  }
256
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
257
+ const before = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
258
+ const beforeSummary = summarizePlannedActivity(before);
259
+ const noop = beforeSummary?.isOutside === (mode === "outside");
204
260
 
205
- const { memberInfo, plannedActivityId, before, after, mutation } = await fetchBeforeAfter(
206
- flags,
207
- deps,
208
- async ({ client, memberInfo, plannedActivityId }) =>
209
- client.switchPlannedActivityMode(plannedActivityId, mode, memberInfo.username),
210
- );
261
+ if (dryRun || noop) {
262
+ const payload = {
263
+ generatedAt: new Date().toISOString(),
264
+ command: "switch-workout",
265
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
266
+ query: { plannedActivityId, mode },
267
+ dryRun,
268
+ noop,
269
+ before: beforeSummary,
270
+ after: noop ? beforeSummary : { ...beforeSummary, isOutside: mode === "outside" },
271
+ mutation: null,
272
+ message: noop
273
+ ? `Workout is already in ${mode} mode.`
274
+ : `Would switch workout to ${mode} mode.`,
275
+ };
276
+
277
+ if (!isJsonMode(flags)) {
278
+ await writeOutput(payload, flags, (value) => {
279
+ if (value.noop) {
280
+ return `No changes: plannedActivityId=${value.query.plannedActivityId} is already ${value.query.mode}`;
281
+ }
282
+ return `Would switch workout | plannedActivityId=${value.query.plannedActivityId} | outside=${value.before?.isOutside} -> ${value.query.mode === "outside"}\nNo changes made.`;
283
+ });
284
+ return;
285
+ }
286
+
287
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
288
+ return;
289
+ }
290
+
291
+ const mutation = await client.switchPlannedActivityMode(plannedActivityId, mode, memberInfo.username);
292
+ const after = await client.getPlannedActivity(plannedActivityId, memberInfo.username);
211
293
 
212
294
  const payload = {
213
295
  generatedAt: new Date().toISOString(),
@@ -228,3 +310,61 @@ export async function commandSwitchWorkout(flags, deps) {
228
310
 
229
311
  await writeOutput(payload, { ...flags, json: !flags.jsonl });
230
312
  }
313
+
314
+ const ADAPTIVE_NOTE =
315
+ "Removing a planned workout lets TrainerRoad's Adaptive Training rebuild the upcoming plan around the gap. Re-read `future` afterwards.";
316
+
317
+ export async function commandRemoveWorkout(flags, deps) {
318
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
319
+ const dryRun = toBoolean(flags["dry-run"], false);
320
+ const plannedActivityId = String(requireFlag("remove-workout", flags, "id"));
321
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
322
+
323
+ let before = null;
324
+ try {
325
+ before = summarizePlannedActivity(await client.getPlannedActivity(plannedActivityId, memberInfo.username));
326
+ } catch (error) {
327
+ if (!isHttpStatus(error, 404)) throw error;
328
+ }
329
+ const noop = before === null;
330
+
331
+ const base = {
332
+ generatedAt: new Date().toISOString(),
333
+ command: "remove-workout",
334
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
335
+ query: { plannedActivityId },
336
+ before,
337
+ adaptiveTraining: ADAPTIVE_NOTE,
338
+ };
339
+
340
+ if (dryRun || noop) {
341
+ const payload = {
342
+ ...base,
343
+ dryRun,
344
+ noop,
345
+ message: noop
346
+ ? `No planned activity with id ${plannedActivityId} exists on the calendar.`
347
+ : `Would remove ${before.workoutName ?? "workout"} from ${before.date}.`,
348
+ };
349
+ if (!isJsonMode(flags)) {
350
+ await writeOutput(payload, flags, (value) => (value.noop ? value.message : `${value.message}\nNo changes made.`));
351
+ return;
352
+ }
353
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
354
+ return;
355
+ }
356
+
357
+ await client.deletePlannedActivity(plannedActivityId, memberInfo.username);
358
+ const payload = {
359
+ ...base,
360
+ dryRun: false,
361
+ noop: false,
362
+ message: `Removed ${before.workoutName ?? "workout"} from ${before.date} (plannedActivityId=${plannedActivityId}).`,
363
+ };
364
+
365
+ if (!isJsonMode(flags)) {
366
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
367
+ return;
368
+ }
369
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
370
+ }