@trainheroic-unofficial/cli 0.3.0 → 0.4.1

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/dist/cli.mjs CHANGED
@@ -1,14 +1,175 @@
1
1
  #!/usr/bin/env node
2
- import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
  import process from "node:process";
5
6
  import { parseArgs } from "node:util";
6
7
  import { z } from "zod";
7
- import { homedir } from "node:os";
8
8
  //#region ../dto/src/common.ts
9
9
  /** An entity id as it arrives over the wire — a number or a numeric string. */
10
10
  const idSchema = z.union([z.string(), z.number()]);
11
- z.union([z.number().int(), z.string().regex(/^\d+$/u)]);
11
+ /**
12
+ * An id as a tool argument: a number, or a string of digits. Unlike idSchema (which
13
+ * tolerates whatever the API sends back), this rejects non-numeric strings up front so
14
+ * a bad argument fails validation instead of becoming NaN in a URL or a query.
15
+ */
16
+ const idArgSchema = z.union([z.number().int(), z.string().regex(/^\d+$/u)]);
17
+ //#endregion
18
+ //#region ../dto/src/athlete.ts
19
+ const intLike$1 = z.union([z.number(), z.string()]);
20
+ const intLikeOrNull$1 = z.union([
21
+ z.number(),
22
+ z.string(),
23
+ z.null()
24
+ ]);
25
+ const numLikeOrNull = z.union([
26
+ z.number(),
27
+ z.string(),
28
+ z.null()
29
+ ]);
30
+ z.looseObject({
31
+ id: intLike$1,
32
+ roles: z.array(z.string()).optional(),
33
+ org_id: intLikeOrNull$1.optional()
34
+ });
35
+ z.looseObject({
36
+ reps_sum: z.number().optional(),
37
+ volume_sum: z.number().optional(),
38
+ sessions_count: z.number().optional(),
39
+ first_logged_date: z.string().optional(),
40
+ last_logged_date: z.string().optional(),
41
+ duration_hours: z.number().optional()
42
+ });
43
+ z.looseObject({
44
+ id: intLike$1,
45
+ email: z.string().optional(),
46
+ name_first: z.string().optional(),
47
+ name_last: z.string().optional(),
48
+ username: z.string().optional(),
49
+ gender: z.string().optional(),
50
+ date_of_birth: z.string().optional(),
51
+ use_metric: z.boolean().optional()
52
+ });
53
+ z.looseObject({ id: intLike$1 });
54
+ /** One item of `/2.0/athlete/workingMax` — the athlete's working max for an exercise. */
55
+ const athleteWorkingMaxSchema = z.looseObject({
56
+ exercise_id: intLike$1,
57
+ title: z.string().optional(),
58
+ param_type: intLikeOrNull$1.optional(),
59
+ value: numLikeOrNull.optional(),
60
+ type_suffix: z.string().optional(),
61
+ working_max_id: intLikeOrNull$1.optional()
62
+ });
63
+ z.array(athleteWorkingMaxSchema);
64
+ /** One item of `/v5/users/exercises/history` — an exercise the athlete has logged. */
65
+ const exerciseHistoryListItemSchema = z.looseObject({
66
+ id: intLike$1,
67
+ title: z.string(),
68
+ isCircuit: z.boolean().optional(),
69
+ prescription: z.string().optional(),
70
+ param1Type: intLikeOrNull$1.optional(),
71
+ param2Type: intLikeOrNull$1.optional()
72
+ });
73
+ z.array(exerciseHistoryListItemSchema);
74
+ /** A single completed set inside a history entry (`/v5/exercises/{id}/history`). */
75
+ const historySetSchema = z.looseObject({
76
+ setNumber: z.number(),
77
+ formattedValue: z.string().optional(),
78
+ rawValue1: numLikeOrNull.optional(),
79
+ rawValue2: numLikeOrNull.optional(),
80
+ savedWorkoutSetExerciseId: intLike$1.optional()
81
+ });
82
+ /** A best rep-max derived for a history entry. */
83
+ const repMaxSchema = z.looseObject({
84
+ reps: z.number(),
85
+ weight: z.number()
86
+ });
87
+ /** One performed session of an exercise (`/v5/exercises/{id}/history` → `history[]`). */
88
+ const historyEntrySchema = z.looseObject({
89
+ dateCompleted: z.string(),
90
+ notes: z.string().nullable().optional(),
91
+ isLift: z.boolean().optional(),
92
+ param1Type: intLikeOrNull$1.optional(),
93
+ param2Type: intLikeOrNull$1.optional(),
94
+ savedWorkoutSetExerciseId: intLike$1.optional(),
95
+ teamId: intLikeOrNull$1.optional(),
96
+ programWorkoutId: intLikeOrNull$1.optional(),
97
+ abr: z.string().optional(),
98
+ bestEstimated1RM: z.number().optional(),
99
+ repMaxes: z.array(repMaxSchema).optional(),
100
+ sets: z.array(historySetSchema).optional()
101
+ });
102
+ /** A lifetime PR row from `/v5/exercises/{id}/history` → `liftPRs[]`. */
103
+ const liftPRSchema = z.looseObject({
104
+ weight: z.number().optional(),
105
+ savedWorkoutSetExerciseId: intLike$1.optional(),
106
+ setNumber: z.number().optional(),
107
+ dateCompleted: z.string().optional(),
108
+ reps: z.number().optional(),
109
+ units: z.string().optional(),
110
+ isMetric: z.boolean().optional(),
111
+ description: z.string().optional()
112
+ });
113
+ z.looseObject({
114
+ liftPRs: z.array(liftPRSchema).optional(),
115
+ singleParamPRs: z.array(z.unknown()).optional(),
116
+ history: z.array(historyEntrySchema).optional()
117
+ });
118
+ /** One item of `/v5/exercises/{id}/personalRecords` — a standards-filtered PR. */
119
+ const personalRecordSchema = z.looseObject({
120
+ id: intLike$1.optional(),
121
+ savedWorkoutSetExerciseId: intLike$1.optional(),
122
+ setNumber: z.number().optional(),
123
+ reps: z.number().optional(),
124
+ weight: z.number().optional(),
125
+ scaledWeight: z.number().optional(),
126
+ units: z.string().optional(),
127
+ isMetric: z.boolean().optional()
128
+ });
129
+ z.array(personalRecordSchema);
130
+ z.looseObject({
131
+ isLift: z.boolean().optional(),
132
+ lastPerformance: z.unknown().optional(),
133
+ personalRecord: z.unknown().optional()
134
+ });
135
+ /**
136
+ * One item of `/3.0/athlete/programworkout/range` — a scheduled/completed workout. The deep
137
+ * `summarizedSavedWorkout` tree is left loose: the presenter in `js` flattens it, so dto only
138
+ * pins the top-level fields the warehouse and presenter key off.
139
+ */
140
+ const programWorkoutSchema = z.looseObject({
141
+ id: intLike$1,
142
+ date: z.string().optional(),
143
+ workout_title: z.string().optional(),
144
+ program_id: intLikeOrNull$1.optional(),
145
+ program_title: z.string().optional(),
146
+ team_id: intLikeOrNull$1.optional(),
147
+ team_title: z.string().optional(),
148
+ summarizedSavedWorkout: z.unknown().optional()
149
+ });
150
+ z.array(programWorkoutSchema);
151
+ /** A `YYYY-MM-DD` date argument. The single definition reused across athlete tool inputs. */
152
+ const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "expected YYYY-MM-DD");
153
+ z.object({
154
+ startDate: dateString,
155
+ endDate: dateString
156
+ });
157
+ /**
158
+ * Args for the set-logging write. `date` (the workout's day) locates the saved
159
+ * workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
160
+ * gives, per exercise in it, the entered value of each set (param 1 / param 2 by entry slot).
161
+ */
162
+ const logSetArgsSchema = z.object({
163
+ date: dateString,
164
+ savedWorkoutSetId: idArgSchema,
165
+ results: z.array(z.object({
166
+ savedWorkoutSetExerciseId: idArgSchema,
167
+ sets: z.array(z.object({
168
+ param1: z.union([z.number(), z.string()]).optional(),
169
+ param2: z.union([z.number(), z.string()]).optional()
170
+ })).min(1)
171
+ })).min(1)
172
+ });
12
173
  //#endregion
