trainerroad-cli 0.1.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/LICENSE +15 -0
- package/README.md +87 -0
- package/package.json +52 -0
- package/src/cli.mjs +677 -0
- package/src/commands/annotations.mjs +53 -0
- package/src/commands/auth.mjs +28 -0
- package/src/commands/discovery.mjs +165 -0
- package/src/commands/events.mjs +52 -0
- package/src/commands/ftp.mjs +210 -0
- package/src/commands/levels.mjs +58 -0
- package/src/commands/plan.mjs +81 -0
- package/src/commands/power.mjs +111 -0
- package/src/commands/timeline.mjs +74 -0
- package/src/commands/weight-history.mjs +51 -0
- package/src/commands/workouts.mjs +436 -0
- package/src/lib/agent-filters.mjs +287 -0
- package/src/lib/command-manifest.mjs +328 -0
- package/src/lib/planning-normalizers.mjs +193 -0
- package/src/trainerroad-client.mjs +465 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const PROGRESSION_ZONE_META = {
|
|
2
|
+
33: { zoneKey: "endurance", zoneLabel: "Endurance", sortOrder: 1 },
|
|
3
|
+
16: { zoneKey: "tempo", zoneLabel: "Tempo", sortOrder: 2 },
|
|
4
|
+
84: { zoneKey: "sweet-spot", zoneLabel: "Sweet Spot", sortOrder: 3 },
|
|
5
|
+
83: { zoneKey: "threshold", zoneLabel: "Threshold", sortOrder: 4 },
|
|
6
|
+
85: { zoneKey: "vo2-max", zoneLabel: "VO2 Max", sortOrder: 5 },
|
|
7
|
+
79: { zoneKey: "anaerobic", zoneLabel: "Anaerobic", sortOrder: 6 },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ANNOTATION_TYPE_LABELS = {
|
|
11
|
+
1: "note",
|
|
12
|
+
2: "time-off",
|
|
13
|
+
3: "injury",
|
|
14
|
+
4: "illness",
|
|
15
|
+
9: "plan-marker",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function toIsoDateFromPlanned(item) {
|
|
19
|
+
return `${String(item.date.year).padStart(4, "0")}-${String(item.date.month).padStart(2, "0")}-${String(item.date.day).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function toIsoDate(value) {
|
|
23
|
+
if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
|
|
24
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toIsoDateFromCalendarDate(dateValue) {
|
|
28
|
+
if (!dateValue) return null;
|
|
29
|
+
const wrapped = { date: dateValue };
|
|
30
|
+
try {
|
|
31
|
+
return toIsoDateFromPlanned(wrapped);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function compactEventRecord(record) {
|
|
38
|
+
return {
|
|
39
|
+
id: record?.id ?? null,
|
|
40
|
+
name: record?.name ?? null,
|
|
41
|
+
date: record?.date ?? null,
|
|
42
|
+
dateOnly: toIsoDateFromCalendarDate(record?.date),
|
|
43
|
+
timeOfDay: record?.timeOfDay ?? null,
|
|
44
|
+
started: record?.started ?? null,
|
|
45
|
+
racePriority: record?.racePriority ?? null,
|
|
46
|
+
activityType: record?.activityType ?? null,
|
|
47
|
+
activityEventType: record?.activityEventType ?? null,
|
|
48
|
+
tss: record?.tss ?? null,
|
|
49
|
+
activityTss: record?.activityTss ?? null,
|
|
50
|
+
isTriathlonType: record?.isTriathlonType ?? null,
|
|
51
|
+
manuallyCompleted: record?.manuallyCompleted ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function compactAnnotationRecord(record) {
|
|
56
|
+
const dateOnly = toIsoDateFromCalendarDate(record?.date);
|
|
57
|
+
const durationDays =
|
|
58
|
+
Number.isFinite(Number(record?.duration)) ? Math.round(Number(record.duration) / 86_400) : null;
|
|
59
|
+
const typeLabel = ANNOTATION_TYPE_LABELS[record?.typeId] ?? "unknown";
|
|
60
|
+
return {
|
|
61
|
+
id: record?.id ?? null,
|
|
62
|
+
type: typeLabel,
|
|
63
|
+
typeId: record?.typeId ?? null,
|
|
64
|
+
typeLabel,
|
|
65
|
+
recordType: typeLabel,
|
|
66
|
+
date: record?.date ?? null,
|
|
67
|
+
dateOnly,
|
|
68
|
+
durationSeconds: record?.duration ?? null,
|
|
69
|
+
durationDays,
|
|
70
|
+
groupId: record?.groupId ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function compactWeightRecord(record) {
|
|
75
|
+
return {
|
|
76
|
+
id: record?.id ?? null,
|
|
77
|
+
value: Number.isFinite(Number(record?.value)) ? Number(record.value) : null,
|
|
78
|
+
units: record?.units ?? null,
|
|
79
|
+
date: record?.date ?? null,
|
|
80
|
+
dateOnly: record?.date ? toIsoDate(record.date) : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function compactPlanSummary(plan) {
|
|
85
|
+
return {
|
|
86
|
+
id: plan?.id ?? null,
|
|
87
|
+
name: plan?.name ?? null,
|
|
88
|
+
discipline: plan?.discipline ?? null,
|
|
89
|
+
volume: plan?.volume ?? null,
|
|
90
|
+
phase: plan?.phase ?? null,
|
|
91
|
+
start: plan?.start ?? null,
|
|
92
|
+
end: plan?.end ?? null,
|
|
93
|
+
date: plan?.start ?? null,
|
|
94
|
+
dateOnly: plan?.start ? toIsoDate(plan.start) : null,
|
|
95
|
+
isAdHoc: plan?.isAdHoc ?? null,
|
|
96
|
+
plannedActivityGroupId: plan?.plannedActivityGroupId ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function compactPlanPhase(phase) {
|
|
101
|
+
return {
|
|
102
|
+
id: phase?.id ?? null,
|
|
103
|
+
customPlanId: phase?.customPlanId ?? null,
|
|
104
|
+
type: phase?.type ?? null,
|
|
105
|
+
volume: phase?.volume ?? null,
|
|
106
|
+
planId: phase?.planId ?? null,
|
|
107
|
+
planName: phase?.planName ?? null,
|
|
108
|
+
start: phase?.start ?? null,
|
|
109
|
+
end: phase?.end ?? null,
|
|
110
|
+
date: phase?.start ?? null,
|
|
111
|
+
dateOnly: phase?.start ? toIsoDate(phase.start) : null,
|
|
112
|
+
isMasters: phase?.isMasters ?? null,
|
|
113
|
+
isPolarized: phase?.isPolarized ?? null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function compactCurrentPlan(plan) {
|
|
118
|
+
if (!plan || typeof plan !== "object") return null;
|
|
119
|
+
return {
|
|
120
|
+
id: plan.id ?? null,
|
|
121
|
+
name: plan.name ?? null,
|
|
122
|
+
memberId: plan.memberId ?? null,
|
|
123
|
+
discipline: plan.discipline ?? null,
|
|
124
|
+
volume: plan.volume ?? null,
|
|
125
|
+
start: plan.start ?? null,
|
|
126
|
+
end: plan.end ?? null,
|
|
127
|
+
date: plan.start ?? null,
|
|
128
|
+
dateOnly: plan.start ? toIsoDate(plan.start) : null,
|
|
129
|
+
canEdit: plan.canEdit ?? null,
|
|
130
|
+
currentPhase: plan.currentPhase ?? null,
|
|
131
|
+
currentPhaseStart: plan.currentPhaseStart ?? null,
|
|
132
|
+
currentPhaseEnd: plan.currentPhaseEnd ?? null,
|
|
133
|
+
plannedActivityGroupType: plan.plannedActivityGroupType ?? null,
|
|
134
|
+
autoUpdateApplied: plan.autoUpdateApplied ?? null,
|
|
135
|
+
phaseCount: Array.isArray(plan.phases) ? plan.phases.length : 0,
|
|
136
|
+
phases: Array.isArray(plan.phases) ? plan.phases.map((phase) => compactPlanPhase(phase)) : [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildLevelsByZone(levelsPayload, aiEligibilityPayload = null) {
|
|
141
|
+
const rawLevels = levelsPayload?.levels ?? {};
|
|
142
|
+
const detection = aiEligibilityPayload?.additionalData?.detection ?? {};
|
|
143
|
+
const aiProjected = new Map(
|
|
144
|
+
(Array.isArray(detection.projectedProgressionLevels) ? detection.projectedProgressionLevels : []).map(
|
|
145
|
+
(item) => [Number(item.progressionId), item],
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
const aiCurrent = new Map(
|
|
149
|
+
(Array.isArray(detection.currentProgressionLevels) ? detection.currentProgressionLevels : []).map(
|
|
150
|
+
(item) => [Number(item.progressionId), item],
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const records = Object.entries(rawLevels).map(([progressionIdRaw, value]) => {
|
|
155
|
+
const progressionId = Number(progressionIdRaw);
|
|
156
|
+
const zoneMeta = PROGRESSION_ZONE_META[progressionId] ?? {
|
|
157
|
+
zoneKey: `progression-${progressionId}`,
|
|
158
|
+
zoneLabel: `Progression ${progressionId}`,
|
|
159
|
+
sortOrder: 1000 + progressionId,
|
|
160
|
+
};
|
|
161
|
+
const aiProjectedRecord = aiProjected.get(progressionId) ?? null;
|
|
162
|
+
const aiCurrentRecord = aiCurrent.get(progressionId) ?? null;
|
|
163
|
+
return {
|
|
164
|
+
progressionId,
|
|
165
|
+
type: zoneMeta.zoneKey,
|
|
166
|
+
recordType: zoneMeta.zoneKey,
|
|
167
|
+
zoneKey: zoneMeta.zoneKey,
|
|
168
|
+
zoneLabel: zoneMeta.zoneLabel,
|
|
169
|
+
sortOrder: zoneMeta.sortOrder,
|
|
170
|
+
recentLevel: value?.recent ?? null,
|
|
171
|
+
endpointPredictedLevel: value?.predicted ?? null,
|
|
172
|
+
activityId: value?.activityId ?? null,
|
|
173
|
+
changeDate: value?.changeEvent?.date ?? null,
|
|
174
|
+
date: value?.changeEvent?.date ?? null,
|
|
175
|
+
dateOnly: value?.changeEvent?.date ? toIsoDate(value.changeEvent.date) : null,
|
|
176
|
+
changeReason: value?.changeEvent?.reason ?? null,
|
|
177
|
+
changeFrom: value?.changeEvent?.level?.from ?? null,
|
|
178
|
+
changeTo: value?.changeEvent?.level?.to ?? null,
|
|
179
|
+
changeDelta: value?.changeEvent?.delta ?? null,
|
|
180
|
+
aiCurrentDisplayLevel: aiCurrentRecord?.previousDisplayLevel ?? null,
|
|
181
|
+
aiProjectedDisplayLevel: aiProjectedRecord?.displayFinalLevel ?? null,
|
|
182
|
+
aiDelta:
|
|
183
|
+
aiCurrentRecord && aiProjectedRecord
|
|
184
|
+
? aiProjectedRecord.displayFinalLevel - aiCurrentRecord.previousDisplayLevel
|
|
185
|
+
: null,
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return records.sort((a, b) => {
|
|
190
|
+
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
|
191
|
+
return a.progressionId - b.progressionId;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://www.trainerroad.com";
|
|
5
|
+
const APP_URL = `${BASE_URL}/app`;
|
|
6
|
+
const DEFAULT_USER_AGENT =
|
|
7
|
+
"trainerroad-cli/0.1 (unofficial; personal data export; +https://www.trainerroad.com)";
|
|
8
|
+
|
|
9
|
+
function ensureLeadingSlash(value) {
|
|
10
|
+
if (!value.startsWith("/")) return `/${value}`;
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toIsoDateOnly(value) {
|
|
15
|
+
if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
|
|
16
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function plannedDateToIso(item) {
|
|
20
|
+
const year = String(item.date?.year ?? "").padStart(4, "0");
|
|
21
|
+
const month = String(item.date?.month ?? "").padStart(2, "0");
|
|
22
|
+
const day = String(item.date?.day ?? "").padStart(2, "0");
|
|
23
|
+
return `${year}-${month}-${day}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function chunk(values, size) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size));
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function filterFuturePlanned(plannedActivities, fromDateIso, toDateIso = null) {
|
|
33
|
+
return plannedActivities.filter((item) => {
|
|
34
|
+
const date = plannedDateToIso(item);
|
|
35
|
+
if (date < fromDateIso) return false;
|
|
36
|
+
if (toDateIso && date > toDateIso) return false;
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function filterPastActivities(activities, fromDateIso = null, toDateIso = null) {
|
|
42
|
+
return activities
|
|
43
|
+
.filter((item) => {
|
|
44
|
+
const startedDate = toIsoDateOnly(item.started);
|
|
45
|
+
if (fromDateIso && startedDate < fromDateIso) return false;
|
|
46
|
+
if (toDateIso && startedDate > toDateIso) return false;
|
|
47
|
+
return true;
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => new Date(b.started).getTime() - new Date(a.started).getTime());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class CookieJar {
|
|
53
|
+
constructor(raw = {}) {
|
|
54
|
+
this.cookies = new Map(Object.entries(raw));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static fromJson(value) {
|
|
58
|
+
return new CookieJar(value ?? {});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
toJson() {
|
|
62
|
+
return Object.fromEntries(this.cookies.entries());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get(name) {
|
|
66
|
+
return this.cookies.get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
has(name) {
|
|
70
|
+
return this.cookies.has(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cookieHeader() {
|
|
74
|
+
return Array.from(this.cookies.entries())
|
|
75
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
76
|
+
.join("; ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
applySetCookies(setCookieHeaders) {
|
|
80
|
+
for (const setCookie of setCookieHeaders) {
|
|
81
|
+
const firstSegment = setCookie.split(";")[0];
|
|
82
|
+
const separator = firstSegment.indexOf("=");
|
|
83
|
+
if (separator <= 0) continue;
|
|
84
|
+
const name = firstSegment.slice(0, separator).trim();
|
|
85
|
+
const value = firstSegment.slice(separator + 1).trim();
|
|
86
|
+
if (!name) continue;
|
|
87
|
+
this.cookies.set(name, value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class TrainerRoadClient {
|
|
93
|
+
constructor({
|
|
94
|
+
username = null,
|
|
95
|
+
password = null,
|
|
96
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
97
|
+
sessionFile = path.resolve(".trainerroad", "session.json"),
|
|
98
|
+
} = {}) {
|
|
99
|
+
this.username = username;
|
|
100
|
+
this.password = password;
|
|
101
|
+
this.userAgent = userAgent;
|
|
102
|
+
this.sessionFile = sessionFile;
|
|
103
|
+
this.jar = new CookieJar();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async loadSession() {
|
|
107
|
+
try {
|
|
108
|
+
const raw = await fs.readFile(this.sessionFile, "utf8");
|
|
109
|
+
const parsed = JSON.parse(raw);
|
|
110
|
+
this.jar = CookieJar.fromJson(parsed.cookies ?? {});
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async saveSession(extra = {}) {
|
|
118
|
+
const dir = path.dirname(this.sessionFile);
|
|
119
|
+
await fs.mkdir(dir, { recursive: true });
|
|
120
|
+
const payload = {
|
|
121
|
+
cookies: this.jar.toJson(),
|
|
122
|
+
updatedAt: new Date().toISOString(),
|
|
123
|
+
...extra,
|
|
124
|
+
};
|
|
125
|
+
await fs.writeFile(this.sessionFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async clearSession() {
|
|
129
|
+
this.jar = new CookieJar();
|
|
130
|
+
try {
|
|
131
|
+
await fs.unlink(this.sessionFile);
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore if no session file exists.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async #request(urlOrPath, options = {}) {
|
|
138
|
+
const url = urlOrPath.startsWith("http") ? urlOrPath : `${BASE_URL}${urlOrPath}`;
|
|
139
|
+
const headers = new Headers(options.headers ?? {});
|
|
140
|
+
headers.set("user-agent", this.userAgent);
|
|
141
|
+
if (!headers.has("accept")) headers.set("accept", "application/json, text/plain, */*");
|
|
142
|
+
const cookieHeader = this.jar.cookieHeader();
|
|
143
|
+
if (cookieHeader) headers.set("cookie", cookieHeader);
|
|
144
|
+
|
|
145
|
+
const response = await fetch(url, {
|
|
146
|
+
method: options.method ?? "GET",
|
|
147
|
+
headers,
|
|
148
|
+
body: options.body ?? null,
|
|
149
|
+
redirect: options.redirect ?? "follow",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
|
|
153
|
+
this.jar.applySetCookies(setCookieHeaders);
|
|
154
|
+
return response;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async #requestJson(urlOrPath, options = {}) {
|
|
158
|
+
const response = await this.#request(urlOrPath, options);
|
|
159
|
+
const text = await response.text();
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = JSON.parse(text);
|
|
163
|
+
} catch {
|
|
164
|
+
payload = text;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const detail =
|
|
169
|
+
typeof payload === "object" && payload !== null
|
|
170
|
+
? JSON.stringify(payload)
|
|
171
|
+
: String(payload);
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Request failed: ${response.status} ${response.statusText} for ${urlOrPath} -> ${detail}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return payload;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async login({
|
|
180
|
+
username = this.username,
|
|
181
|
+
password = this.password,
|
|
182
|
+
returnPath = "/app/career/quinnsprouse",
|
|
183
|
+
} = {}) {
|
|
184
|
+
if (!username || !password) {
|
|
185
|
+
throw new Error("Username and password are required for login.");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const normalizedReturnPath = ensureLeadingSlash(returnPath);
|
|
189
|
+
const loginPath = `/app/login?ReturnUrl=${encodeURIComponent(normalizedReturnPath)}`;
|
|
190
|
+
|
|
191
|
+
const loginPage = await this.#request(loginPath, {
|
|
192
|
+
method: "GET",
|
|
193
|
+
headers: { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
|
|
194
|
+
redirect: "manual",
|
|
195
|
+
});
|
|
196
|
+
const html = await loginPage.text();
|
|
197
|
+
|
|
198
|
+
const tokenMatch = html.match(
|
|
199
|
+
/name="__RequestVerificationToken"\s+type="hidden"\s+value="([^"]+)"/i,
|
|
200
|
+
);
|
|
201
|
+
const returnUrlMatch = html.match(/id="ReturnUrl"\s+name="ReturnUrl"\s+type="hidden"\s+value="([^"]+)"/i);
|
|
202
|
+
|
|
203
|
+
if (!tokenMatch) {
|
|
204
|
+
throw new Error("Could not locate __RequestVerificationToken on login page.");
|
|
205
|
+
}
|
|
206
|
+
if (!returnUrlMatch) {
|
|
207
|
+
throw new Error("Could not locate ReturnUrl hidden input on login page.");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const form = new URLSearchParams({
|
|
211
|
+
Username: username,
|
|
212
|
+
Password: password,
|
|
213
|
+
ReturnUrl: returnUrlMatch[1],
|
|
214
|
+
__RequestVerificationToken: tokenMatch[1],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const response = await this.#request("/app/login", {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {
|
|
220
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
221
|
+
origin: BASE_URL,
|
|
222
|
+
referer: `${BASE_URL}${loginPath}`,
|
|
223
|
+
},
|
|
224
|
+
body: form.toString(),
|
|
225
|
+
redirect: "manual",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!(response.status >= 300 && response.status < 400)) {
|
|
229
|
+
const body = await response.text();
|
|
230
|
+
throw new Error(`Login did not redirect. Status=${response.status}. Body preview=${body.slice(0, 300)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!this.jar.has("SharedTrainerRoadAuth")) {
|
|
234
|
+
throw new Error("Login redirect succeeded, but SharedTrainerRoadAuth cookie is missing.");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const location = response.headers.get("location") ?? "";
|
|
238
|
+
await this.saveSession({
|
|
239
|
+
authenticatedAt: new Date().toISOString(),
|
|
240
|
+
lastLoginRedirect: location,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
redirect: location,
|
|
246
|
+
hasAuthCookie: this.jar.has("SharedTrainerRoadAuth"),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async getMemberInfo() {
|
|
251
|
+
return this.#requestJson("/app/api/member-info", {
|
|
252
|
+
headers: { "trainerroad-jsonformat": "camel-case" },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async getPublicTssByUsername(username) {
|
|
257
|
+
return this.#requestJson(`/app/api/tss/${encodeURIComponent(username)}`, {
|
|
258
|
+
headers: { "trainerroad-jsonformat": "camel-case" },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getWeightHistory(memberId, usernameForReferer) {
|
|
263
|
+
return this.#requestJson(`/app/api/weight-history/${memberId}/all`, {
|
|
264
|
+
headers: {
|
|
265
|
+
"trainerroad-jsonformat": "camel-case",
|
|
266
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async getAllUserPlans(usernameForPath) {
|
|
272
|
+
return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(usernameForPath)}/all-user-plans`, {
|
|
273
|
+
headers: {
|
|
274
|
+
"trainerroad-jsonformat": "camel-case",
|
|
275
|
+
referer: `${APP_URL}/career/${usernameForPath}`,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async getCurrentCustomPlan(usernameForPath) {
|
|
281
|
+
return this.#requestJson(
|
|
282
|
+
`/app/api/plan-builder/current-custom-plan/${encodeURIComponent(usernameForPath)}`,
|
|
283
|
+
{
|
|
284
|
+
headers: {
|
|
285
|
+
"trainerroad-jsonformat": "camel-case",
|
|
286
|
+
referer: `${APP_URL}/career/${usernameForPath}`,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async getPlanPhases(usernameForPath) {
|
|
293
|
+
return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(usernameForPath)}/plan-phases`, {
|
|
294
|
+
headers: {
|
|
295
|
+
"trainerroad-jsonformat": "camel-case",
|
|
296
|
+
referer: `${APP_URL}/career/${usernameForPath}`,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async getCareerSummary(usernameForPath) {
|
|
302
|
+
return this.#requestJson(`/app/api/career/${encodeURIComponent(usernameForPath)}/new`, {
|
|
303
|
+
headers: { "trainerroad-jsonformat": "camel-case" },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async getCareerLevels(memberId, usernameForReferer) {
|
|
308
|
+
return this.#requestJson(`/app/api/career/${memberId}/levels`, {
|
|
309
|
+
headers: {
|
|
310
|
+
"trainerroad-jsonformat": "camel-case",
|
|
311
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async getAiFtpEligibility(memberId, usernameForReferer) {
|
|
317
|
+
return this.#requestJson(`/app/api/ai-ftp-detection/can-use-ai-ftp/${memberId}`, {
|
|
318
|
+
headers: {
|
|
319
|
+
"trainerroad-jsonformat": "camel-case",
|
|
320
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async getAiFtpFailureStatus(memberId, usernameForReferer) {
|
|
326
|
+
return this.#requestJson(`/app/api/calendar/aiftp/${memberId}/ai-failure-status`, {
|
|
327
|
+
headers: {
|
|
328
|
+
"trainerroad-jsonformat": "camel-case",
|
|
329
|
+
"tr-cache-control": "use-cache",
|
|
330
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async getPowerRanking(memberId, usernameForReferer) {
|
|
336
|
+
const params = new URLSearchParams({ memberId: String(memberId) });
|
|
337
|
+
return this.#requestJson(`/app/api/onboarding/power-ranking?${params.toString()}`, {
|
|
338
|
+
headers: {
|
|
339
|
+
"trainerroad-jsonformat": "camel-case",
|
|
340
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async getOnboardingPersonalRecords({ startTimeIso = null, endTimeIso = null, usernameForReferer } = {}) {
|
|
346
|
+
const params = new URLSearchParams();
|
|
347
|
+
if (startTimeIso) params.set("startTime", startTimeIso);
|
|
348
|
+
if (endTimeIso) params.set("endTime", endTimeIso);
|
|
349
|
+
const query = params.toString();
|
|
350
|
+
const pathWithQuery = query ? `/app/api/onboarding/personal-records?${query}` : "/app/api/onboarding/personal-records";
|
|
351
|
+
return this.#requestJson(pathWithQuery, {
|
|
352
|
+
headers: {
|
|
353
|
+
"trainerroad-jsonformat": "camel-case",
|
|
354
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async getSeasons(memberId, usernameForReferer) {
|
|
360
|
+
return this.#requestJson(`/app/api/seasons/${memberId}`, {
|
|
361
|
+
headers: {
|
|
362
|
+
"trainerroad-jsonformat": "camel-case",
|
|
363
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async getPersonalRecordsForDateRange(
|
|
369
|
+
memberId,
|
|
370
|
+
usernameForReferer,
|
|
371
|
+
{ startDate, endDate, rowType = 101, indoorOnly = false, slot = 1 } = {},
|
|
372
|
+
) {
|
|
373
|
+
if (!startDate || !endDate) {
|
|
374
|
+
throw new Error("startDate and endDate are required (YYYY-MM-DD) for personal record date-range queries.");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const params = new URLSearchParams({
|
|
378
|
+
rowType: String(rowType),
|
|
379
|
+
indoorOnly: String(Boolean(indoorOnly)),
|
|
380
|
+
});
|
|
381
|
+
const payload = [
|
|
382
|
+
{
|
|
383
|
+
Slot: Number.isFinite(Number(slot)) ? Number(slot) : 1,
|
|
384
|
+
StartDate: startDate,
|
|
385
|
+
EndDate: endDate,
|
|
386
|
+
},
|
|
387
|
+
];
|
|
388
|
+
return this.#requestJson(
|
|
389
|
+
`/app/api/personal-records/for-date-range/${memberId}?${params.toString()}`,
|
|
390
|
+
{
|
|
391
|
+
method: "POST",
|
|
392
|
+
headers: {
|
|
393
|
+
"content-type": "application/json",
|
|
394
|
+
"trainerroad-jsonformat": "camel-case",
|
|
395
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
396
|
+
},
|
|
397
|
+
body: JSON.stringify(payload),
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async getTimeline(memberId, usernameForReferer) {
|
|
403
|
+
return this.#requestJson(`/app/api/react-calendar/${memberId}/timeline`, {
|
|
404
|
+
headers: {
|
|
405
|
+
"trainerroad-jsonformat": "camel-case",
|
|
406
|
+
"tr-cache-control": "use-cache",
|
|
407
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async getActivitiesByIds(memberId, usernameForReferer, activityIds) {
|
|
413
|
+
if (activityIds.length === 0) return [];
|
|
414
|
+
const batches = chunk(activityIds, 100);
|
|
415
|
+
const results = [];
|
|
416
|
+
for (const batch of batches) {
|
|
417
|
+
const payload = await this.#requestJson(`/app/api/react-calendar/${memberId}/activities`, {
|
|
418
|
+
headers: {
|
|
419
|
+
"trainerroad-jsonformat": "camel-case",
|
|
420
|
+
"tr-cache-control": "use-cache",
|
|
421
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
422
|
+
ids: batch.join(","),
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
results.push(...payload);
|
|
426
|
+
}
|
|
427
|
+
return results;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async getPlannedActivitiesByIds(memberId, usernameForReferer, plannedIds) {
|
|
431
|
+
if (plannedIds.length === 0) return [];
|
|
432
|
+
const batches = chunk(plannedIds, 100);
|
|
433
|
+
const results = [];
|
|
434
|
+
for (const batch of batches) {
|
|
435
|
+
const payload = await this.#requestJson(`/app/api/react-calendar/${memberId}/planned-activities`, {
|
|
436
|
+
headers: {
|
|
437
|
+
"trainerroad-jsonformat": "camel-case",
|
|
438
|
+
"tr-cache-control": "use-cache",
|
|
439
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
440
|
+
ids: batch.join(","),
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
results.push(...payload);
|
|
444
|
+
}
|
|
445
|
+
return results;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async getPersonalRecordsByActivityIds(memberId, usernameForReferer, activityIds) {
|
|
449
|
+
if (activityIds.length === 0) return {};
|
|
450
|
+
const batches = chunk(activityIds, 100);
|
|
451
|
+
const merged = {};
|
|
452
|
+
for (const batch of batches) {
|
|
453
|
+
const payload = await this.#requestJson(`/app/api/react-calendar/${memberId}/personal-records`, {
|
|
454
|
+
headers: {
|
|
455
|
+
"trainerroad-jsonformat": "camel-case",
|
|
456
|
+
"tr-cache-control": "use-cache",
|
|
457
|
+
referer: `${APP_URL}/career/${usernameForReferer}`,
|
|
458
|
+
ids: batch.join(","),
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
Object.assign(merged, payload);
|
|
462
|
+
}
|
|
463
|
+
return merged;
|
|
464
|
+
}
|
|
465
|
+
}
|