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 +18 -0
- package/README.md +46 -1
- package/package.json +9 -2
- package/src/cli.mjs +43 -12
- package/src/commands/annotation-mutations.mjs +253 -0
- 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 +60 -0
- package/src/commands/workout-tools.mjs +1 -0
- package/src/lib/command-manifest.mjs +162 -1
- package/src/lib/planning-normalizers.mjs +110 -4
- package/src/trainerroad-client.mjs +245 -35
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,3 +1,5 @@
|
|
|
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
|
|
|
@@ -308,3 +310,61 @@ export async function commandSwitchWorkout(flags, deps) {
|
|
|
308
310
|
|
|
309
311
|
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
310
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
|
+
}
|
|
@@ -31,6 +31,7 @@ function summarizeWorkout(workout) {
|
|
|
31
31
|
firstPublishDate: workout.firstPublishDate ?? null,
|
|
32
32
|
indoorAlternativeId: workout.indoorAlternativeId ?? null,
|
|
33
33
|
energyKj: workout.kj ?? null,
|
|
34
|
+
chartUrl: workout.picUrl ?? null,
|
|
34
35
|
goal: stripHtml(workout.goalDescription),
|
|
35
36
|
description: stripHtml(workout.workoutDescription),
|
|
36
37
|
};
|
|
@@ -264,6 +264,61 @@ export const COMMANDS = {
|
|
|
264
264
|
"trainerroad-cli switch-workout --id 123456 --mode outside --json",
|
|
265
265
|
],
|
|
266
266
|
},
|
|
267
|
+
"add-event": {
|
|
268
|
+
summary:
|
|
269
|
+
"Add a race or event to the calendar with discipline, priority, duration, and a TSS or intensity estimate. Remove it later with remove-workout (private mode).",
|
|
270
|
+
usage: [
|
|
271
|
+
"trainerroad-cli add-event --name <text> --date YYYY-MM-DD --discipline <name|id> --duration <minutes> (--tss <number> | --intensity <1-10>) [--priority A|B|C] [--notes <text>] [--dry-run] [--json|--jsonl]",
|
|
272
|
+
],
|
|
273
|
+
examples: [
|
|
274
|
+
"trainerroad-cli add-event --name \"Black Fork\" --date 2027-05-01 --discipline gravel --priority A --duration 300 --tss 340 --dry-run",
|
|
275
|
+
"trainerroad-cli add-event --name \"Tuesday crit\" --date 2026-10-06 --discipline criterium --priority C --duration 60 --intensity 9 --json",
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
"remove-workout": {
|
|
279
|
+
summary:
|
|
280
|
+
"Remove a planned workout or event from the calendar by planned-activity id. TrainerRoad may rebuild the plan around the gap (private mode).",
|
|
281
|
+
usage: ["trainerroad-cli remove-workout --id <planned-activity-id> [--dry-run] [--json|--jsonl]"],
|
|
282
|
+
examples: [
|
|
283
|
+
"trainerroad-cli remove-workout --id 05a68215-0fd5-431e-ba3f-b3bf01210c29 --dry-run",
|
|
284
|
+
"trainerroad-cli remove-workout --id 05a68215-0fd5-431e-ba3f-b3bf01210c29 --json",
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
"workout-image": {
|
|
288
|
+
summary: "Save a workout's power-profile chart as PNG (default) or SVG (private mode).",
|
|
289
|
+
usage: [
|
|
290
|
+
"trainerroad-cli workout-image --id <workout-id> [--file <path>] [--format png|svg] [--width <px>] [--background <css-color>] [--json]",
|
|
291
|
+
],
|
|
292
|
+
examples: [
|
|
293
|
+
"trainerroad-cli workout-image --id 1592808 --file fishers.png",
|
|
294
|
+
"trainerroad-cli workout-image --id 1592808 --format svg --file fishers.svg --json",
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
"annotation-details": {
|
|
298
|
+
summary: "Fetch one calendar annotation with its title and notes (private mode).",
|
|
299
|
+
usage: ["trainerroad-cli annotation-details --id <annotation-id> [--full] [--json|--jsonl]"],
|
|
300
|
+
examples: ["trainerroad-cli annotation-details --id 1818fb12-7294-4a98-b494-b4b90160edc1 --json"],
|
|
301
|
+
},
|
|
302
|
+
"add-annotation": {
|
|
303
|
+
summary:
|
|
304
|
+
"Add a calendar annotation: time off, illness, injury, or a note. Multi-day via --days or --end-date. TrainerRoad may adapt nearby workouts (private mode).",
|
|
305
|
+
usage: [
|
|
306
|
+
"trainerroad-cli add-annotation --type time-off|illness|injury|note --date YYYY-MM-DD [--days <count> | --end-date YYYY-MM-DD] [--title <text>] [--notes <text>] [--dry-run] [--json|--jsonl]",
|
|
307
|
+
],
|
|
308
|
+
examples: [
|
|
309
|
+
"trainerroad-cli add-annotation --type time-off --date 2026-09-21 --days 3 --title \"Travel\" --dry-run",
|
|
310
|
+
"trainerroad-cli add-annotation --type illness --date 2026-09-21 --end-date 2026-09-23 --notes \"Head cold\" --json",
|
|
311
|
+
"trainerroad-cli add-annotation --type note --date 2026-09-21 --title \"New saddle\" --json",
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
"remove-annotation": {
|
|
315
|
+
summary: "Remove a calendar annotation by id. No-op if it is already gone (private mode).",
|
|
316
|
+
usage: ["trainerroad-cli remove-annotation --id <annotation-id> [--dry-run] [--json|--jsonl]"],
|
|
317
|
+
examples: [
|
|
318
|
+
"trainerroad-cli remove-annotation --id 1818fb12-7294-4a98-b494-b4b90160edc1 --dry-run",
|
|
319
|
+
"trainerroad-cli remove-annotation --id 1818fb12-7294-4a98-b494-b4b90160edc1 --json",
|
|
320
|
+
],
|
|
321
|
+
},
|
|
267
322
|
logout: {
|
|
268
323
|
summary: "Clear local persisted session.",
|
|
269
324
|
usage: ["trainerroad-cli logout"],
|
|
@@ -326,6 +381,12 @@ export const COMMAND_REQUIRED_FLAGS = {
|
|
|
326
381
|
"move-workout": ["id", "to"],
|
|
327
382
|
"replace-workout": ["id", "alternate-id"],
|
|
328
383
|
"switch-workout": ["id", "mode"],
|
|
384
|
+
"add-event": ["name", "date", "discipline", "duration"],
|
|
385
|
+
"remove-workout": ["id"],
|
|
386
|
+
"workout-image": ["id"],
|
|
387
|
+
"annotation-details": ["id"],
|
|
388
|
+
"add-annotation": ["type", "date"],
|
|
389
|
+
"remove-annotation": ["id"],
|
|
329
390
|
};
|
|
330
391
|
|
|
331
392
|
export const FLAG_DETAILS = {
|
|
@@ -438,7 +499,23 @@ export const FLAG_DETAILS = {
|
|
|
438
499
|
placeholder: "<count>",
|
|
439
500
|
description: "Upstream workout library page size.",
|
|
440
501
|
},
|
|
441
|
-
id: { placeholder: "<id>", description: "Workout, planned activity, or record identifier." },
|
|
502
|
+
id: { placeholder: "<id>", description: "Workout, planned activity, annotation, or record identifier." },
|
|
503
|
+
name: { placeholder: "<text>", description: "Event name shown on the calendar." },
|
|
504
|
+
discipline: {
|
|
505
|
+
placeholder: "<name|id>",
|
|
506
|
+
description:
|
|
507
|
+
"Event discipline: gravel, criterium, time-trial, gran-fondo, climbing-road-race, rolling-road-race, cyclocross, xc-olympic, xc-marathon, short-track, gravity, enduro, or a triathlon type; numeric ids accepted.",
|
|
508
|
+
},
|
|
509
|
+
priority: { placeholder: "A|B|C", description: "Race priority. A drives the plan, C is a training race (default B)." },
|
|
510
|
+
tss: { placeholder: "<number>", description: "Expected TSS for the event. Use this or --intensity." },
|
|
511
|
+
intensity: { placeholder: "<1-10>", description: "Expected intensity on TrainerRoad's 1-10 scale. Use this or --tss." },
|
|
512
|
+
file: { placeholder: "<path>", description: "Destination file for the image (default workout-<id>.png)." },
|
|
513
|
+
format: { placeholder: "png|svg", description: "Image format. Inferred from --file extension when omitted." },
|
|
514
|
+
width: { placeholder: "<px>", description: "PNG width in pixels (default 1200)." },
|
|
515
|
+
background: { placeholder: "<css-color>", description: "PNG background colour (default #1c1c1c)." },
|
|
516
|
+
title: { placeholder: "<text>", description: "Annotation title shown on the calendar. Defaults to the type name." },
|
|
517
|
+
notes: { placeholder: "<text>", description: "Free-text notes stored with the annotation." },
|
|
518
|
+
"color-id": { placeholder: "<id>", description: "TrainerRoad annotation colour id (default 2)." },
|
|
442
519
|
"include-chart": {
|
|
443
520
|
placeholder: "true|false",
|
|
444
521
|
description: "Include workout chart/sample data in workout-details.",
|
|
@@ -484,6 +561,38 @@ function mergeFlagGroups(...groups) {
|
|
|
484
561
|
);
|
|
485
562
|
}
|
|
486
563
|
|
|
564
|
+
// Per-command overrides for flags whose meaning differs from the shared FLAG_DETAILS entry.
|
|
565
|
+
export const COMMAND_FLAG_DETAILS = {
|
|
566
|
+
"add-annotation": {
|
|
567
|
+
type: {
|
|
568
|
+
placeholder: "time-off|illness|injury|note",
|
|
569
|
+
description: "Annotation type. Also accepts a numeric TrainerRoad typeId.",
|
|
570
|
+
},
|
|
571
|
+
date: { placeholder: "YYYY-MM-DD", description: "First day of the annotation." },
|
|
572
|
+
days: { placeholder: "<count>", description: "Number of days the annotation covers (default 1)." },
|
|
573
|
+
"end-date": { placeholder: "YYYY-MM-DD", description: "Last day of the annotation, inclusive. Overrides --days." },
|
|
574
|
+
},
|
|
575
|
+
"annotation-details": {
|
|
576
|
+
id: { placeholder: "<annotation-id>", description: "Annotation id from `annotations`." },
|
|
577
|
+
full: { description: "Include the raw upstream annotation payload." },
|
|
578
|
+
},
|
|
579
|
+
"workout-image": {
|
|
580
|
+
id: { placeholder: "<workout-id>", description: "Library workout id (from workout-library, workout-details, or future --details)." },
|
|
581
|
+
},
|
|
582
|
+
"remove-workout": {
|
|
583
|
+
id: { placeholder: "<planned-activity-id>", description: "Planned activity id from `future --details` or `events`." },
|
|
584
|
+
},
|
|
585
|
+
"add-event": {
|
|
586
|
+
date: { placeholder: "YYYY-MM-DD", description: "Event date." },
|
|
587
|
+
duration: { placeholder: "<minutes>", description: "Expected event duration in minutes." },
|
|
588
|
+
notes: { placeholder: "<text>", description: "Free-text description stored with the event." },
|
|
589
|
+
full: { description: "Include TrainerRoad's raw create response." },
|
|
590
|
+
},
|
|
591
|
+
"remove-annotation": {
|
|
592
|
+
id: { placeholder: "<annotation-id>", description: "Annotation id from `annotations`." },
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
|
|
487
596
|
const SHARED_FLAGS = {
|
|
488
597
|
help: ["help"],
|
|
489
598
|
output: ["output"],
|
|
@@ -764,5 +873,57 @@ export const COMMAND_FLAG_ALLOWLIST = {
|
|
|
764
873
|
SHARED_FLAGS.writeSafety,
|
|
765
874
|
["id", "mode"],
|
|
766
875
|
),
|
|
876
|
+
"add-event": mergeFlagGroups(
|
|
877
|
+
SHARED_FLAGS.help,
|
|
878
|
+
SHARED_FLAGS.output,
|
|
879
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
880
|
+
SHARED_FLAGS.session,
|
|
881
|
+
SHARED_FLAGS.credentials,
|
|
882
|
+
SHARED_FLAGS.writeSafety,
|
|
883
|
+
["name", "date", "discipline", "priority", "duration", "tss", "intensity", "notes", "full"],
|
|
884
|
+
),
|
|
885
|
+
"remove-workout": mergeFlagGroups(
|
|
886
|
+
SHARED_FLAGS.help,
|
|
887
|
+
SHARED_FLAGS.output,
|
|
888
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
889
|
+
SHARED_FLAGS.session,
|
|
890
|
+
SHARED_FLAGS.credentials,
|
|
891
|
+
SHARED_FLAGS.writeSafety,
|
|
892
|
+
["id"],
|
|
893
|
+
),
|
|
894
|
+
"workout-image": mergeFlagGroups(
|
|
895
|
+
SHARED_FLAGS.help,
|
|
896
|
+
SHARED_FLAGS.output,
|
|
897
|
+
SHARED_FLAGS.json,
|
|
898
|
+
SHARED_FLAGS.session,
|
|
899
|
+
SHARED_FLAGS.credentials,
|
|
900
|
+
["id", "file", "format", "width", "background"],
|
|
901
|
+
),
|
|
902
|
+
"annotation-details": mergeFlagGroups(
|
|
903
|
+
SHARED_FLAGS.help,
|
|
904
|
+
SHARED_FLAGS.output,
|
|
905
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
906
|
+
SHARED_FLAGS.session,
|
|
907
|
+
SHARED_FLAGS.credentials,
|
|
908
|
+
["id", "full"],
|
|
909
|
+
),
|
|
910
|
+
"add-annotation": mergeFlagGroups(
|
|
911
|
+
SHARED_FLAGS.help,
|
|
912
|
+
SHARED_FLAGS.output,
|
|
913
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
914
|
+
SHARED_FLAGS.session,
|
|
915
|
+
SHARED_FLAGS.credentials,
|
|
916
|
+
SHARED_FLAGS.writeSafety,
|
|
917
|
+
["type", "date", "days", "end-date", "title", "notes", "color-id"],
|
|
918
|
+
),
|
|
919
|
+
"remove-annotation": mergeFlagGroups(
|
|
920
|
+
SHARED_FLAGS.help,
|
|
921
|
+
SHARED_FLAGS.output,
|
|
922
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
923
|
+
SHARED_FLAGS.session,
|
|
924
|
+
SHARED_FLAGS.credentials,
|
|
925
|
+
SHARED_FLAGS.writeSafety,
|
|
926
|
+
["id"],
|
|
927
|
+
),
|
|
767
928
|
logout: mergeFlagGroups(SHARED_FLAGS.help, SHARED_FLAGS.output, SHARED_FLAGS.session),
|
|
768
929
|
};
|
|
@@ -9,14 +9,61 @@ const PROGRESSION_ZONE_META = {
|
|
|
9
9
|
79: { zoneKey: "anaerobic", zoneLabel: "Anaerobic", sortOrder: 6 },
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// From the web app's enum, checked against real annotations on 2026-09-02 (typeId 2 "Wisdom Teeth",
|
|
13
|
+
// typeId 4 "Hiking Out West"). Earlier releases had 2 and 4 swapped.
|
|
14
|
+
export const ANNOTATION_TYPE_LABELS = {
|
|
13
15
|
1: "note",
|
|
14
|
-
2: "
|
|
16
|
+
2: "illness",
|
|
15
17
|
3: "injury",
|
|
16
|
-
4: "
|
|
17
|
-
|
|
18
|
+
4: "time-off",
|
|
19
|
+
5: "stage-race",
|
|
20
|
+
6: "custom-plan-start",
|
|
21
|
+
7: "custom-plan-week",
|
|
22
|
+
8: "custom-plan-block",
|
|
23
|
+
9: "plan-start",
|
|
24
|
+
10: "plan-week",
|
|
18
25
|
};
|
|
19
26
|
|
|
27
|
+
// Names an agent can pass to add-annotation --type. Only the four user-editable types.
|
|
28
|
+
export const ANNOTATION_TYPE_IDS = {
|
|
29
|
+
note: 1,
|
|
30
|
+
illness: 2,
|
|
31
|
+
sick: 2,
|
|
32
|
+
injury: 3,
|
|
33
|
+
"time-off": 4,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function endDateOnlyFrom(startDateOnly, durationSeconds) {
|
|
37
|
+
if (!startDateOnly || !Number.isFinite(Number(durationSeconds))) return startDateOnly ?? null;
|
|
38
|
+
const days = Math.max(1, Math.round(Number(durationSeconds) / 86_400));
|
|
39
|
+
const [year, month, day] = startDateOnly.split("-").map(Number);
|
|
40
|
+
return new Date(Date.UTC(year, month - 1, day + days - 1)).toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Shape of GET /app/api/react-calendar/annotation/{id}: the timeline row plus title, text, colour.
|
|
44
|
+
export function compactAnnotationDetail(record) {
|
|
45
|
+
const dateOnly = toIsoDateFromCalendarDate(record?.date);
|
|
46
|
+
const durationSeconds = record?.duration ?? null;
|
|
47
|
+
const typeLabel = ANNOTATION_TYPE_LABELS[record?.typeId] ?? `type-${record?.typeId ?? "unknown"}`;
|
|
48
|
+
return {
|
|
49
|
+
id: record?.id ?? null,
|
|
50
|
+
type: typeLabel,
|
|
51
|
+
typeId: record?.typeId ?? null,
|
|
52
|
+
typeLabel,
|
|
53
|
+
title: record?.title ?? null,
|
|
54
|
+
text: record?.text ?? null,
|
|
55
|
+
date: record?.date ?? null,
|
|
56
|
+
dateOnly,
|
|
57
|
+
endDateOnly: endDateOnlyFrom(dateOnly, durationSeconds),
|
|
58
|
+
durationSeconds,
|
|
59
|
+
durationDays: Number.isFinite(Number(durationSeconds)) ? Math.round(Number(durationSeconds) / 86_400) : null,
|
|
60
|
+
timeOfDay: record?.timeOfDay ?? null,
|
|
61
|
+
colorId: record?.colorId ?? null,
|
|
62
|
+
colorHex: record?.colorHex ?? null,
|
|
63
|
+
plannedActivityGroupId: record?.plannedActivityGroupId ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
20
67
|
function toIsoDateFromPlanned(item) {
|
|
21
68
|
return `${String(item.date.year).padStart(4, "0")}-${String(item.date.month).padStart(2, "0")}-${String(item.date.day).padStart(2, "0")}`;
|
|
22
69
|
}
|
|
@@ -135,12 +182,71 @@ export function compactCurrentPlan(plan) {
|
|
|
135
182
|
dateOnly: plan.start ? toIsoDate(plan.start) : null,
|
|
136
183
|
canEdit: plan.canEdit ?? null,
|
|
137
184
|
currentPhase: plan.currentPhase ?? null,
|
|
185
|
+
currentPhaseId: plan.currentPhaseId ?? null,
|
|
186
|
+
currentPhaseName: plan.currentPhaseName ?? null,
|
|
138
187
|
currentPhaseStart: plan.currentPhaseStart ?? null,
|
|
139
188
|
currentPhaseEnd: plan.currentPhaseEnd ?? null,
|
|
140
189
|
plannedActivityGroupType: plan.plannedActivityGroupType ?? null,
|
|
141
190
|
autoUpdateApplied: plan.autoUpdateApplied ?? null,
|
|
142
191
|
phaseCount: Array.isArray(plan.phases) ? plan.phases.length : 0,
|
|
143
192
|
phases: Array.isArray(plan.phases) ? plan.phases.map((phase) => compactPlanPhase(phase)) : [],
|
|
193
|
+
source: plan.source ?? "current-custom-plan",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function dateWindowContains(start, end, dateOnly) {
|
|
198
|
+
if (!dateOnly) return false;
|
|
199
|
+
const startDateOnly = start ? toIsoDate(start) : null;
|
|
200
|
+
const endDateOnly = end ? toIsoDate(end) : null;
|
|
201
|
+
if (!startDateOnly || !endDateOnly) return false;
|
|
202
|
+
return startDateOnly <= dateOnly && dateOnly <= endDateOnly;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Phases carry the plan's id. When they don't, fall back to phases that sit inside the plan window.
|
|
206
|
+
function phaseBelongsToPlan(phase, plan) {
|
|
207
|
+
if (phase?.customPlanId != null && plan?.id != null) {
|
|
208
|
+
return String(phase.customPlanId) === String(plan.id);
|
|
209
|
+
}
|
|
210
|
+
const phaseStart = phase?.start ? toIsoDate(phase.start) : null;
|
|
211
|
+
const phaseEnd = phase?.end ? toIsoDate(phase.end) : null;
|
|
212
|
+
return dateWindowContains(plan?.start, plan?.end, phaseStart) && dateWindowContains(plan?.start, plan?.end, phaseEnd);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Replacement for the retired current-custom-plan endpoint: the plan whose window contains today,
|
|
216
|
+
// with its phases attached. Returns a raw-shaped plan for compactCurrentPlan, or null.
|
|
217
|
+
export function deriveCurrentPlanFromPlans(plans, phases, todayDateOnly, { memberId = null } = {}) {
|
|
218
|
+
const planList = Array.isArray(plans) ? plans : [];
|
|
219
|
+
const phaseList = Array.isArray(phases) ? phases : [];
|
|
220
|
+
const activePlans = planList
|
|
221
|
+
.filter((plan) => dateWindowContains(plan?.start, plan?.end, todayDateOnly))
|
|
222
|
+
.sort((a, b) => toIsoDate(b.start).localeCompare(toIsoDate(a.start)));
|
|
223
|
+
const plan = activePlans[0];
|
|
224
|
+
if (!plan) return null;
|
|
225
|
+
|
|
226
|
+
const planPhases = phaseList
|
|
227
|
+
.filter((phase) => phaseBelongsToPlan(phase, plan))
|
|
228
|
+
.sort((a, b) => (a?.start && b?.start ? toIsoDate(a.start).localeCompare(toIsoDate(b.start)) : 0));
|
|
229
|
+
const currentPhase =
|
|
230
|
+
planPhases.find((phase) => dateWindowContains(phase?.start, phase?.end, todayDateOnly)) ?? null;
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: plan.id ?? null,
|
|
234
|
+
name: plan.name ?? null,
|
|
235
|
+
memberId: plan.memberId ?? memberId,
|
|
236
|
+
discipline: plan.discipline ?? null,
|
|
237
|
+
volume: plan.volume ?? null,
|
|
238
|
+
start: plan.start ?? null,
|
|
239
|
+
end: plan.end ?? null,
|
|
240
|
+
canEdit: plan.canEdit ?? null,
|
|
241
|
+
currentPhase: currentPhase?.type ?? plan.phase ?? null,
|
|
242
|
+
currentPhaseId: currentPhase?.id ?? null,
|
|
243
|
+
currentPhaseName: currentPhase?.planName ?? null,
|
|
244
|
+
currentPhaseStart: currentPhase?.start ?? null,
|
|
245
|
+
currentPhaseEnd: currentPhase?.end ?? null,
|
|
246
|
+
plannedActivityGroupType: plan.plannedActivityGroupType ?? null,
|
|
247
|
+
autoUpdateApplied: plan.autoUpdateApplied ?? null,
|
|
248
|
+
phases: planPhases,
|
|
249
|
+
source: "all-user-plans",
|
|
144
250
|
};
|
|
145
251
|
}
|
|
146
252
|
|