13
174
  //#region ../dto/src/exercise.ts
14
175
  /** Body for creating a custom exercise; extra fields the API accepts are preserved. */
@@ -290,7 +451,7 @@ function withUnits(row) {
290
451
  * Present a full raw exercise object for display: drop the raw param-type codes and add the
291
452
  * positional `units` array. Keeps every other field of the raw object intact.
292
453
  */
293
- function presentExercise(raw) {
454
+ function presentExercise$1(raw) {
294
455
  const { param_1_type, param_2_type, ...rest } = raw;
295
456
  return {
296
457
  ...rest,
@@ -356,13 +517,314 @@ function rankSearch(rows, query, limit) {
356
517
  if (title.startsWith(q)) score += 100;
357
518
  for (const tok of tokens) if (title.includes(tok)) score += 10;
358
519
  score -= title.length * .05;
359
- if (row.can_edit === 0) score += 1;
520
+ if ((row.can_edit ?? 0) === 0) score += 1;
360
521
  return {
361
522
  row,
362
523
  score
363
524
  };
364
525
  }).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
365
526
  }
527
+ /**
528
+ * Map over items with a bounded number of concurrent workers. Used to fan out upstream
529
+ * fetches (per-exercise history, the CLI export) without bursting the host all at once or,
530
+ * on workerd, blowing the subrequest budget.
531
+ */
532
+ async function mapPool(items, limit, fn) {
533
+ const out = Array.from({ length: items.length });
534
+ let next = 0;
535
+ const worker = async () => {
536
+ while (next < items.length) {
537
+ const i = next;
538
+ next += 1;
539
+ out[i] = await fn(items[i], i);
540
+ }
541
+ };
542
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
543
+ return out;
544
+ }
545
+ //#endregion
546
+ //#region ../js/src/athlete.ts
547
+ async function getJson(client, path, label) {
548
+ const res = await client.request("GET", path);
549
+ if (!res.ok) throw new Error(`${label} failed (HTTP ${res.status}).`);
550
+ return res.data;
551
+ }
552
+ async function getArray(client, path, label) {
553
+ const res = await client.request("GET", path);
554
+ if (!res.ok || !Array.isArray(res.data)) throw new Error(`${label} failed (HTTP ${res.status}).`);
555
+ return res.data;
556
+ }
557
+ /**
558
+ * The logged-in account's numeric user id (the athlete warehouse's tenant key, and a required
559
+ * query arg for several athlete endpoints). Works from any session — coach or athlete — and
560
+ * even from a cached session that never went through login.
561
+ */
562
+ async function resolveAthleteUserId(client) {
563
+ const res = await client.request("GET", "/user/simple");
564
+ const id = isRecord(res.data) ? coerceInt(res.data.id) : null;
565
+ if (!res.ok || id === null || id <= 0) throw new Error("Could not resolve athlete user id from /user/simple.");
566
+ return id;
567
+ }
568
+ /** Lifetime training totals. `use_metric` is required by the API (omitting it 400s). */
569
+ function fetchAthleteProfileSummary(client, userId, useMetric = false) {
570
+ return getJson(client, `/v5/athleteProfile/summary?user_id=${userId}&use_metric=${useMetric ? 1 : 0}`, "athlete profile summary");
571
+ }
572
+ function fetchAthleteUser(client, userId) {
573
+ return getJson(client, `/v5/users/${userId}`, "athlete user");
574
+ }
575
+ function fetchAthletePrefs(client) {
576
+ return getJson(client, "/1.0/athlete/prefs", "athlete prefs");
577
+ }
578
+ function fetchWorkingMaxes(client) {
579
+ return getArray(client, "/2.0/athlete/workingMax", "athlete working maxes");
580
+ }
581
+ function fetchExerciseHistoryList(client) {
582
+ return getArray(client, "/v5/users/exercises/history", "athlete exercise history list");
583
+ }
584
+ /** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
585
+ async function searchExerciseHistory(client, query, limit = 20) {
586
+ return rankSearch(await fetchExerciseHistoryList(client), query, limit);
587
+ }
588
+ function fetchExerciseHistoryDetail(client, exerciseId, userId) {
589
+ return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
590
+ }
591
+ function fetchPersonalRecords(client, exerciseId) {
592
+ return getArray(client, `/v5/exercises/${exerciseId}/personalRecords`, "athlete personal records");
593
+ }
594
+ /** Last performance + PR for an exercise. `date` (YYYY-MM-DD) is required by the API. */
595
+ function fetchExerciseStats(client, exerciseId, userId, date) {
596
+ return getJson(client, `/v5/exercises/${exerciseId}/stats?userId=${userId}&date=${date}`, "athlete exercise stats");
597
+ }
598
+ /** Scheduled + completed workouts in an inclusive YYYY-MM-DD window. */
599
+ function fetchAthleteWorkouts(client, startDate, endDate) {
600
+ return getArray(client, `/3.0/athlete/programworkout/range?startDate=${startDate}&endDate=${endDate}`, "athlete workouts");
601
+ }
602
+ function fetchLeaderboard(client, workoutId, opts = {}) {
603
+ const qs = new URLSearchParams();
604
+ if (opts.page !== void 0) qs.set("page", String(opts.page));
605
+ if (opts.pageSize !== void 0) qs.set("pageSize", String(opts.pageSize));
606
+ if (opts.gender !== void 0) qs.set("gender", String(opts.gender));
607
+ const query = qs.toString();
608
+ return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
609
+ }
610
+ const SLOTS = 10;
611
+ function nonEmpty(value) {
612
+ return value !== void 0 && value !== null && String(value).trim() !== "";
613
+ }
614
+ /**
615
+ * Per-set prescriptions from the param_N_data slots, e.g. ["5 @ 225", "3 @ 245"] or ["AMRAP"].
616
+ * Values are kept raw (a non-numeric prescription like "AMRAP" or "8-12" must survive); the
617
+ * positional units come from the exercise's param types, mirroring the coach presenter.
618
+ */
619
+ function prescribedSets(ex) {
620
+ const out = [];
621
+ for (let i = 1; i <= SLOTS; i += 1) {
622
+ const p1 = ex[`param_1_data_${i}`];
623
+ const p2 = ex[`param_2_data_${i}`];
624
+ const has1 = nonEmpty(p1);
625
+ const has2 = nonEmpty(p2);
626
+ if (!has1 && !has2) continue;
627
+ if (has1 && has2) out.push(`${p1} @ ${p2}`);
628
+ else if (has1) out.push(String(p1));
629
+ else out.push(`@ ${p2}`);
630
+ }
631
+ return out;
632
+ }
633
+ function presentExercise(ex) {
634
+ const instruction = typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null;
635
+ return {
636
+ exerciseId: coerceInt(ex.exercise_id),
637
+ title: typeof ex.title === "string" ? ex.title : "",
638
+ instruction,
639
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type),
640
+ prescribed: prescribedSets(ex)
641
+ };
642
+ }
643
+ function presentBlock(set) {
644
+ const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
645
+ return {
646
+ order: coerceInt(set.order) ?? 0,
647
+ title: typeof set.title === "string" && set.title !== "" ? set.title : null,
648
+ instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
649
+ isTest: coerceInt(set.is_test) === 1,
650
+ exercises: exercises.filter(isRecord).map(presentExercise)
651
+ };
652
+ }
653
+ function str$1(v) {
654
+ return typeof v === "string" && v !== "" ? v : null;
655
+ }
656
+ /** Flatten one `/3.0/athlete/programworkout/range` item into a readable workout. */
657
+ function presentAthleteWorkout(raw) {
658
+ const rec = raw;
659
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
660
+ const workout = isRecord(ssw.workout) ? ssw.workout : {};
661
+ const sets = Array.isArray(workout.workoutSets) ? workout.workoutSets : [];
662
+ return {
663
+ id: coerceInt(rec.id),
664
+ date: str$1(rec.date) ?? "",
665
+ title: str$1(rec.workout_title) ?? "",
666
+ program: str$1(rec.program_title),
667
+ team: str$1(rec.team_title),
668
+ instruction: str$1(workout.instruction),
669
+ blocks: sets.filter(isRecord).map(presentBlock).sort((a, b) => a.order - b.order)
670
+ };
671
+ }
672
+ function presentAthleteWorkouts(list) {
673
+ return list.map(presentAthleteWorkout);
674
+ }
675
+ const MAX_PARAM_SLOTS = 10;
676
+ /**
677
+ * Build the body for `PUT /1.0/athlete/savedworkoutsetexercise/{id}`. The body uses
678
+ * snake_case keys matching the live API response shape. Each set slot (1-10) gets a
679
+ * `param_N_made` flag (1 if data is present, 0 otherwise) and `param_1_data_N` /
680
+ * `param_2_data_N` string values.
681
+ *
682
+ * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
683
+ * required from the live exercise record; everything else is derived from `results`.
684
+ *
685
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
686
+ */
687
+ function buildExerciseLogPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results) {
688
+ if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
689
+ const body = {
690
+ id: savedWorkoutSetExerciseId,
691
+ saved_workout_set_id: savedWorkoutSetId,
692
+ workout_set_exercise_id: workoutSetExerciseId,
693
+ completed: results.some((s) => s.param1 !== void 0 || s.param2 !== void 0) ? 1 : 0
694
+ };
695
+ for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
696
+ const slot = results[i - 1];
697
+ const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
698
+ const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
699
+ body[`param_${i}_made`] = p1 !== "" || p2 !== "" ? 1 : 0;
700
+ body[`param_1_data_${i}`] = p1;
701
+ body[`param_2_data_${i}`] = p2;
702
+ }
703
+ return body;
704
+ }
705
+ /**
706
+ * Locate the target saved workout set across all program workouts on the given day.
707
+ * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
708
+ * can look up `workout_set_exercise_id` by `savedWorkoutSetExerciseId`, and the raw set
709
+ * record so callers can build the set-completion PUT body via `buildSetCompletePayload`.
710
+ */
711
+ function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
712
+ for (const pw of workouts) {
713
+ const rec = pw;
714
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
715
+ const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
716
+ if (!sw) continue;
717
+ const sets = Array.isArray(sw.workoutSets) ? sw.workoutSets : [];
718
+ for (const s of sets) {
719
+ if (!isRecord(s)) continue;
720
+ if (coerceInt(s.id) === savedWorkoutSetId) {
721
+ const savedWorkoutId = coerceInt(sw.id);
722
+ if (!savedWorkoutId) continue;
723
+ return {
724
+ savedWorkoutId,
725
+ exercises: Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises.filter(isRecord) : [],
726
+ rawSet: s
727
+ };
728
+ }
729
+ }
730
+ }
731
+ throw new Error(`Saved workout set ${savedWorkoutSetId} not found on this date.`);
732
+ }
733
+ /**
734
+ * Build the body for `PUT /1.0/athlete/savedworkoutset/{id}` that marks the set completed.
735
+ *
736
+ * The API requires the **app's camelCase in-memory model**, not the snake_case shape the
737
+ * GET endpoints return. Key field mappings from the raw set record:
738
+ * saved_workout_id → sessionId
739
+ * workout_set_id → workoutSetId
740
+ * is_super_set (0/1) → isSuperSet (boolean)
741
+ * plain_text (0/1) → isPlainText (boolean)
742
+ * unit ("lb"/"kg") → isMetric (boolean)
743
+ * workoutSetExercises[].id → exercises (array of IDs)
744
+ *
745
+ * `exerciseIds` must be the savedWorkoutSetExercise IDs (not workout_set_exercise_ids).
746
+ *
747
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
748
+ */
749
+ function buildSetCompletePayload(rawSet, exerciseIds, complete) {
750
+ const id = coerceInt(rawSet.id);
751
+ const sessionId = coerceInt(rawSet.saved_workout_id);
752
+ const workoutSetId = coerceInt(rawSet.workout_set_id);
753
+ if (!id || !sessionId || !workoutSetId) throw new Error("Raw set is missing required id / saved_workout_id / workout_set_id.");
754
+ const unit = typeof rawSet.unit === "string" ? rawSet.unit : "";
755
+ return {
756
+ id,
757
+ sessionId,
758
+ workoutSetId,
759
+ completed: complete ? "1" : "0",
760
+ rx: coerceInt(rawSet.rx) ?? 0,
761
+ version: coerceInt(rawSet.version) ?? 0,
762
+ isMetric: unit.toLowerCase() === "kg",
763
+ isSuperSet: rawSet.is_super_set === 1 || rawSet.is_super_set === true,
764
+ isPlainText: rawSet.plain_text === 1 || rawSet.plain_text === true,
765
+ title: typeof rawSet.title === "string" ? rawSet.title : "",
766
+ instruction: typeof rawSet.instruction === "string" ? rawSet.instruction : "",
767
+ notes: rawSet.notes ?? null,
768
+ exercises: [...exerciseIds]
769
+ };
770
+ }
771
+ /**
772
+ * Record entered set results for one saved workout set.
773
+ *
774
+ * **Two-step write (both required for data to persist):**
775
+ * 1. For each exercise in `results`:
776
+ * `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}` with the
777
+ * per-slot `param_N_data_M` values. This is the only path that actually stores reps
778
+ * and weight — the `savedworkoutset` PUT accepts the same fields but silently discards
779
+ * them.
780
+ * 2. `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with `completed:"1"` to
781
+ * mark the block done and make the entry visible as a logged set in history.
782
+ *
783
+ * The date is used to locate the saved workout (via the range endpoint) so we can resolve
784
+ * `saved_workout_id` and `workout_set_exercise_id` for each exercise. Both are required
785
+ * by the respective PUT bodies.
786
+ */
787
+ async function logAthleteSet(client, args) {
788
+ const { exercises, rawSet } = findSavedWorkoutSet(await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId);
789
+ let exercisesLogged = 0;
790
+ for (const result of args.results) {
791
+ const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
792
+ if (!ex) throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${args.savedWorkoutSetId}.`);
793
+ const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
794
+ if (!workoutSetExerciseId) throw new Error(`Could not resolve workout_set_exercise_id for exercise ${result.savedWorkoutSetExerciseId}.`);
795
+ const body = buildExerciseLogPayload(result.savedWorkoutSetExerciseId, args.savedWorkoutSetId, workoutSetExerciseId, result.sets);
796
+ const res = await client.request("PUT", `/1.0/athlete/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}`, { body });
797
+ if (!res.ok) throw new Error(`Failed to log exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).`);
798
+ exercisesLogged += 1;
799
+ }
800
+ const setBody = buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true);
801
+ const setRes = await client.request("PUT", `/1.0/athlete/savedworkoutset/${args.savedWorkoutSetId}`, { body: setBody });
802
+ if (!setRes.ok) throw new Error(`Failed to mark workout set ${args.savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
803
+ return {
804
+ savedWorkoutSetId: args.savedWorkoutSetId,
805
+ exercisesLogged
806
+ };
807
+ }
808
+ /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
809
+ function presentExerciseHistory(detail) {
810
+ return {
811
+ liftPRs: (detail.liftPRs ?? []).map((p) => ({
812
+ description: p.description ?? null,
813
+ reps: p.reps ?? null,
814
+ weight: p.weight ?? null,
815
+ date: p.dateCompleted ?? null
816
+ })),
817
+ sessions: (detail.history ?? []).map((h) => ({
818
+ date: h.dateCompleted,
819
+ abr: h.abr ?? null,
820
+ estimated1RM: h.bestEstimated1RM ?? null,
821
+ sets: (h.sets ?? []).map((s) => ({
822
+ setNumber: s.setNumber,
823
+ value: s.formattedValue ?? null
824
+ }))
825
+ }))
826
+ };
827
+ }
366
828
  //#endregion
