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