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.
- package/CHANGELOG.md +28 -0
- package/README.md +65 -1
- package/package.json +11 -2
- package/src/cli.mjs +120 -37
- package/src/commands/annotation-mutations.mjs +253 -0
- package/src/commands/auth.mjs +3 -2
- package/src/commands/discovery.mjs +9 -8
- package/src/commands/event-mutations.mjs +174 -0
- package/src/commands/plan.mjs +23 -5
- package/src/commands/power.mjs +2 -1
- package/src/commands/workout-image.mjs +99 -0
- package/src/commands/workout-library.mjs +1 -0
- package/src/commands/workout-mutations.mjs +186 -46
- package/src/commands/workout-tools.mjs +104 -41
- package/src/lib/command-manifest.mjs +458 -35
- package/src/lib/planning-normalizers.mjs +110 -4
- package/src/trainerroad-client.mjs +246 -36
|
@@ -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
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|
package/src/commands/plan.mjs
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
context.client
|
|
31
|
-
context.client.
|
|
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
|
|
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,
|
package/src/commands/power.mjs
CHANGED
|
@@ -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
|
|
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
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|