trainerroad-cli 0.1.0 → 0.2.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/README.md +60 -11
- package/package.json +11 -1
- package/src/cli.mjs +119 -12
- package/src/commands/discovery.mjs +7 -0
- package/src/commands/train-now.mjs +142 -0
- package/src/commands/workout-library.mjs +430 -0
- package/src/commands/workout-mutations.mjs +230 -0
- package/src/commands/workout-recommend.mjs +175 -0
- package/src/commands/workout-tools.mjs +326 -0
- package/src/commands/workouts.mjs +41 -9
- package/src/lib/agent-filters.mjs +9 -2
- package/src/lib/command-manifest.mjs +181 -3
- package/src/lib/planning-normalizers.mjs +9 -2
- package/src/lib/timezone.mjs +187 -0
- package/src/trainerroad-client.mjs +299 -5
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { queryWorkoutLibrary } from "./workout-library.mjs";
|
|
2
|
+
|
|
3
|
+
async function requirePrivateMember(flags, deps) {
|
|
4
|
+
const { withClient } = deps;
|
|
5
|
+
const client = await withClient(flags);
|
|
6
|
+
try {
|
|
7
|
+
const memberInfo = await client.getMemberInfo();
|
|
8
|
+
return { client, memberInfo };
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"workout-recommend requires private authenticated mode. Login first with trainerroad-cli login.",
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function numericOrNull(value, deps) {
|
|
17
|
+
if (value == null) return null;
|
|
18
|
+
return deps.requireNumber(value, null);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function deriveTarget(explicit, min, max) {
|
|
22
|
+
if (explicit != null) return explicit;
|
|
23
|
+
if (min != null && max != null) return (min + max) / 2;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compareValues(left, right) {
|
|
28
|
+
if (left === right) return 0;
|
|
29
|
+
if (left == null) return 1;
|
|
30
|
+
if (right == null) return -1;
|
|
31
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
32
|
+
return String(left).localeCompare(String(right));
|
|
33
|
+
}
|
|
34
|
+
return Number(left) - Number(right);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scoreWorkout(item, targets) {
|
|
38
|
+
let score = 0;
|
|
39
|
+
const reasons = [];
|
|
40
|
+
|
|
41
|
+
if (targets.level != null && item.progressionLevel != null) {
|
|
42
|
+
const delta = Math.abs(item.progressionLevel - targets.level);
|
|
43
|
+
score += delta * 10;
|
|
44
|
+
reasons.push({ metric: "level", target: targets.level, actual: item.progressionLevel, delta });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (targets.duration != null && item.durationMinutes != null) {
|
|
48
|
+
const delta = Math.abs(item.durationMinutes - targets.duration);
|
|
49
|
+
score += delta / 5;
|
|
50
|
+
reasons.push({ metric: "duration", target: targets.duration, actual: item.durationMinutes, delta });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (targets.tss != null && item.tss != null) {
|
|
54
|
+
const delta = Math.abs(item.tss - targets.tss);
|
|
55
|
+
score += delta / 10;
|
|
56
|
+
reasons.push({ metric: "tss", target: targets.tss, actual: item.tss, delta });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (targets.searchText) {
|
|
60
|
+
const text = String(item.workoutName ?? "").toLowerCase();
|
|
61
|
+
const query = String(targets.searchText).toLowerCase();
|
|
62
|
+
if (text === query) score -= 2;
|
|
63
|
+
else if (text.includes(query)) score -= 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (item.hasInstructions) score -= 0.15;
|
|
67
|
+
if (item.isOutside === false) score -= 0.05;
|
|
68
|
+
|
|
69
|
+
return { score, reasons };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function commandWorkoutRecommend(flags, deps) {
|
|
73
|
+
const { isJsonMode, requirePositiveInteger, requireNumber, toBoolean, writeOutput } = deps;
|
|
74
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
75
|
+
|
|
76
|
+
const count = requirePositiveInteger(flags.count, 5);
|
|
77
|
+
const candidateLimit = requirePositiveInteger(flags["candidate-limit"], 100);
|
|
78
|
+
const minDuration = numericOrNull(flags["min-duration"], deps);
|
|
79
|
+
const maxDuration = numericOrNull(flags["max-duration"], deps);
|
|
80
|
+
const minTss = numericOrNull(flags["min-tss"], deps);
|
|
81
|
+
const maxTss = numericOrNull(flags["max-tss"], deps);
|
|
82
|
+
const minLevel = numericOrNull(flags["min-level"], deps);
|
|
83
|
+
const maxLevel = numericOrNull(flags["max-level"], deps);
|
|
84
|
+
const targetDuration = deriveTarget(numericOrNull(flags["target-duration"], deps), minDuration, maxDuration);
|
|
85
|
+
const targetTss = deriveTarget(numericOrNull(flags["target-tss"], deps), minTss, maxTss);
|
|
86
|
+
const targetLevel = deriveTarget(numericOrNull(flags["target-level"], deps), minLevel, maxLevel);
|
|
87
|
+
const searchText = String(flags.search ?? "").trim();
|
|
88
|
+
|
|
89
|
+
const library = await queryWorkoutLibrary(client, memberInfo, {
|
|
90
|
+
searchText,
|
|
91
|
+
zone: flags.zone,
|
|
92
|
+
zoneId: flags["zone-id"],
|
|
93
|
+
profile: flags.profile,
|
|
94
|
+
profileId: flags["profile-id"],
|
|
95
|
+
outside: flags.outside == null ? null : toBoolean(flags.outside, false),
|
|
96
|
+
hasInstructions:
|
|
97
|
+
flags["has-instructions"] == null ? null : toBoolean(flags["has-instructions"], false),
|
|
98
|
+
minDuration,
|
|
99
|
+
maxDuration,
|
|
100
|
+
minTss,
|
|
101
|
+
maxTss,
|
|
102
|
+
minLevel,
|
|
103
|
+
maxLevel,
|
|
104
|
+
sort: String(flags.sort ?? "level"),
|
|
105
|
+
limit: candidateLimit,
|
|
106
|
+
pageSize: requirePositiveInteger(flags["page-size"], 50),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const targets = {
|
|
110
|
+
duration: targetDuration,
|
|
111
|
+
tss: targetTss,
|
|
112
|
+
level: targetLevel,
|
|
113
|
+
searchText,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const recommendations = library.records
|
|
117
|
+
.map((item) => {
|
|
118
|
+
const ranking = scoreWorkout(item, targets);
|
|
119
|
+
return { ...item, recommendationScore: ranking.score, rationale: ranking.reasons };
|
|
120
|
+
})
|
|
121
|
+
.sort(
|
|
122
|
+
(a, b) =>
|
|
123
|
+
compareValues(a.recommendationScore, b.recommendationScore) ||
|
|
124
|
+
compareValues(a.progressionLevel, b.progressionLevel) ||
|
|
125
|
+
compareValues(a.durationMinutes, b.durationMinutes) ||
|
|
126
|
+
compareValues(a.workoutName, b.workoutName),
|
|
127
|
+
)
|
|
128
|
+
.slice(0, count);
|
|
129
|
+
|
|
130
|
+
const payload = {
|
|
131
|
+
generatedAt: new Date().toISOString(),
|
|
132
|
+
command: "workout-recommend",
|
|
133
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
134
|
+
query: {
|
|
135
|
+
searchText,
|
|
136
|
+
zone: flags.zone ?? null,
|
|
137
|
+
zoneId: flags["zone-id"] ?? null,
|
|
138
|
+
profile: flags.profile ?? null,
|
|
139
|
+
profileId: flags["profile-id"] ?? null,
|
|
140
|
+
outside: flags.outside ?? null,
|
|
141
|
+
hasInstructions: flags["has-instructions"] ?? null,
|
|
142
|
+
minDuration,
|
|
143
|
+
maxDuration,
|
|
144
|
+
minTss,
|
|
145
|
+
maxTss,
|
|
146
|
+
minLevel,
|
|
147
|
+
maxLevel,
|
|
148
|
+
targetDuration,
|
|
149
|
+
targetTss,
|
|
150
|
+
targetLevel,
|
|
151
|
+
count,
|
|
152
|
+
candidateLimit,
|
|
153
|
+
},
|
|
154
|
+
fetch: library.fetch,
|
|
155
|
+
recommendationCount: recommendations.length,
|
|
156
|
+
records: recommendations,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (!isJsonMode(flags)) {
|
|
160
|
+
await writeOutput(payload, flags, (value) => {
|
|
161
|
+
const lines = [
|
|
162
|
+
`Workout recommendations (${value.recommendationCount}) candidates=${value.fetch.fetchedCount}/${value.fetch.serverTotalCount ?? "?"}`,
|
|
163
|
+
];
|
|
164
|
+
for (const item of value.records) {
|
|
165
|
+
lines.push(
|
|
166
|
+
`- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | score=${item.recommendationScore.toFixed(2)} | level=${item.progressionLevel ?? "?"} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
175
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
function stripHtml(value) {
|
|
2
|
+
return String(value ?? "")
|
|
3
|
+
.replace(/<[^>]+>/g, " ")
|
|
4
|
+
.replace(/ /gi, " ")
|
|
5
|
+
.replace(/&/gi, "&")
|
|
6
|
+
.replace(/"/gi, '"')
|
|
7
|
+
.replace(/'/gi, "'")
|
|
8
|
+
.replace(/\s+/g, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function summarizeWorkout(workout) {
|
|
13
|
+
if (!workout || typeof workout !== "object") return null;
|
|
14
|
+
return {
|
|
15
|
+
workoutId: workout.id ?? null,
|
|
16
|
+
workoutName: workout.workoutName ?? workout.name ?? null,
|
|
17
|
+
zoneId: workout.progressionId ?? workout.progression?.id ?? null,
|
|
18
|
+
zoneName: workout.progression?.text ?? null,
|
|
19
|
+
profileId: workout.profileId ?? null,
|
|
20
|
+
profileName: workout.profileName ?? null,
|
|
21
|
+
durationMinutes: workout.duration ?? null,
|
|
22
|
+
tss: workout.tss ?? null,
|
|
23
|
+
intensityFactor: workout.intensityFactor ?? null,
|
|
24
|
+
averageFtpPercent: workout.averageFtpPercent ?? null,
|
|
25
|
+
progressionLevel: workout.progressionLevel ?? null,
|
|
26
|
+
workoutDifficultyRating: workout.workoutDifficultyRating ?? null,
|
|
27
|
+
workoutTypeId: workout.workoutTypeId ?? null,
|
|
28
|
+
workoutLabelId: workout.workoutLabelId ?? null,
|
|
29
|
+
isOutside: workout.isOutside ?? null,
|
|
30
|
+
hasInstructions: workout.hasInstructions ?? null,
|
|
31
|
+
firstPublishDate: workout.firstPublishDate ?? null,
|
|
32
|
+
indoorAlternativeId: workout.indoorAlternativeId ?? null,
|
|
33
|
+
energyKj: workout.kj ?? null,
|
|
34
|
+
goal: stripHtml(workout.goalDescription),
|
|
35
|
+
description: stripHtml(workout.workoutDescription),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function summarizePlannedActivity(activity) {
|
|
40
|
+
if (!activity || typeof activity !== "object") return null;
|
|
41
|
+
const workout = activity.workout ?? null;
|
|
42
|
+
const date = activity.date
|
|
43
|
+
? `${String(activity.date.year).padStart(4, "0")}-${String(activity.date.month).padStart(2, "0")}-${String(activity.date.day).padStart(2, "0")}`
|
|
44
|
+
: null;
|
|
45
|
+
return {
|
|
46
|
+
plannedActivityId: activity.id ?? null,
|
|
47
|
+
date,
|
|
48
|
+
timeOfDay: activity.timeOfDay ?? null,
|
|
49
|
+
workoutId: workout?.id ?? null,
|
|
50
|
+
workoutName: workout?.name ?? activity.name ?? null,
|
|
51
|
+
isOutside: workout?.isOutside ?? null,
|
|
52
|
+
durationMinutes:
|
|
53
|
+
workout?.duration ??
|
|
54
|
+
(Number.isFinite(Number(activity.durationInSeconds))
|
|
55
|
+
? Math.round(Number(activity.durationInSeconds) / 60)
|
|
56
|
+
: null),
|
|
57
|
+
tss: activity.tss ?? workout?.tss ?? null,
|
|
58
|
+
recommendationReason: activity.recommendationReason ?? null,
|
|
59
|
+
modified: activity.modified ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function summarizeLevels(levelsPayload) {
|
|
64
|
+
return {
|
|
65
|
+
workoutLevels: Array.isArray(levelsPayload?.workoutLevels) ? levelsPayload.workoutLevels : [],
|
|
66
|
+
athleteLevels:
|
|
67
|
+
levelsPayload && typeof levelsPayload.athleteLevels === "object" ? levelsPayload.athleteLevels : {},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function summarizeChart(chartData, pointLimit = 200) {
|
|
72
|
+
const rawPoints = Array.isArray(chartData?.courseData) ? chartData.courseData : [];
|
|
73
|
+
const pointsUseMilliseconds =
|
|
74
|
+
rawPoints.length > 1 && Number(rawPoints[1]?.seconds) >= 1000 && Number(rawPoints[1]?.seconds) % 1000 === 0;
|
|
75
|
+
const points = rawPoints.map((point) => ({
|
|
76
|
+
...point,
|
|
77
|
+
seconds:
|
|
78
|
+
pointsUseMilliseconds && Number.isFinite(Number(point?.seconds))
|
|
79
|
+
? Number(point.seconds) / 1000
|
|
80
|
+
: point?.seconds ?? null,
|
|
81
|
+
}));
|
|
82
|
+
let minPercent = null;
|
|
83
|
+
let maxPercent = null;
|
|
84
|
+
let durationSeconds = 0;
|
|
85
|
+
for (const point of points) {
|
|
86
|
+
const ftpPercent = Number(point?.ftpPercent);
|
|
87
|
+
if (Number.isFinite(ftpPercent)) {
|
|
88
|
+
minPercent = minPercent == null ? ftpPercent : Math.min(minPercent, ftpPercent);
|
|
89
|
+
maxPercent = maxPercent == null ? ftpPercent : Math.max(maxPercent, ftpPercent);
|
|
90
|
+
}
|
|
91
|
+
const seconds = Number(point?.seconds);
|
|
92
|
+
if (Number.isFinite(seconds)) durationSeconds = Math.max(durationSeconds, seconds);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
pointCount: points.length,
|
|
96
|
+
durationSeconds,
|
|
97
|
+
minFtpPercent: minPercent,
|
|
98
|
+
maxFtpPercent: maxPercent,
|
|
99
|
+
points: points.slice(0, pointLimit),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requireFlag(flags, name) {
|
|
104
|
+
const value = flags[name];
|
|
105
|
+
if (value === undefined || value === null || value === "") {
|
|
106
|
+
throw new Error(`Missing required flag --${name}.`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function requirePrivateMember(flags, deps) {
|
|
112
|
+
const { withClient } = deps;
|
|
113
|
+
const client = await withClient(flags);
|
|
114
|
+
try {
|
|
115
|
+
const memberInfo = await client.getMemberInfo();
|
|
116
|
+
return { client, memberInfo };
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"This command requires private authenticated mode. Login first with trainerroad-cli login.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function sleep(ms) {
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId) {
|
|
129
|
+
const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
|
|
130
|
+
const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
|
|
131
|
+
.filter((item) => {
|
|
132
|
+
const date = item?.date;
|
|
133
|
+
const asIso = date
|
|
134
|
+
? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
|
|
135
|
+
: null;
|
|
136
|
+
return asIso === dateIso;
|
|
137
|
+
})
|
|
138
|
+
.map((item) => item.id)
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
|
|
141
|
+
if (candidateIds.length === 0) return null;
|
|
142
|
+
const details = await client.getPlannedActivitiesByIds(
|
|
143
|
+
memberInfo.memberId,
|
|
144
|
+
memberInfo.username,
|
|
145
|
+
candidateIds,
|
|
146
|
+
);
|
|
147
|
+
const matches = details.filter((item) => Number(item?.workout?.id) === Number(workoutId));
|
|
148
|
+
if (matches.length === 0) return null;
|
|
149
|
+
return matches.sort((a, b) => String(b.modified ?? "").localeCompare(String(a.modified ?? "")))[0];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function listPlannedWorkoutsOnDate(client, memberInfo, dateIso) {
|
|
153
|
+
const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
|
|
154
|
+
const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
|
|
155
|
+
.filter((item) => {
|
|
156
|
+
const date = item?.date;
|
|
157
|
+
const asIso = date
|
|
158
|
+
? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
|
|
159
|
+
: null;
|
|
160
|
+
return asIso === dateIso;
|
|
161
|
+
})
|
|
162
|
+
.map((item) => item.id)
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
|
|
165
|
+
if (candidateIds.length === 0) return [];
|
|
166
|
+
return client.getPlannedActivitiesByIds(memberInfo.memberId, memberInfo.username, candidateIds);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function commandWorkoutDetails(flags, deps) {
|
|
170
|
+
const { isJsonMode, requirePositiveInteger, toBoolean, writeOutput } = deps;
|
|
171
|
+
const workoutId = Number(requireFlag(flags, "id"));
|
|
172
|
+
if (!Number.isFinite(workoutId)) {
|
|
173
|
+
throw new Error(`Invalid --id "${flags.id}". Expected a numeric workout ID.`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const includeChart = toBoolean(flags["include-chart"], false);
|
|
177
|
+
const chartPointLimit = requirePositiveInteger(flags["chart-point-limit"], 200);
|
|
178
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
179
|
+
|
|
180
|
+
const [byIdRows, summaryPayload, levelsPayload, chartData] = await Promise.all([
|
|
181
|
+
client.getWorkoutsByIds([workoutId], memberInfo.username),
|
|
182
|
+
client.getWorkoutSummary(workoutId, memberInfo.username),
|
|
183
|
+
client.getWorkoutLevels(workoutId, memberInfo.username),
|
|
184
|
+
includeChart ? client.getWorkoutChartData(workoutId, memberInfo.username) : Promise.resolve(null),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const byIdWorkout = Array.isArray(byIdRows) && byIdRows.length > 0 ? byIdRows[0] : null;
|
|
188
|
+
const summaryWorkout = summaryPayload?.summary ?? null;
|
|
189
|
+
const workout = summarizeWorkout(summaryWorkout ?? byIdWorkout);
|
|
190
|
+
const payload = {
|
|
191
|
+
generatedAt: new Date().toISOString(),
|
|
192
|
+
command: "workout-details",
|
|
193
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
194
|
+
query: { workoutId, includeChart, chartPointLimit },
|
|
195
|
+
workout,
|
|
196
|
+
levels: summarizeLevels(levelsPayload),
|
|
197
|
+
chart: includeChart ? summarizeChart(chartData, chartPointLimit) : undefined,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (!isJsonMode(flags)) {
|
|
201
|
+
await writeOutput(payload, flags, (value) => {
|
|
202
|
+
const lines = [
|
|
203
|
+
`${value.workout?.workoutName ?? "Workout"} | workoutId=${value.query.workoutId} | zone=${value.workout?.zoneName ?? value.workout?.zoneId ?? "?"} | profile=${value.workout?.profileName ?? value.workout?.profileId ?? "?"}`,
|
|
204
|
+
`duration=${value.workout?.durationMinutes ?? "?"}m | tss=${value.workout?.tss ?? "?"} | if=${value.workout?.intensityFactor ?? "?"} | level=${value.workout?.progressionLevel ?? "?"} | outside=${value.workout?.isOutside}`,
|
|
205
|
+
];
|
|
206
|
+
if (value.chart) {
|
|
207
|
+
lines.push(
|
|
208
|
+
`chart: points=${value.chart.pointCount} duration=${value.chart.durationSeconds}s ftp%=${value.chart.minFtpPercent ?? "?"}-${value.chart.maxFtpPercent ?? "?"}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function commandAddWorkout(flags, deps) {
|
|
220
|
+
const { isJsonMode, toBoolean, writeOutput } = deps;
|
|
221
|
+
const workoutId = Number(requireFlag(flags, "workout-id"));
|
|
222
|
+
const dateIso = String(requireFlag(flags, "date"));
|
|
223
|
+
if (!Number.isFinite(workoutId)) {
|
|
224
|
+
throw new Error(`Invalid --workout-id "${flags["workout-id"]}". Expected a numeric workout ID.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const outside = toBoolean(flags.outside, false);
|
|
228
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
229
|
+
const workoutRows = await client.getWorkoutsByIds([workoutId], memberInfo.username);
|
|
230
|
+
const workout = summarizeWorkout(Array.isArray(workoutRows) ? workoutRows[0] : null);
|
|
231
|
+
if (!workout) {
|
|
232
|
+
throw new Error(`Workout ${workoutId} was not found in the library.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const attempts = await client.tryAddWorkoutToCalendar(workoutId, dateIso, {
|
|
236
|
+
outside,
|
|
237
|
+
usernameForReferer: memberInfo.username,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
let created = null;
|
|
241
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
242
|
+
created = await findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId);
|
|
243
|
+
if (created) break;
|
|
244
|
+
await sleep(750);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!created) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`TrainerRoad did not expose a confirmed added workout for ${dateIso}. Attempts: ${JSON.stringify(attempts)}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const payload = {
|
|
254
|
+
generatedAt: new Date().toISOString(),
|
|
255
|
+
command: "add-workout",
|
|
256
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
257
|
+
query: { workoutId, date: dateIso, outside },
|
|
258
|
+
workout,
|
|
259
|
+
created: summarizePlannedActivity(created),
|
|
260
|
+
attempts,
|
|
261
|
+
warnings: attempts.some((item) => !item.ok)
|
|
262
|
+
? [
|
|
263
|
+
"TrainerRoad returned one or more non-2xx responses during add-workout, but the workout was observed on the calendar after reconciliation.",
|
|
264
|
+
]
|
|
265
|
+
: [],
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (!isJsonMode(flags)) {
|
|
269
|
+
await writeOutput(payload, flags, (value) => {
|
|
270
|
+
const warning = value.warnings.length > 0 ? " | warning=server-status-mismatch" : "";
|
|
271
|
+
return `Added ${value.workout?.workoutName ?? "workout"} to ${value.query.date} | plannedActivityId=${value.created?.plannedActivityId}${warning}`;
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function commandCopyWorkout(flags, deps) {
|
|
280
|
+
const { isJsonMode, writeOutput } = deps;
|
|
281
|
+
const sourcePlannedActivityId = String(requireFlag(flags, "id"));
|
|
282
|
+
const targetDate = String(requireFlag(flags, "date"));
|
|
283
|
+
const { client, memberInfo } = await requirePrivateMember(flags, deps);
|
|
284
|
+
const source = await client.getPlannedActivity(sourcePlannedActivityId, memberInfo.username);
|
|
285
|
+
const beforeTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
|
|
286
|
+
const beforeIds = new Set(beforeTarget.map((item) => item.id));
|
|
287
|
+
|
|
288
|
+
const mutation = await client.copyPlannedActivity(sourcePlannedActivityId, targetDate, memberInfo.username);
|
|
289
|
+
|
|
290
|
+
let created = null;
|
|
291
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
292
|
+
const afterTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
|
|
293
|
+
created = afterTarget.find(
|
|
294
|
+
(item) =>
|
|
295
|
+
!beforeIds.has(item.id) &&
|
|
296
|
+
Number(item?.workout?.id) === Number(source?.workout?.id),
|
|
297
|
+
);
|
|
298
|
+
if (created) break;
|
|
299
|
+
await sleep(500);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!created) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Copy completed but no new planned workout was found on ${targetDate} for source ${sourcePlannedActivityId}.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const payload = {
|
|
309
|
+
generatedAt: new Date().toISOString(),
|
|
310
|
+
command: "copy-workout",
|
|
311
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
312
|
+
query: { sourcePlannedActivityId, date: targetDate },
|
|
313
|
+
source: summarizePlannedActivity(source),
|
|
314
|
+
created: summarizePlannedActivity(created),
|
|
315
|
+
mutation,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (!isJsonMode(flags)) {
|
|
319
|
+
await writeOutput(payload, flags, (value) => {
|
|
320
|
+
return `Copied ${value.source?.workoutName ?? "workout"} to ${value.query.date} | plannedActivityId=${value.created?.plannedActivityId}`;
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
326
|
+
}
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
function addLocalTimeSummary(record, summarizeActivityTime) {
|
|
2
|
+
if (!record || typeof record !== "object" || typeof summarizeActivityTime !== "function") {
|
|
3
|
+
return record;
|
|
4
|
+
}
|
|
5
|
+
const summary = summarizeActivityTime(record.started, record.durationInSeconds);
|
|
6
|
+
if (!summary) return record;
|
|
7
|
+
return { ...record, ...summary };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function addLocalTimeSummaryList(records, summarizeActivityTime) {
|
|
11
|
+
const rows = Array.isArray(records) ? records : [];
|
|
12
|
+
return rows.map((record) => addLocalTimeSummary(record, summarizeActivityTime));
|
|
13
|
+
}
|
|
14
|
+
|
|
1
15
|
export async function commandFuture(flags, deps) {
|
|
2
16
|
const {
|
|
3
17
|
requireNumber,
|
|
@@ -130,6 +144,7 @@ export async function commandPast(flags, deps) {
|
|
|
130
144
|
writeOutput,
|
|
131
145
|
hasAgentRecordTransforms,
|
|
132
146
|
sortByDateDesc,
|
|
147
|
+
summarizeActivityTime,
|
|
133
148
|
} = deps;
|
|
134
149
|
|
|
135
150
|
const days = requireNumber(flags.days, 60);
|
|
@@ -144,7 +159,8 @@ export async function commandPast(flags, deps) {
|
|
|
144
159
|
if (context.mode === "private") {
|
|
145
160
|
const filtered = filterPastActivities(context.timeline.activities, fromDate, toDate).slice(0, limit);
|
|
146
161
|
if (!flags.details) {
|
|
147
|
-
const
|
|
162
|
+
const recordsWithLocalTime = addLocalTimeSummaryList(filtered, summarizeActivityTime);
|
|
163
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(recordsWithLocalTime, flags);
|
|
148
164
|
const payload = {
|
|
149
165
|
mode: "private",
|
|
150
166
|
generatedAt: new Date().toISOString(),
|
|
@@ -165,7 +181,10 @@ export async function commandPast(flags, deps) {
|
|
|
165
181
|
return lines.join("\n");
|
|
166
182
|
}
|
|
167
183
|
for (const item of value.records) {
|
|
168
|
-
|
|
184
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
185
|
+
lines.push(
|
|
186
|
+
`- ${item.startedAtLocal ?? item.started} id=${item.id} type=${item.type} tss=${item.tss}${overnightLabel}`,
|
|
187
|
+
);
|
|
169
188
|
}
|
|
170
189
|
return lines.join("\n");
|
|
171
190
|
});
|
|
@@ -190,7 +209,11 @@ export async function commandPast(flags, deps) {
|
|
|
190
209
|
...item,
|
|
191
210
|
personalRecordCount: Array.isArray(personalRecords[item.id]) ? personalRecords[item.id].length : 0,
|
|
192
211
|
}));
|
|
193
|
-
const
|
|
212
|
+
const detailRecordsWithLocalTime = addLocalTimeSummaryList(detailRecords, summarizeActivityTime);
|
|
213
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(
|
|
214
|
+
detailRecordsWithLocalTime,
|
|
215
|
+
flags,
|
|
216
|
+
);
|
|
194
217
|
const payload = {
|
|
195
218
|
mode: "private",
|
|
196
219
|
generatedAt: new Date().toISOString(),
|
|
@@ -215,8 +238,9 @@ export async function commandPast(flags, deps) {
|
|
|
215
238
|
return lines.join("\n");
|
|
216
239
|
}
|
|
217
240
|
for (const item of value.records) {
|
|
241
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
218
242
|
lines.push(
|
|
219
|
-
`- ${
|
|
243
|
+
`- ${item.startedAtLocal ?? item.started} ${item.name} | id=${item.id} | tss=${item.tss} | duration=${item.durationInSeconds}s | prs=${item.personalRecordCount}${overnightLabel}`,
|
|
220
244
|
);
|
|
221
245
|
}
|
|
222
246
|
return lines.join("\n");
|
|
@@ -286,6 +310,7 @@ export async function commandToday(flags, deps) {
|
|
|
286
310
|
writeOutput,
|
|
287
311
|
hasAgentRecordTransforms,
|
|
288
312
|
toIsoDateFromPlanned,
|
|
313
|
+
summarizeActivityTime,
|
|
289
314
|
} = deps;
|
|
290
315
|
|
|
291
316
|
const today = normalizeDateOnlyInput(flags.date, isoDateShift(0));
|
|
@@ -316,9 +341,10 @@ export async function commandToday(flags, deps) {
|
|
|
316
341
|
);
|
|
317
342
|
}
|
|
318
343
|
|
|
344
|
+
const completedWithLocalTime = addLocalTimeSummaryList(activityRecords, summarizeActivityTime);
|
|
319
345
|
const records = [
|
|
320
346
|
...plannedRecords.map((item) => ({ recordType: "planned", ...item })),
|
|
321
|
-
...
|
|
347
|
+
...completedWithLocalTime.map((item) => ({
|
|
322
348
|
recordType: "completed",
|
|
323
349
|
...item,
|
|
324
350
|
personalRecordCount: Array.isArray(personalRecords[item.id]) ? personalRecords[item.id].length : 0,
|
|
@@ -333,9 +359,9 @@ export async function commandToday(flags, deps) {
|
|
|
333
359
|
query: { date: today, details: Boolean(flags.details) },
|
|
334
360
|
filters: filterSummary,
|
|
335
361
|
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
336
|
-
counts: { planned: plannedRecords.length, completed:
|
|
362
|
+
counts: { planned: plannedRecords.length, completed: completedWithLocalTime.length },
|
|
337
363
|
planned: plannedRecords,
|
|
338
|
-
completed:
|
|
364
|
+
completed: completedWithLocalTime,
|
|
339
365
|
personalRecords,
|
|
340
366
|
count: filteredRecords.length,
|
|
341
367
|
records: filteredRecords,
|
|
@@ -372,9 +398,15 @@ export async function commandToday(flags, deps) {
|
|
|
372
398
|
for (const item of value.completed) {
|
|
373
399
|
if (flags.details) {
|
|
374
400
|
const prs = Array.isArray(value.personalRecords[item.id]) ? value.personalRecords[item.id].length : 0;
|
|
375
|
-
|
|
401
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
402
|
+
lines.push(
|
|
403
|
+
`- completed ${item.startedAtLocal ?? item.started} ${item.name} | tss=${item.tss} | prs=${prs}${overnightLabel}`,
|
|
404
|
+
);
|
|
376
405
|
} else {
|
|
377
|
-
|
|
406
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
407
|
+
lines.push(
|
|
408
|
+
`- completed ${item.startedAtLocal ?? item.started} id=${item.id} type=${item.type} tss=${item.tss}${overnightLabel}`,
|
|
409
|
+
);
|
|
378
410
|
}
|
|
379
411
|
}
|
|
380
412
|
return lines.join("\n");
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { normalizeTimeZone, toDateOnlyInTimeZone } from "./timezone.mjs";
|
|
2
|
+
|
|
1
3
|
function toIsoDate(value) {
|
|
2
|
-
if (typeof value === "string" && value.length >= 10
|
|
3
|
-
|
|
4
|
+
if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
5
|
+
return value.slice(0, 10);
|
|
6
|
+
}
|
|
7
|
+
return (
|
|
8
|
+
toDateOnlyInTimeZone(value, normalizeTimeZone(), { assumeUtcForOffsetlessDateTime: true }) ??
|
|
9
|
+
new Date(value).toISOString().slice(0, 10)
|
|
10
|
+
);
|
|
4
11
|
}
|
|
5
12
|
|
|
6
13
|
function toIsoDateFromPlannedRecord(record) {
|