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.
@@ -9,14 +9,61 @@ const PROGRESSION_ZONE_META = {
9
9
  79: { zoneKey: "anaerobic", zoneLabel: "Anaerobic", sortOrder: 6 },
10
10
  };
11
11
 
12
- const ANNOTATION_TYPE_LABELS = {
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: "time-off",
16
+ 2: "illness",
15
17
  3: "injury",
16
- 4: "illness",
17
- 9: "plan-marker",
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 Error(
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/quinnsprouse",
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
- const loginPath = `/app/login?ReturnUrl=${encodeURIComponent(normalizedReturnPath)}`;
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("Could not locate __RequestVerificationToken on login page.");
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
- if (!this.jar.has("SharedTrainerRoadAuth")) {
246
- throw new Error("Login redirect succeeded, but SharedTrainerRoadAuth cookie is missing.");
247
- }
338
+ return this.#finishLogin(response.headers.get("location") ?? "");
339
+ }
248
340
 
249
- const location = response.headers.get("location") ?? "";
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: location,
347
+ lastLoginRedirect: redirect,
253
348
  });
254
-
255
349
  return {
256
350
  ok: true,
257
- redirect: location,
258
- hasAuthCookie: this.jar.has("SharedTrainerRoadAuth"),
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(usernameForPath) {
284
- return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(usernameForPath)}/all-user-plans`, {
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/${usernameForPath}`,
487
+ referer: `${APP_URL}/career/${usernameForReferer}`,
288
488
  },
289
489
  });
290
490
  }
291
491
 
292
- async getCurrentCustomPlan(usernameForPath) {
492
+ async getCurrentCustomPlan(memberId, usernameForReferer) {
293
493
  return this.#requestJson(
294
- `/app/api/plan-builder/current-custom-plan/${encodeURIComponent(usernameForPath)}`,
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/${usernameForPath}`,
498
+ referer: `${APP_URL}/career/${usernameForReferer}`,
299
499
  },
300
500
  },
301
501
  );
302
502
  }
303
503
 
304
- async getPlanPhases(usernameForPath) {
305
- return this.#requestJson(`/app/api/plan-builder/${encodeURIComponent(usernameForPath)}/plan-phases`, {
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/${usernameForPath}`,
508
+ referer: `${APP_URL}/career/${usernameForReferer}`,
309
509
  },
310
510
  });
311
511
  }
312
512
 
313
- async getCareerSummary(usernameForPath) {
314
- return this.#requestJson(`/app/api/career/${encodeURIComponent(usernameForPath)}/new`, {
315
- headers: { "trainerroad-jsonformat": "camel-case" },
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
- return this.#requestJson(
401
- `/app/api/personal-records/for-date-range/${memberId}?${params.toString()}`,
402
- {
403
- method: "POST",
404
- headers: {
405
- "content-type": "application/json",
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) {