trainerroad-cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +65 -1
- package/package.json +11 -2
- package/src/cli.mjs +120 -37
- package/src/commands/annotation-mutations.mjs +253 -0
- package/src/commands/auth.mjs +3 -2
- package/src/commands/discovery.mjs +9 -8
- package/src/commands/event-mutations.mjs +174 -0
- package/src/commands/plan.mjs +23 -5
- package/src/commands/power.mjs +2 -1
- package/src/commands/workout-image.mjs +99 -0
- package/src/commands/workout-library.mjs +1 -0
- package/src/commands/workout-mutations.mjs +186 -46
- package/src/commands/workout-tools.mjs +104 -41
- package/src/lib/command-manifest.mjs +458 -35
- package/src/lib/planning-normalizers.mjs +110 -4
- package/src/trainerroad-client.mjs +246 -36
|
@@ -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
|
|
|
@@ -6,6 +6,51 @@ const BASE_URL = "https://www.trainerroad.com";
|
|
|
6
6
|
const APP_URL = `${BASE_URL}/app`;
|
|
7
7
|
const DEFAULT_USER_AGENT =
|
|
8
8
|
"trainerroad-cli/0.1 (unofficial; personal data export; +https://www.trainerroad.com)";
|
|
9
|
+
const JSON_FORMAT_HEADER = "trainerroad-jsonformat";
|
|
10
|
+
const AUTH_COOKIE = "SharedTrainerRoadAuth";
|
|
11
|
+
|
|
12
|
+
function lowerFirst(key) {
|
|
13
|
+
return key.length > 0 ? key[0].toLowerCase() + key.slice(1) : key;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function looksPascalCase(value) {
|
|
17
|
+
const sample = Array.isArray(value) ? value.find((item) => item && typeof item === "object") : value;
|
|
18
|
+
if (!sample || typeof sample !== "object") return false;
|
|
19
|
+
const keys = Object.keys(sample);
|
|
20
|
+
return keys.length > 0 && keys.every((key) => /^[A-Z]/.test(key));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* TrainerRoad serialises with PascalCase keys unless the `trainerroad-jsonformat: camel-case`
|
|
25
|
+
* header is honoured, and some endpoints (personal records) nest PascalCase objects inside a
|
|
26
|
+
* camelCase envelope even then. Every consumer in this CLI expects camelCase, so each object whose
|
|
27
|
+
* keys all start with a capital gets its keys lower-cased, at every depth. camelCase passes through.
|
|
28
|
+
*/
|
|
29
|
+
export function camelizeKeys(value) {
|
|
30
|
+
if (Array.isArray(value)) return value.map((item) => camelizeKeys(item));
|
|
31
|
+
if (value === null || typeof value !== "object") return value;
|
|
32
|
+
const rename = looksPascalCase(value);
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
35
|
+
out[rename ? lowerFirst(key) : key] = camelizeKeys(inner);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class HttpError extends Error {
|
|
41
|
+
constructor(message, { status, statusText = "", path = "", payload = null } = {}) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "HttpError";
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.statusText = statusText;
|
|
46
|
+
this.path = path;
|
|
47
|
+
this.payload = payload;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isHttpStatus(error, status) {
|
|
52
|
+
return error instanceof HttpError && error.status === status;
|
|
53
|
+
}
|
|
9
54
|
|
|
10
55
|
function ensureLeadingSlash(value) {
|
|
11
56
|
if (!value.startsWith("/")) return `/${value}`;
|
|
@@ -151,6 +196,8 @@ export class TrainerRoadClient {
|
|
|
151
196
|
const headers = new Headers(options.headers ?? {});
|
|
152
197
|
headers.set("user-agent", this.userAgent);
|
|
153
198
|
if (!headers.has("accept")) headers.set("accept", "application/json, text/plain, */*");
|
|
199
|
+
// The web app sends this on every API call; without it responses come back PascalCase.
|
|
200
|
+
if (!headers.has(JSON_FORMAT_HEADER)) headers.set(JSON_FORMAT_HEADER, "camel-case");
|
|
154
201
|
const cookieHeader = this.jar.cookieHeader();
|
|
155
202
|
if (cookieHeader) headers.set("cookie", cookieHeader);
|
|
156
203
|
|
|
@@ -181,24 +228,68 @@ export class TrainerRoadClient {
|
|
|
181
228
|
typeof payload === "object" && payload !== null
|
|
182
229
|
? JSON.stringify(payload)
|
|
183
230
|
: String(payload);
|
|
184
|
-
throw new
|
|
231
|
+
throw new HttpError(
|
|
185
232
|
`Request failed: ${response.status} ${response.statusText} for ${urlOrPath} -> ${detail}`,
|
|
233
|
+
{ status: response.status, statusText: response.statusText, path: urlOrPath, payload },
|
|
186
234
|
);
|
|
187
235
|
}
|
|
188
|
-
return payload;
|
|
236
|
+
return camelizeKeys(payload);
|
|
189
237
|
}
|
|
190
238
|
|
|
191
239
|
async login({
|
|
192
240
|
username = this.username,
|
|
193
241
|
password = this.password,
|
|
194
|
-
returnPath = "/app/career
|
|
242
|
+
returnPath = username ? `/app/career/${username}` : "/app/career",
|
|
195
243
|
} = {}) {
|
|
196
244
|
if (!username || !password) {
|
|
197
245
|
throw new Error("Username and password are required for login.");
|
|
198
246
|
}
|
|
199
247
|
|
|
200
248
|
const normalizedReturnPath = ensureLeadingSlash(returnPath);
|
|
201
|
-
|
|
249
|
+
|
|
250
|
+
// The current web app authenticates through a JSON endpoint. The older server-rendered form
|
|
251
|
+
// flow is kept as a fallback so the CLI keeps working if that route disappears again.
|
|
252
|
+
const jsonResult = await this.#loginJson({ username, password, returnPath: normalizedReturnPath });
|
|
253
|
+
if (jsonResult.handled) return this.#finishLogin(jsonResult.redirect);
|
|
254
|
+
|
|
255
|
+
return this.#loginLegacyForm({ username, password, returnPath: normalizedReturnPath });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #loginJson({ username, password, returnPath }) {
|
|
259
|
+
const loginPath = "/app/api/login/login";
|
|
260
|
+
const response = await this.#request(loginPath, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: {
|
|
263
|
+
"content-type": "application/json",
|
|
264
|
+
origin: BASE_URL,
|
|
265
|
+
referer: `${APP_URL}/login`,
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify({ username, password, returnUrl: returnPath }),
|
|
268
|
+
redirect: "manual",
|
|
269
|
+
});
|
|
270
|
+
const text = await response.text();
|
|
271
|
+
let payload;
|
|
272
|
+
try {
|
|
273
|
+
payload = camelizeKeys(JSON.parse(text));
|
|
274
|
+
} catch {
|
|
275
|
+
return { handled: false };
|
|
276
|
+
}
|
|
277
|
+
if (response.status === 404 || response.status === 405 || payload === null || typeof payload !== "object") {
|
|
278
|
+
return { handled: false };
|
|
279
|
+
}
|
|
280
|
+
if (payload.success === true || this.jar.has(AUTH_COOKIE)) {
|
|
281
|
+
return { handled: true, redirect: payload.redirectUrl ?? "" };
|
|
282
|
+
}
|
|
283
|
+
if (payload.success === false) {
|
|
284
|
+
throw new Error("Login failed: TrainerRoad rejected the username or password.");
|
|
285
|
+
}
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Login failed: unexpected response from ${loginPath} (status ${response.status}): ${text.slice(0, 300)}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async #loginLegacyForm({ username, password, returnPath }) {
|
|
292
|
+
const loginPath = `/app/login?ReturnUrl=${encodeURIComponent(returnPath)}`;
|
|
202
293
|
|
|
203
294
|
const loginPage = await this.#request(loginPath, {
|
|
204
295
|
method: "GET",
|
|
@@ -213,7 +304,9 @@ export class TrainerRoadClient {
|
|
|
213
304
|
const returnUrlMatch = html.match(/id="ReturnUrl"\s+name="ReturnUrl"\s+type="hidden"\s+value="([^"]+)"/i);
|
|
214
305
|
|
|
215
306
|
if (!tokenMatch) {
|
|
216
|
-
throw new Error(
|
|
307
|
+
throw new Error(
|
|
308
|
+
"Login failed: the JSON login API did not answer and the login page has no __RequestVerificationToken form. TrainerRoad may have changed its login flow again.",
|
|
309
|
+
);
|
|
217
310
|
}
|
|
218
311
|
if (!returnUrlMatch) {
|
|
219
312
|
throw new Error("Could not locate ReturnUrl hidden input on login page.");
|
|
@@ -242,20 +335,21 @@ export class TrainerRoadClient {
|
|
|
242
335
|
throw new Error(`Login did not redirect. Status=${response.status}. Body preview=${body.slice(0, 300)}`);
|
|
243
336
|
}
|
|
244
337
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
338
|
+
return this.#finishLogin(response.headers.get("location") ?? "");
|
|
339
|
+
}
|
|
248
340
|
|
|
249
|
-
|
|
341
|
+
async #finishLogin(redirect) {
|
|
342
|
+
if (!this.jar.has(AUTH_COOKIE)) {
|
|
343
|
+
throw new Error(`Login succeeded, but the ${AUTH_COOKIE} cookie is missing.`);
|
|
344
|
+
}
|
|
250
345
|
await this.saveSession({
|
|
251
346
|
authenticatedAt: new Date().toISOString(),
|
|
252
|
-
lastLoginRedirect:
|
|
347
|
+
lastLoginRedirect: redirect,
|
|
253
348
|
});
|
|
254
|
-
|
|
255
349
|
return {
|
|
256
350
|
ok: true,
|
|
257
|
-
redirect
|
|
258
|
-
hasAuthCookie: this.jar.has(
|
|
351
|
+
redirect,
|
|
352
|
+
hasAuthCookie: this.jar.has(AUTH_COOKIE),
|
|
259
353
|
};
|
|
260
354
|
}
|
|
261
355
|
|
|
@@ -271,6 +365,112 @@ export class TrainerRoadClient {
|
|
|
271
365
|
});
|
|
272
366
|
}
|
|
273
367
|
|
|
368
|
+
// Public asset fetch (workout chart SVGs live on a CDN, no cookies needed).
|
|
369
|
+
async fetchText(url) {
|
|
370
|
+
const response = await fetch(url, { headers: { "user-agent": this.userAgent } });
|
|
371
|
+
const text = await response.text();
|
|
372
|
+
if (!response.ok) {
|
|
373
|
+
throw new HttpError(`Request failed: ${response.status} ${response.statusText} for ${url}`, {
|
|
374
|
+
status: response.status,
|
|
375
|
+
statusText: response.statusText,
|
|
376
|
+
path: url,
|
|
377
|
+
payload: text,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return text;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Body shape: see docs/api-notes.md "Event and planned-activity write endpoints".
|
|
384
|
+
async createEvent(event, usernameForReferer) {
|
|
385
|
+
const path = "/app/api/calendar/plannedactivities/event";
|
|
386
|
+
const response = await this.#request(path, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: {
|
|
389
|
+
"content-type": "application/json",
|
|
390
|
+
"trainerroad-jsonformat": "camel-case",
|
|
391
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
392
|
+
},
|
|
393
|
+
body: JSON.stringify(event),
|
|
394
|
+
});
|
|
395
|
+
const text = await response.text();
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
throw new HttpError(`Request failed: ${response.status} ${response.statusText} for ${path} -> ${text}`, {
|
|
398
|
+
status: response.status,
|
|
399
|
+
statusText: response.statusText,
|
|
400
|
+
path,
|
|
401
|
+
payload: text,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
return camelizeKeys(JSON.parse(text));
|
|
406
|
+
} catch {
|
|
407
|
+
return { ok: true, status: response.status, raw: text };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async deletePlannedActivity(plannedActivityId, usernameForReferer) {
|
|
412
|
+
const path = `/app/api/calendar/plannedactivities/${encodeURIComponent(plannedActivityId)}`;
|
|
413
|
+
const response = await this.#request(path, {
|
|
414
|
+
method: "DELETE",
|
|
415
|
+
headers: { referer: `${APP_URL}/calendar/${usernameForReferer}` },
|
|
416
|
+
});
|
|
417
|
+
const text = await response.text();
|
|
418
|
+
if (!response.ok) {
|
|
419
|
+
throw new HttpError(
|
|
420
|
+
`Request failed: ${response.status} ${response.statusText} for ${path} -> ${text}`,
|
|
421
|
+
{ status: response.status, statusText: response.statusText, path, payload: text },
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, status: response.status };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async getAnnotation(annotationId, usernameForReferer) {
|
|
428
|
+
return this.#requestJson(`/app/api/react-calendar/annotation/${encodeURIComponent(annotationId)}`, {
|
|
429
|
+
headers: {
|
|
430
|
+
"trainerroad-jsonformat": "camel-case",
|
|
431
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Body: { date: "YYYY-MM-DD", timeOfDay, duration (seconds, whole days), title, text, typeId, colorId }.
|
|
437
|
+
// Responds 204 with no body; the new id only shows up in the timeline afterwards.
|
|
438
|
+
async createAnnotation(annotation, usernameForReferer) {
|
|
439
|
+
const response = await this.#request("/app/api/calendar/annotations", {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers: {
|
|
442
|
+
"content-type": "application/json",
|
|
443
|
+
"trainerroad-jsonformat": "camel-case",
|
|
444
|
+
referer: `${APP_URL}/calendar/${usernameForReferer}`,
|
|
445
|
+
},
|
|
446
|
+
body: JSON.stringify(annotation),
|
|
447
|
+
});
|
|
448
|
+
const text = await response.text();
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
throw new HttpError(
|
|
451
|
+
`Request failed: ${response.status} ${response.statusText} for create annotation -> ${text}`,
|
|
452
|
+
{ status: response.status, statusText: response.statusText, path: "/app/api/calendar/annotations", payload: text },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return { ok: true, status: response.status };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async deleteAnnotation(annotationId, usernameForReferer) {
|
|
459
|
+
const path = `/app/api/calendar/annotations/${encodeURIComponent(annotationId)}`;
|
|
460
|
+
const response = await this.#request(path, {
|
|
461
|
+
method: "DELETE",
|
|
462
|
+
headers: { referer: `${APP_URL}/calendar/${usernameForReferer}` },
|
|
463
|
+
});
|
|
464
|
+
const text = await response.text();
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
throw new HttpError(
|
|
467
|
+
`Request failed: ${response.status} ${response.statusText} for ${path} -> ${text}`,
|
|
468
|
+
{ status: response.status, statusText: response.statusText, path, payload: text },
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return { ok: true, status: response.status };
|
|
472
|
+
}
|
|
473
|
+
|
|
274
474
|
async getWeightHistory(memberId, usernameForReferer) {
|
|
275
475
|
return this.#requestJson(`/app/api/weight-history/${memberId}/all`, {
|
|
276
476
|
headers: {
|
|
@@ -280,39 +480,42 @@ export class TrainerRoadClient {
|
|
|
280
480
|
});
|
|
281
481
|
}
|
|
282
482
|
|
|
283
|
-
async getAllUserPlans(
|
|
284
|
-
return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(
|
|
483
|
+
async getAllUserPlans(memberId, usernameForReferer) {
|
|
484
|
+
return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(memberId)}/all-user-plans`, {
|
|
285
485
|
headers: {
|
|
286
486
|
"trainerroad-jsonformat": "camel-case",
|
|
287
|
-
referer: `${APP_URL}/career/${
|
|
487
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
288
488
|
},
|
|
289
489
|
});
|
|
290
490
|
}
|
|
291
491
|
|
|
292
|
-
async getCurrentCustomPlan(
|
|
492
|
+
async getCurrentCustomPlan(memberId, usernameForReferer) {
|
|
293
493
|
return this.#requestJson(
|
|
294
|
-
`/app/api/plan-builder/current-custom-plan/${encodeURIComponent(
|
|
494
|
+
`/app/api/plan-builder/current-custom-plan/${encodeURIComponent(memberId)}`,
|
|
295
495
|
{
|
|
296
496
|
headers: {
|
|
297
497
|
"trainerroad-jsonformat": "camel-case",
|
|
298
|
-
referer: `${APP_URL}/career/${
|
|
498
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
299
499
|
},
|
|
300
500
|
},
|
|
301
501
|
);
|
|
302
502
|
}
|
|
303
503
|
|
|
304
|
-
async getPlanPhases(
|
|
305
|
-
return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(
|
|
504
|
+
async getPlanPhases(memberId, usernameForReferer) {
|
|
505
|
+
return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(memberId)}/plan-phases`, {
|
|
306
506
|
headers: {
|
|
307
507
|
"trainerroad-jsonformat": "camel-case",
|
|
308
|
-
referer: `${APP_URL}/career/${
|
|
508
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
309
509
|
},
|
|
310
510
|
});
|
|
311
511
|
}
|
|
312
512
|
|
|
313
|
-
async getCareerSummary(
|
|
314
|
-
return this.#requestJson(`/app/api/career/${
|
|
315
|
-
headers: {
|
|
513
|
+
async getCareerSummary(memberId, usernameForReferer) {
|
|
514
|
+
return this.#requestJson(`/app/api/career/${memberId}/new`, {
|
|
515
|
+
headers: {
|
|
516
|
+
"trainerroad-jsonformat": "camel-case",
|
|
517
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
518
|
+
},
|
|
316
519
|
});
|
|
317
520
|
}
|
|
318
521
|
|
|
@@ -397,18 +600,25 @@ export class TrainerRoadClient {
|
|
|
397
600
|
EndDate: endDate,
|
|
398
601
|
},
|
|
399
602
|
];
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
{
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
"trainerroad-jsonformat": "camel-case",
|
|
407
|
-
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
408
|
-
},
|
|
409
|
-
body: JSON.stringify(payload),
|
|
603
|
+
const options = {
|
|
604
|
+
method: "POST",
|
|
605
|
+
headers: {
|
|
606
|
+
"content-type": "application/json",
|
|
607
|
+
"trainerroad-jsonformat": "camel-case",
|
|
608
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
410
609
|
},
|
|
411
|
-
|
|
610
|
+
body: JSON.stringify(payload),
|
|
611
|
+
};
|
|
612
|
+
// The web app dropped the /for-date-range segment in 2026; keep the old path as a fallback.
|
|
613
|
+
try {
|
|
614
|
+
return await this.#requestJson(`/app/api/personal-records/${memberId}?${params.toString()}`, options);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (!isHttpStatus(error, 404)) throw error;
|
|
617
|
+
return this.#requestJson(
|
|
618
|
+
`/app/api/personal-records/for-date-range/${memberId}?${params.toString()}`,
|
|
619
|
+
options,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
412
622
|
}
|
|
413
623
|
|
|
414
624
|
async getTimeline(memberId, usernameForReferer) {
|