367
829
  //#region ../js/src/workout-encode.ts
368
830
  const LEADERBOARD_TYPE = {
@@ -850,7 +1312,7 @@ var ExerciseLibrary = class {
850
1312
  await this.ensureFresh();
851
1313
  const s = this.#byId.get(id);
852
1314
  if (!s) return null;
853
- return presentExercise(s.raw);
1315
+ return presentExercise$1(s.raw);
854
1316
  }
855
1317
  async defaults(id) {
856
1318
  const s = this.#byId.get(id);
@@ -988,7 +1450,7 @@ const HELP = `trainheroic — command-line tool for the TrainHeroic coaching API
988
1450
  Credentials come from TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD. Output is JSON.
989
1451
 
990
1452
  Setup:
991
- install-skill copy the Claude Code skill to ~/.claude/skills/trainheroic-unofficial/
1453
+ install-skill copy the Claude Code skills to ~/.claude/skills/
992
1454
 
993
1455
  Reads:
994
1456
  whoami | head-coach | athletes | programs | teams | notifications | analytics
@@ -1016,6 +1478,17 @@ Messaging:
1016
1478
  message draft <streamId> <text> [--reply-to <id>]
1017
1479
  message send <streamId> <text> [--reply-to <id>] --yes
1018
1480
  message delete <streamId> <commentId> --yes
1481
+
1482
+ Athlete (the logged-in user's own training; a coach account works too):
1483
+ athlete whoami | profile [--metric] | prefs | working-maxes
1484
+ athlete workouts --start Y-M-D --end Y-M-D [--raw]
1485
+ athlete exercises [--q <text>] [--limit N]
1486
+ athlete history <exerciseId> [--raw]
1487
+ athlete prs <exerciseId>
1488
+ athlete stats <exerciseId> --date Y-M-D
1489
+ athlete leaderboard <workoutId> [--page N] [--page-size N] [--gender N]
1490
+ athlete export [--out dir] [--start Y-M-D] [--end Y-M-D] [--full]
1491
+ athlete log-set --date Y-M-D --set <id> <resultsJson>|--file f --yes (writes to your live log)
1019
1492
  `;
1020
1493
  function out(value) {
1021
1494
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
@@ -1252,17 +1725,182 @@ async function cmdMessage(client, rest) {
1252
1725
  default: return fail("usage: trainheroic message <list|read|draft|send|delete>");
1253
1726
  }
1254
1727
  }
1728
+ function isoDate(value, label) {
1729
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) fail(`${label} must be YYYY-MM-DD, got "${value}".`);
1730
+ return value;
1731
+ }
1732
+ function logErr(msg) {
1733
+ process.stderr.write(`${msg}\n`);
1734
+ }
1735
+ async function cmdAthleteExport(client, a) {
1736
+ const { values } = parse(a, {
1737
+ out: { type: "string" },
1738
+ start: { type: "string" },
1739
+ end: { type: "string" },
1740
+ full: { type: "boolean" }
1741
+ });
1742
+ const dir = values.out ?? join(homedir(), ".trainheroic", "athlete-export");
1743
+ const today = /* @__PURE__ */ new Date();
1744
+ const past = /* @__PURE__ */ new Date();
1745
+ past.setFullYear(past.getFullYear() - 2);
1746
+ const start = values.start !== void 0 ? isoDate(values.start, "--start") : past.toISOString().slice(0, 10);
1747
+ const end = values.end !== void 0 ? isoDate(values.end, "--end") : today.toISOString().slice(0, 10);
1748
+ await mkdir(dir, { recursive: true });
1749
+ const userId = await resolveAthleteUserId(client);
1750
+ logErr(`exporting to ${dir} (user ${userId})`);
1751
+ const [summary, user, prefs, workouts, exercises, workingMaxes] = await Promise.all([
1752
+ fetchAthleteProfileSummary(client, userId),
1753
+ fetchAthleteUser(client, userId),
1754
+ fetchAthletePrefs(client),
1755
+ fetchAthleteWorkouts(client, start, end),
1756
+ fetchExerciseHistoryList(client),
1757
+ fetchWorkingMaxes(client)
1758
+ ]);
1759
+ await writeFile(join(dir, "profile.json"), JSON.stringify({
1760
+ summary,
1761
+ user,
1762
+ prefs
1763
+ }, null, 2));
1764
+ await writeFile(join(dir, "workouts.json"), JSON.stringify(presentAthleteWorkouts(workouts), null, 2));
1765
+ await writeFile(join(dir, "exercises.json"), JSON.stringify(exercises, null, 2));
1766
+ await writeFile(join(dir, "working-maxes.json"), JSON.stringify(workingMaxes, null, 2));
1767
+ let histories = 0;
1768
+ if (values.full === true) {
1769
+ await mkdir(join(dir, "history"), { recursive: true });
1770
+ logErr(`fetching per-exercise history for ${exercises.length} exercises (--full)...`);
1771
+ await mapPool(exercises, 5, async (ex) => {
1772
+ const id = Number(ex.id);
1773
+ const detail = await fetchExerciseHistoryDetail(client, id, userId).catch(() => null);
1774
+ if (detail) {
1775
+ await writeFile(join(dir, "history", `${id}.json`), JSON.stringify(presentExerciseHistory(detail), null, 2));
1776
+ histories += 1;
1777
+ }
1778
+ });
1779
+ }
1780
+ out({
1781
+ exported: dir,
1782
+ range: {
1783
+ start,
1784
+ end
1785
+ },
1786
+ workouts: workouts.length,
1787
+ exercises: exercises.length,
1788
+ workingMaxes: workingMaxes.length,
1789
+ histories: values.full === true ? histories : "skipped (use --full)"
1790
+ });
1791
+ }
1792
+ const LOG_SET_USAGE = "athlete log-set --date Y-M-D --set <id> <resultsJson> --yes";
1793
+ async function cmdAthleteLogSet(client, a) {
1794
+ const { values, positionals } = parse(a, {
1795
+ date: { type: "string" },
1796
+ set: { type: "string" },
1797
+ file: { type: "string" },
1798
+ yes: { type: "boolean" }
1799
+ });
1800
+ const date = isoDate(need(values.date, LOG_SET_USAGE), "--date");
1801
+ const savedWorkoutSetId = toInt(need(values.set, LOG_SET_USAGE), "--set");
1802
+ const args = validate(logSetArgsSchema, {
1803
+ date,
1804
+ savedWorkoutSetId,
1805
+ results: await jsonInput(positionals[0], values.file)
1806
+ }, "log-set args");
1807
+ if (values.yes !== true) fail(`logging to set ${savedWorkoutSetId} writes to your coach-visible log; add --yes.`);
1808
+ const mapped = args.results.map((r) => ({
1809
+ savedWorkoutSetExerciseId: toInt(String(r.savedWorkoutSetExerciseId), "savedWorkoutSetExerciseId"),
1810
+ sets: r.sets.map((s) => {
1811
+ const slot = {};
1812
+ if (s.param1 !== void 0) slot.param1 = s.param1;
1813
+ if (s.param2 !== void 0) slot.param2 = s.param2;
1814
+ return slot;
1815
+ })
1816
+ }));
1817
+ return out(await logAthleteSet(client, {
1818
+ date: args.date,
1819
+ savedWorkoutSetId,
1820
+ results: mapped
1821
+ }));
1822
+ }
1823
+ async function cmdAthlete(client, rest) {
1824
+ const [sub, ...a] = rest;
1825
+ switch (sub) {
1826
+ case "whoami": return out(await get(client, "/user/simple"));
1827
+ case "profile": {
1828
+ const { values } = parse(a, { metric: { type: "boolean" } });
1829
+ const userId = await resolveAthleteUserId(client);
1830
+ const [summary, user] = await Promise.all([fetchAthleteProfileSummary(client, userId, values.metric === true), fetchAthleteUser(client, userId)]);
1831
+ return out({
1832
+ summary,
1833
+ user
1834
+ });
1835
+ }
1836
+ case "prefs": return out(await fetchAthletePrefs(client));
1837
+ case "workouts": {
1838
+ const { values } = parse(a, {
1839
+ start: { type: "string" },
1840
+ end: { type: "string" },
1841
+ raw: { type: "boolean" }
1842
+ });
1843
+ const workouts = await fetchAthleteWorkouts(client, isoDate(need(values.start, "athlete workouts --start Y-M-D --end Y-M-D"), "--start"), isoDate(need(values.end, "athlete workouts --start Y-M-D --end Y-M-D"), "--end"));
1844
+ return out(values.raw === true ? workouts : presentAthleteWorkouts(workouts));
1845
+ }
1846
+ case "exercises": {
1847
+ const { values } = parse(a, {
1848
+ q: { type: "string" },
1849
+ limit: { type: "string" }
1850
+ });
1851
+ const limit = values.limit !== void 0 ? toInt(values.limit, "--limit") : 20;
1852
+ const q = values.q;
1853
+ return out(q !== void 0 ? await searchExerciseHistory(client, q, limit) : await fetchExerciseHistoryList(client));
1854
+ }
1855
+ case "history": {
1856
+ const { values, positionals } = parse(a, { raw: { type: "boolean" } });
1857
+ const detail = await fetchExerciseHistoryDetail(client, toInt(need(positionals[0], "athlete history <exerciseId>"), "exerciseId"), await resolveAthleteUserId(client));
1858
+ return out(values.raw === true ? detail : presentExerciseHistory(detail));
1859
+ }
1860
+ case "prs": return out(await fetchPersonalRecords(client, toInt(need(a[0], "athlete prs <exerciseId>"), "exerciseId")));
1861
+ case "stats": {
1862
+ const { values, positionals } = parse(a, { date: { type: "string" } });
1863
+ const id = toInt(need(positionals[0], "athlete stats <exerciseId> --date Y-M-D"), "exerciseId");
1864
+ const date = isoDate(need(values.date, "athlete stats <exerciseId> --date Y-M-D"), "--date");
1865
+ return out(await fetchExerciseStats(client, id, await resolveAthleteUserId(client), date));
1866
+ }
1867
+ case "working-maxes": return out(await fetchWorkingMaxes(client));
1868
+ case "leaderboard": {
1869
+ const { values, positionals } = parse(a, {
1870
+ page: { type: "string" },
1871
+ "page-size": { type: "string" },
1872
+ gender: { type: "string" }
1873
+ });
1874
+ const workoutId = toInt(need(positionals[0], "athlete leaderboard <workoutId>"), "workoutId");
1875
+ const opts = {};
1876
+ if (values.page !== void 0) opts.page = toInt(values.page, "--page");
1877
+ if (values["page-size"] !== void 0) opts.pageSize = toInt(values["page-size"], "--page-size");
1878
+ if (values.gender !== void 0) opts.gender = toInt(values.gender, "--gender");
1879
+ return out(await fetchLeaderboard(client, workoutId, opts));
1880
+ }
1881
+ case "export": return cmdAthleteExport(client, a);
1882
+ case "log-set": return cmdAthleteLogSet(client, a);
1883
+ default: return fail("usage: trainheroic athlete <whoami|profile|prefs|workouts|exercises|history|prs|stats|working-maxes|leaderboard|export|log-set>");
1884
+ }
1885
+ }
1255
1886
  async function cmdInstallSkill() {
1256
1887
  const home = process.env.HOME ?? process.env.USERPROFILE;
1257
1888
  if (!home) fail("cannot determine home directory.");
1258
- const skillSrc = join(import.meta.dirname, "../skill/trainheroic-unofficial");
1259
- const skillDest = join(home, ".claude/skills/trainheroic-unofficial");
1260
- await mkdir(join(home, ".claude/skills"), { recursive: true });
1261
- await cp(skillSrc, skillDest, {
1262
- recursive: true,
1263
- force: true
1264
- });
1265
- out({ installed: skillDest });
1889
+ const skillRoot = join(import.meta.dirname, "../skill");
1890
+ const skillsDir = join(home, ".claude/skills");
1891
+ await mkdir(skillsDir, { recursive: true });
1892
+ const entries = await readdir(skillRoot, { withFileTypes: true });
1893
+ const installed = [];
1894
+ for (const entry of entries) {
1895
+ if (!entry.isDirectory()) continue;
1896
+ const dest = join(skillsDir, entry.name);
1897
+ await cp(join(skillRoot, entry.name), dest, {
1898
+ recursive: true,
1899
+ force: true
1900
+ });
1901
+ installed.push(dest);
1902
+ }
1903
+ out({ installed });
1266
1904
  }
1267
1905
  async function dispatch(client, group, rest) {
1268
1906
  switch (group) {
@@ -1280,6 +1918,7 @@ async function dispatch(client, group, rest) {
1280
1918
  case "exercise": return cmdExercise(client, rest);
1281
1919
  case "workout": return cmdWorkout(client, rest);
1282
1920
  case "message": return cmdMessage(client, rest);
1921
+ case "athlete": return cmdAthlete(client, rest);
1283
1922
  default: return fail(`unknown command "${group}". Run 'trainheroic help'.`);
1284
1923
  }
1285
1924
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/dto": "0.3.0",
25
- "@trainheroic-unofficial/js": "0.3.0"
24
+ "@trainheroic-unofficial/dto": "0.4.1",
25
+ "@trainheroic-unofficial/js": "0.4.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: trainheroic-athlete
3
+ description: Read and analyze your own TrainHeroic training — scheduled and completed workouts, per-exercise history, PRs, working maxes, and lifetime totals — and optionally log completed sets. Use when the user wants to review their training history, track progress on a lift over time, look up PRs or working maxes, see what's programmed, export their training data, or log a workout result. Works for an athlete account or a coach account (a coach also has athlete-scoped training).
4
+ metadata:
5
+ author: alandotcom
6
+ version: "1.0.0"
7
+ ---
8
+
9
+ # TrainHeroic Athlete API
10
+
11
+ Authenticated access to the logged-in user's **own** training through the `trainheroic`
12
+ command-line tool (`@trainheroic-unofficial/cli`), which wraps the
13
+ `@trainheroic-unofficial/js` SDK. This is the athlete surface: history, scheduled workouts,
14
+ PRs, working maxes. For coaching (rosters, teams, programming, messaging) use the
15
+ `trainheroic-unofficial` skill instead.
16
+
17
+ - Base URL: `https://api.trainheroic.com`
18
+ - Login endpoint: `https://apis.trainheroic.com/auth`
19
+
20
+ A coach account works here too: a coach login also carries athlete scope, so these commands
21
+ return the coach's own training data.
22
+
23
+ ## Setup
24
+
25
+ ### CLI
26
+
27
+ Verify the CLI is available, then install if missing (no credentials required):
28
+
29
+ ```bash
30
+ which trainheroic || npm install -g @trainheroic-unofficial/cli
31
+ trainheroic install-skill # refreshes skill files in ~/.claude/skills/
32
+ ```
33
+
34
+ Set `TH` for the commands below:
35
+
36
+ ```bash
37
+ TH="trainheroic"
38
+ ```
39
+
40
+ ### Credentials
41
+
42
+ Credentials come from the environment (if unset, ask the user — do not guess):
43
+
44
+ ```bash
45
+ export TRAINHEROIC_EMAIL="athlete@example.com"
46
+ export TRAINHEROIC_PASSWORD="..."
47
+ ```
48
+
49
+ The CLI logs in, caches the session at `~/.trainheroic/session.json`, and re-authenticates
50
+ on a 401/403. Start with `$TH athlete whoami` to confirm auth and get your `id`. Run
51
+ `$TH help` for the full command list.
52
+
53
+ ## What you can do
54
+
55
+ | Goal | Command |
56
+ | ------------------------------------- | -------------------------------------------------------- |
57
+ | Lifetime totals + profile | `$TH athlete profile [--metric]` |
58
+ | Scheduled / completed workouts | `$TH athlete workouts --start Y-M-D --end Y-M-D [--raw]` |
59
+ | Find an exercise you've logged | `$TH athlete exercises [--q <text>] [--limit N]` |
60
+ | One lift's PRs + history over time | `$TH athlete history <exerciseId> [--raw]` |
61
+ | Personal records for a lift | `$TH athlete prs <exerciseId>` |
62
+ | Last performance + PR as of a date | `$TH athlete stats <exerciseId> --date Y-M-D` |
63
+ | Working maxes (drive % prescriptions) | `$TH athlete working-maxes` |
64
+ | Benchmark leaderboard | `$TH athlete leaderboard <workoutId>` |
65
+ | Download all historicals to JSON | `$TH athlete export [--out dir] [--full]` |
66
+ | Log completed set results (gated) | `$TH athlete log-set --date Y-M-D --set <id> ... --yes` |
67
+
68
+ ## Reading training
69
+
70
+ Most analysis starts by finding the exercise id, then pulling its history:
71
+
72
+ ```bash
73
+ $TH athlete exercises --q "back squat" # -> id + title + positional units
74
+ $TH athlete history 1659830 # PRs + dated session time-series
75
+ $TH athlete stats 1659830 --date 2026-06-01
76
+ ```
77
+
78
+ `history` returns a presented view (PRs plus each session's date, summary `abr`, estimated
79
+ 1RM, and sets). Add `--raw` for the untouched API object.
80
+
81
+ `workouts` flattens each day into blocks and exercises with per-set prescriptions and
82
+ positional units:
83
+
84
+ ```bash
85
+ $TH athlete workouts --start 2026-06-01 --end 2026-06-14
86
+ ```
87
+
88
+ `profile` returns lifetime totals (reps, volume, sessions, first/last logged, hours) plus
89
+ the profile. Pass `--metric` for kg/metric totals.
90
+
91
+ ## Exporting your history
92
+
93
+ `$TH athlete export` writes your historicals to JSON files (default
94
+ `~/.trainheroic/athlete-export`): `profile.json`, `workouts.json` (a 2-year window by
95
+ default; override with `--start`/`--end`), `exercises.json` (the catalog), and
96
+ `working-maxes.json`. Add `--full` to also fetch each exercise's history into
97
+ `history/<id>.json` — that is one request per exercise (hundreds), so it is slower.
98
+
99
+ ```bash
100
+ $TH athlete export --out ./my-training # fast: workouts, catalog, maxes, profile
101
+ $TH athlete export --out ./my-training --full # also per-exercise history (slow)
102
+ ```
103
+
104
+ ## Logging a set
105
+
106
+ `$TH athlete log-set` writes completed results back to a workout (reps/weight per set),
107
+ marks the set completed, and the result shows up in your exercise history. **It is
108
+ athlete-facing**: it mutates your training log, which your coach can see, so confirm before
109
+ running and re-read the workout to check the result landed.
110
+
111
+ Get the `savedWorkoutSetId` and each `savedWorkoutSetExerciseId` from the raw workout
112
+ (`$TH athlete workouts --start <day> --end <day> --raw`, under
113
+ `summarizedSavedWorkout.saved_workout.workoutSets`). Then:
114
+
115
+ ```bash
116
+ $TH athlete log-set --date 2026-06-01 --set 1593305783 --yes \
117
+ '[{"savedWorkoutSetExerciseId": 2712369448, "sets": [{"param1": 3, "param2": 225}]}]'
118
+ ```
119
+
120
+ `--yes` is required. Confirm with the user before running it, and re-read the workout to
121
+ check the result landed as intended. `param1`/`param2` are the entered values by entry slot
122
+ (check the exercise's positional units first).
123
+
124
+ ## Gotchas
125
+
126
+ - `profile` must send `use_metric` and `stats` must send `date`, or the API returns 400.
127
+ The CLI fills `use_metric` (toggle with `--metric`); pass `--date` to `stats`.
128
+ - Units are **fixed per exercise** and surfaced positionally as `[param 1, param 2]` (e.g.
129
+ `["reps", "lb"]`). They are not labelled by role: some exercises reverse the slots.
130
+ - `log-set` writes to your coach-visible log. Gate it behind explicit user confirmation,
131
+ never run it autonomously, and verify the result. Its request shape was reverse-engineered
132
+ from the mobile app (a two-step write: log the exercise data, then mark the set completed).
133
+ - This API is undocumented and can change. `references/athlete-api.md` lists the endpoints
134
+ and shapes.
135
+
136
+ ## Beyond the CLI
137
+
138
+ The same SDK powers the local `@trainheroic-unofficial/athlete-mcp` stdio server (the same
139
+ tools as MCP tools) and the hosted Cloudflare worker. The hosted worker adds a **training
140
+ warehouse** (D1): `athlete_workouts_sync` / `athlete_training_sync` download your
141
+ historicals, and `athlete_workouts_stored` / `athlete_training_stored` query them — so you
142
+ can research your training over time without re-hitting the API. See
143
+ `references/athlete-api.md` for the warehouse tools.
@@ -0,0 +1,100 @@
1
+ # Athlete API reference
2
+
3
+ The athlete endpoints operate on the **logged-in user** (no athlete id in the path — the
4
+ session identifies you). Several need the numeric user id as a query arg; get it from
5
+ `GET /user/simple` (`.id`). All are on the default host `https://api.trainheroic.com` and
6
+ authenticate with the `session-token` header. Response schemas are loose (the API drifts);
7
+ only the fields below are relied on.
8
+
9
+ ## Identity & profile
10
+
11
+ | Endpoint | Notes |
12
+ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `GET /user/simple` | `id`, `roles`, `org_id`, name. The id is the tenant key for everything else. |
14
+ | `GET /v5/athleteProfile/summary?user_id={id}&use_metric={0\|1}` | Lifetime totals: `reps_sum`, `volume_sum`, `sessions_count`, `first_logged_date`, `last_logged_date`, `duration_hours`. **`use_metric` is required** — omitting it returns `400 Invalid data`. |
15
+ | `GET /v5/users/{id}` | Detailed profile: dob, gender, height/weight, `use_metric`, trial status. |
16
+ | `GET /1.0/athlete/prefs` | Notification + display preference flags. |
17
+
18
+ ## Workouts
19
+
20
+ `GET /3.0/athlete/programworkout/range?startDate={Y-M-D}&endDate={Y-M-D}` — scheduled and
21
+ completed workouts in an inclusive window. Each item:
22
+
23
+ - Top level: `id` (the programWorkout id), `date`, `workout_title`, `program_id`,
24
+ `program_title`, `team_id`, `team_title`.
25
+ - `summarizedSavedWorkout.workout`: `title`, `instruction` (coach note), `workoutSets[]`.
26
+ - Each set: `title`, `order`, `instruction`, `is_test`, `workoutSetExercises[]`.
27
+ - Each exercise: `exercise_id`, `title`, `instruction`, `param_1_type`, `param_2_type`,
28
+ and the prescription in `param_1_data_1..10` / `param_2_data_1..10` (one slot per set;
29
+ empty string for unused). Non-numeric prescriptions (`AMRAP`, `8-12`) appear as-is.
30
+ - `summarizedSavedWorkout.saved_workout`: the athlete's logged copy, with `workoutSets[]`
31
+ whose `id` is the **savedWorkoutSetId** and whose `workoutSetExercises[].id` is the
32
+ **savedWorkoutSetExerciseId** — the ids the logging write targets.
33
+
34
+ The SDK's `presentAthleteWorkout` flattens this to `{ id, date, title, program, team,
35
+ instruction, blocks: [{ order, title, instruction, isTest, exercises: [{ exerciseId, title,
36
+ instruction, units, prescribed }] }] }`.
37
+
38
+ ## Exercises, history, PRs
39
+
40
+ | Endpoint | Notes |
41
+ | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
+ | `GET /v5/users/exercises/history` | The exercises you've logged: `id`, `title`, `param1Type`, `param2Type`, `prescription`, `isCircuit`. Use it to find an exercise id. |
43
+ | `GET /v5/exercises/{id}/history?userId={id}` | `liftPRs[]` (rep-max PRs), `history[]` (per-session: `dateCompleted`, `abr`, `bestEstimated1RM`, `savedWorkoutSetExerciseId`, `programWorkoutId`, `sets[]`, `repMaxes[]`). |
44
+ | `GET /v5/exercises/{id}/personalRecords` | PR rows with strength-standard `filters`. |
45
+ | `GET /v5/exercises/{id}/stats?userId={id}&date={Y-M-D}` | `isLift`, `lastPerformance`, `personalRecord`. **`date` is required** — omitting it returns `400 Invalid date parameter`. |
46
+ | `GET /v5/exercises/{id}` | Exercise detail: description, `param1Type`/`param2Type`, `units`, video. |
47
+ | `GET /2.0/athlete/workingMax` | Working max per exercise: `exercise_id`, `title`, `param_type`, `value`, `type_suffix`. |
48
+ | `GET /3.0/athlete/leaderboard/{workoutId}` | Leaderboard for a benchmark/test workout (`tests`, `results`, `testStats`). |
49
+
50
+ ## Logging a set (write)
51
+
52
+ Logging is a **two-step** write, reverse-engineered from the mobile app (verified against
53
+ captured traffic). The SDK's `logAthleteSet` performs both; it fetches the day's range to
54
+ resolve the ids from `summarizedSavedWorkout.saved_workout`.
55
+
56
+ 1. **Persist the entered data** — `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}`
57
+ with `{ id, saved_workout_set_id, workout_set_exercise_id, completed:1, param_N_made,
58
+ param_1_data_N, param_2_data_N (10 slots) }`. This is the only path that actually stores
59
+ reps/weight (and it alone surfaces the result in exercise history). The
60
+ `savedworkoutset`/`savedworkout` PUTs accept the same fields but **silently discard** the
61
+ `param_N_data` values.
62
+ 2. **Mark the set completed** — `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with
63
+ the camelCase in-memory model (`sessionId` ← `saved_workout.id`, `workoutSetId` ←
64
+ `workout_set_id`, `isSuperSet`, `exercises: [savedWorkoutSetExerciseId, …]`, `completed`).
65
+ A minimal `{id, sessionId, completed}` body returns 500 — the full mapped body is required.
66
+
67
+ There is no GET for a single saved workout set; read it from the workout range's
68
+ `saved_workout.workoutSets[]` (`id` = savedWorkoutSetId, `workoutSetExercises[].id` =
69
+ savedWorkoutSetExerciseId, `workoutSetExercises[].workout_set_exercise_id` = the template id).
70
+
71
+ ## MCP tools
72
+
73
+ The local `@trainheroic-unofficial/athlete-mcp` server and the hosted worker (for any
74
+ account) expose:
75
+
76
+ - Live reads: `athlete_whoami`, `athlete_profile`, `athlete_prefs`, `athlete_workouts`,
77
+ `athlete_exercises`, `athlete_exercise_history`, `athlete_personal_records`,
78
+ `athlete_exercise_stats`, `athlete_working_maxes`, `athlete_leaderboard`.
79
+ - Gated write: `athlete_log_set` (elicitation or `confirm:true`).
80
+
81
+ ## Warehouse tools (hosted worker only, D1-backed)
82
+
83
+ Download historicals into D1 so they can be queried over time without re-hitting the API.
84
+ One sync verb populates each zone; one query tool reads it.
85
+
86
+ - `athlete_workouts_sync { startDate, endDate }` → `athlete_workouts_stored { workoutId? |
87
+ startDate?/endDate? }`.
88
+ - `athlete_training_sync { exerciseId? | batchSize?, full? }` → `athlete_training_stored
89
+ { q? | exerciseId? (+prs?) | workingMaxes? }`. Omitting `exerciseId` syncs the catalog +
90
+ working maxes and drains a **batch** of un-synced exercises (repeat until `remaining` is 0
91
+ — bounded per call to respect Worker subrequest limits). `full:true` re-pulls every
92
+ exercise.
93
+
94
+ ## Still unexplored
95
+
96
+ - `PUT /1.0/athlete/savedworkout/{id}` (whole-workout sync; not needed for per-set logging,
97
+ which uses the two-step path above).
98
+ - `GET /v5/users/circuits/{recent,history}` (circuit history; shapes mirror the exercise
99
+ history list).
100
+ - `GET /1.0/athlete/programming/programs` (subscribed programs; empty for the test account).