@trainheroic-unofficial/js 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { n as LibrarySnapshot, r as MemoryLibraryCache, t as LibraryCache } from "./library-cache-CDABOdIN.mjs";
2
- import { Advisory, BlockSpec, ExerciseRow, ExerciseSpec, ExerciseView, ReadResult, ResolveResult, WorkoutDate } from "@trainheroic-unofficial/dto";
2
+ import { Advisory, AthletePrefs, AthleteProfileSummary, AthleteUser, AthleteWorkingMax, AthleteWorkoutView, BlockSpec, ExerciseHistoryDetail, ExerciseHistoryListItem, ExerciseRow, ExerciseSpec, ExerciseStats, ExerciseView, PersonalRecord, PresentedExerciseHistory, ProgramWorkout, ReadResult, ResolveResult, WorkoutDate } from "@trainheroic-unofficial/dto";
3
3
  import { ZodType } from "zod";
4
4
  export * from "@trainheroic-unofficial/dto";
5
5
 
@@ -95,9 +95,15 @@ declare function isRecord(x: unknown): x is Record<string, unknown>;
95
95
  */
96
96
  declare function rankSearch<T extends {
97
97
  title: string;
98
- can_edit: number;
98
+ can_edit?: number;
99
99
  }>(rows: readonly T[], query: string, limit: number): T[];
100
100
  declare function chunk<T>(items: readonly T[], size: number): T[][];
101
+ /**
102
+ * Map over items with a bounded number of concurrent workers. Used to fan out upstream
103
+ * fetches (per-exercise history, the CLI export) without bursting the host all at once or,
104
+ * on workerd, blowing the subrequest budget.
105
+ */
106
+ declare function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
101
107
  /**
102
108
  * The exercise-library surface the tools depend on, so the tools work over either a
103
109
  * D1-backed mirror (hosted, multi-tenant) or an in-memory cache (local, single-user).
@@ -117,6 +123,117 @@ interface ExerciseIndex {
117
123
  stats(): Promise<Record<string, unknown>>;
118
124
  }
119
125
  //#endregion
126
+ //#region src/athlete.d.ts
127
+ /**
128
+ * The logged-in account's numeric user id (the athlete warehouse's tenant key, and a required
129
+ * query arg for several athlete endpoints). Works from any session — coach or athlete — and
130
+ * even from a cached session that never went through login.
131
+ */
132
+ declare function resolveAthleteUserId(client: TrainHeroicClient): Promise<number>;
133
+ /** Lifetime training totals. `use_metric` is required by the API (omitting it 400s). */
134
+ declare function fetchAthleteProfileSummary(client: TrainHeroicClient, userId: number, useMetric?: boolean): Promise<AthleteProfileSummary>;
135
+ declare function fetchAthleteUser(client: TrainHeroicClient, userId: number): Promise<AthleteUser>;
136
+ declare function fetchAthletePrefs(client: TrainHeroicClient): Promise<AthletePrefs>;
137
+ declare function fetchWorkingMaxes(client: TrainHeroicClient): Promise<AthleteWorkingMax[]>;
138
+ declare function fetchExerciseHistoryList(client: TrainHeroicClient): Promise<ExerciseHistoryListItem[]>;
139
+ /** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
140
+ declare function searchExerciseHistory(client: TrainHeroicClient, query: string, limit?: number): Promise<ExerciseHistoryListItem[]>;
141
+ declare function fetchExerciseHistoryDetail(client: TrainHeroicClient, exerciseId: number, userId: number): Promise<ExerciseHistoryDetail>;
142
+ declare function fetchPersonalRecords(client: TrainHeroicClient, exerciseId: number): Promise<PersonalRecord[]>;
143
+ /** Last performance + PR for an exercise. `date` (YYYY-MM-DD) is required by the API. */
144
+ declare function fetchExerciseStats(client: TrainHeroicClient, exerciseId: number, userId: number, date: string): Promise<ExerciseStats>;
145
+ /** Scheduled + completed workouts in an inclusive YYYY-MM-DD window. */
146
+ declare function fetchAthleteWorkouts(client: TrainHeroicClient, startDate: string, endDate: string): Promise<ProgramWorkout[]>;
147
+ declare function fetchLeaderboard(client: TrainHeroicClient, workoutId: number, opts?: {
148
+ page?: number;
149
+ pageSize?: number;
150
+ gender?: number;
151
+ }): Promise<unknown>;
152
+ /** Flatten one `/3.0/athlete/programworkout/range` item into a readable workout. */
153
+ declare function presentAthleteWorkout(raw: ProgramWorkout): AthleteWorkoutView;
154
+ declare function presentAthleteWorkouts(list: readonly ProgramWorkout[]): AthleteWorkoutView[];
155
+ /**
156
+ * One set of entered values for a single exercise within a saved workout set.
157
+ * `param1` and `param2` correspond to the exercise's first and second parameter types
158
+ * (e.g. reps and weight). At most 10 sets are supported.
159
+ */
160
+ type SetResult = {
161
+ savedWorkoutSetExerciseId: number;
162
+ sets: Array<{
163
+ param1?: number | string;
164
+ param2?: number | string;
165
+ }>;
166
+ };
167
+ /**
168
+ * Build the body for `PUT /1.0/athlete/savedworkoutsetexercise/{id}`. The body uses
169
+ * snake_case keys matching the live API response shape. Each set slot (1-10) gets a
170
+ * `param_N_made` flag (1 if data is present, 0 otherwise) and `param_1_data_N` /
171
+ * `param_2_data_N` string values.
172
+ *
173
+ * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
174
+ * required from the live exercise record; everything else is derived from `results`.
175
+ *
176
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
177
+ */
178
+ declare function buildExerciseLogPayload(savedWorkoutSetExerciseId: number, savedWorkoutSetId: number, workoutSetExerciseId: number, results: readonly {
179
+ param1?: number | string;
180
+ param2?: number | string;
181
+ }[]): Record<string, unknown>;
182
+ /**
183
+ * Locate the target saved workout set across all program workouts on the given day.
184
+ * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
185
+ * can look up `workout_set_exercise_id` by `savedWorkoutSetExerciseId`, and the raw set
186
+ * record so callers can build the set-completion PUT body via `buildSetCompletePayload`.
187
+ */
188
+ declare function findSavedWorkoutSet(workouts: readonly ProgramWorkout[], savedWorkoutSetId: number): {
189
+ savedWorkoutId: number;
190
+ exercises: Record<string, unknown>[];
191
+ rawSet: Record<string, unknown>;
192
+ };
193
+ /**
194
+ * Build the body for `PUT /1.0/athlete/savedworkoutset/{id}` that marks the set completed.
195
+ *
196
+ * The API requires the **app's camelCase in-memory model**, not the snake_case shape the
197
+ * GET endpoints return. Key field mappings from the raw set record:
198
+ * saved_workout_id → sessionId
199
+ * workout_set_id → workoutSetId
200
+ * is_super_set (0/1) → isSuperSet (boolean)
201
+ * plain_text (0/1) → isPlainText (boolean)
202
+ * unit ("lb"/"kg") → isMetric (boolean)
203
+ * workoutSetExercises[].id → exercises (array of IDs)
204
+ *
205
+ * `exerciseIds` must be the savedWorkoutSetExercise IDs (not workout_set_exercise_ids).
206
+ *
207
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
208
+ */
209
+ declare function buildSetCompletePayload(rawSet: Record<string, unknown>, exerciseIds: readonly number[], complete: boolean): Record<string, unknown>;
210
+ /**
211
+ * Record entered set results for one saved workout set.
212
+ *
213
+ * **Two-step write (both required for data to persist):**
214
+ * 1. For each exercise in `results`:
215
+ * `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}` with the
216
+ * per-slot `param_N_data_M` values. This is the only path that actually stores reps
217
+ * and weight — the `savedworkoutset` PUT accepts the same fields but silently discards
218
+ * them.
219
+ * 2. `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with `completed:"1"` to
220
+ * mark the block done and make the entry visible as a logged set in history.
221
+ *
222
+ * The date is used to locate the saved workout (via the range endpoint) so we can resolve
223
+ * `saved_workout_id` and `workout_set_exercise_id` for each exercise. Both are required
224
+ * by the respective PUT bodies.
225
+ */
226
+ declare function logAthleteSet(client: TrainHeroicClient, args: {
227
+ date: string;
228
+ savedWorkoutSetId: number;
229
+ results: readonly SetResult[];
230
+ }): Promise<{
231
+ savedWorkoutSetId: number;
232
+ exercisesLogged: number;
233
+ }>;
234
+ /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
235
+ declare function presentExerciseHistory(detail: ExerciseHistoryDetail): PresentedExerciseHistory;
236
+ //#endregion
120
237
  //#region src/workout-encode.d.ts
121
238
  declare const LEADERBOARD_TYPE: Readonly<Record<string, number>>;
122
239
  declare const LEADERBOARD_LABEL: Readonly<Record<number, string>>;
@@ -206,4 +323,4 @@ declare class ExerciseLibrary implements ExerciseIndex {
206
323
  stats(): Promise<Record<string, unknown>>;
207
324
  }
208
325
  //#endregion
209
- export { ApiBase, BuildOptions, ClientResult, ExerciseIndex, ExerciseLibrary, LEADERBOARD_LABEL, LEADERBOARD_TYPE, Leaderboard, LibraryCache, LibrarySnapshot, MemoryLibraryCache, PARAM_NONE, PARAM_PCT_MAX, PARAM_REPS, PARAM_RPE, PARAM_UNIT, PARAM_WEIGHT, RequestOptions, TrainHeroicAuthError, TrainHeroicClient, TrainHeroicSession, asExerciseList, buildBlockPayload, buildCommentPayload, buildSearchText, buildSession, checkResponse, chunk, coerceInt, coerceNum, collectAdvisories, deleteComment, exerciseUnits, fetchStreams, isRecord, loginTrainHeroic, makeExercise, presentExercise, publishSession, rankSearch, readLive, readSession, removeSession, repsList, resolveLeaderboard, sendComment, setSessionInstruction, unitAdvisory, unitLabel, unwrapEnvelope, withUnits };
326
+ export { ApiBase, BuildOptions, ClientResult, ExerciseIndex, ExerciseLibrary, LEADERBOARD_LABEL, LEADERBOARD_TYPE, Leaderboard, LibraryCache, LibrarySnapshot, MemoryLibraryCache, PARAM_NONE, PARAM_PCT_MAX, PARAM_REPS, PARAM_RPE, PARAM_UNIT, PARAM_WEIGHT, RequestOptions, SetResult, TrainHeroicAuthError, TrainHeroicClient, TrainHeroicSession, asExerciseList, buildBlockPayload, buildCommentPayload, buildExerciseLogPayload, buildSearchText, buildSession, buildSetCompletePayload, checkResponse, chunk, coerceInt, coerceNum, collectAdvisories, deleteComment, exerciseUnits, fetchAthletePrefs, fetchAthleteProfileSummary, fetchAthleteUser, fetchAthleteWorkouts, fetchExerciseHistoryDetail, fetchExerciseHistoryList, fetchExerciseStats, fetchLeaderboard, fetchPersonalRecords, fetchStreams, fetchWorkingMaxes, findSavedWorkoutSet, isRecord, logAthleteSet, loginTrainHeroic, makeExercise, mapPool, presentAthleteWorkout, presentAthleteWorkouts, presentExercise, presentExerciseHistory, publishSession, rankSearch, readLive, readSession, removeSession, repsList, resolveAthleteUserId, resolveLeaderboard, searchExerciseHistory, sendComment, setSessionInstruction, unitAdvisory, unitLabel, unwrapEnvelope, withUnits };
package/dist/index.mjs CHANGED
@@ -261,7 +261,7 @@ function rankSearch(rows, query, limit) {
261
261
  if (title.startsWith(q)) score += 100;
262
262
  for (const tok of tokens) if (title.includes(tok)) score += 10;
263
263
  score -= title.length * .05;
264
- if (row.can_edit === 0) score += 1;
264
+ if ((row.can_edit ?? 0) === 0) score += 1;
265
265
  return {
266
266
  row,
267
267
  score
@@ -273,6 +273,307 @@ function chunk(items, size) {
273
273
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
274
274
  return out;
275
275
  }
276
+ /**
277
+ * Map over items with a bounded number of concurrent workers. Used to fan out upstream
278
+ * fetches (per-exercise history, the CLI export) without bursting the host all at once or,
279
+ * on workerd, blowing the subrequest budget.
280
+ */
281
+ async function mapPool(items, limit, fn) {
282
+ const out = Array.from({ length: items.length });
283
+ let next = 0;
284
+ const worker = async () => {
285
+ while (next < items.length) {
286
+ const i = next;
287
+ next += 1;
288
+ out[i] = await fn(items[i], i);
289
+ }
290
+ };
291
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
292
+ return out;
293
+ }
294
+ //#endregion
295
+ //#region src/athlete.ts
296
+ async function getJson(client, path, label) {
297
+ const res = await client.request("GET", path);
298
+ if (!res.ok) throw new Error(`${label} failed (HTTP ${res.status}).`);
299
+ return res.data;
300
+ }
301
+ async function getArray(client, path, label) {
302
+ const res = await client.request("GET", path);
303
+ if (!res.ok || !Array.isArray(res.data)) throw new Error(`${label} failed (HTTP ${res.status}).`);
304
+ return res.data;
305
+ }
306
+ /**
307
+ * The logged-in account's numeric user id (the athlete warehouse's tenant key, and a required
308
+ * query arg for several athlete endpoints). Works from any session — coach or athlete — and
309
+ * even from a cached session that never went through login.
310
+ */
311
+ async function resolveAthleteUserId(client) {
312
+ const res = await client.request("GET", "/user/simple");
313
+ const id = isRecord(res.data) ? coerceInt(res.data.id) : null;
314
+ if (!res.ok || id === null || id <= 0) throw new Error("Could not resolve athlete user id from /user/simple.");
315
+ return id;
316
+ }
317
+ /** Lifetime training totals. `use_metric` is required by the API (omitting it 400s). */
318
+ function fetchAthleteProfileSummary(client, userId, useMetric = false) {
319
+ return getJson(client, `/v5/athleteProfile/summary?user_id=${userId}&use_metric=${useMetric ? 1 : 0}`, "athlete profile summary");
320
+ }
321
+ function fetchAthleteUser(client, userId) {
322
+ return getJson(client, `/v5/users/${userId}`, "athlete user");
323
+ }
324
+ function fetchAthletePrefs(client) {
325
+ return getJson(client, "/1.0/athlete/prefs", "athlete prefs");
326
+ }
327
+ function fetchWorkingMaxes(client) {
328
+ return getArray(client, "/2.0/athlete/workingMax", "athlete working maxes");
329
+ }
330
+ function fetchExerciseHistoryList(client) {
331
+ return getArray(client, "/v5/users/exercises/history", "athlete exercise history list");
332
+ }
333
+ /** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
334
+ async function searchExerciseHistory(client, query, limit = 20) {
335
+ return rankSearch(await fetchExerciseHistoryList(client), query, limit);
336
+ }
337
+ function fetchExerciseHistoryDetail(client, exerciseId, userId) {
338
+ return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
339
+ }
340
+ function fetchPersonalRecords(client, exerciseId) {
341
+ return getArray(client, `/v5/exercises/${exerciseId}/personalRecords`, "athlete personal records");
342
+ }
343
+ /** Last performance + PR for an exercise. `date` (YYYY-MM-DD) is required by the API. */
344
+ function fetchExerciseStats(client, exerciseId, userId, date) {
345
+ return getJson(client, `/v5/exercises/${exerciseId}/stats?userId=${userId}&date=${date}`, "athlete exercise stats");
346
+ }
347
+ /** Scheduled + completed workouts in an inclusive YYYY-MM-DD window. */
348
+ function fetchAthleteWorkouts(client, startDate, endDate) {
349
+ return getArray(client, `/3.0/athlete/programworkout/range?startDate=${startDate}&endDate=${endDate}`, "athlete workouts");
350
+ }
351
+ function fetchLeaderboard(client, workoutId, opts = {}) {
352
+ const qs = new URLSearchParams();
353
+ if (opts.page !== void 0) qs.set("page", String(opts.page));
354
+ if (opts.pageSize !== void 0) qs.set("pageSize", String(opts.pageSize));
355
+ if (opts.gender !== void 0) qs.set("gender", String(opts.gender));
356
+ const query = qs.toString();
357
+ return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
358
+ }
359
+ const SLOTS = 10;
360
+ function nonEmpty(value) {
361
+ return value !== void 0 && value !== null && String(value).trim() !== "";
362
+ }
363
+ /**
364
+ * Per-set prescriptions from the param_N_data slots, e.g. ["5 @ 225", "3 @ 245"] or ["AMRAP"].
365
+ * Values are kept raw (a non-numeric prescription like "AMRAP" or "8-12" must survive); the
366
+ * positional units come from the exercise's param types, mirroring the coach presenter.
367
+ */
368
+ function prescribedSets(ex) {
369
+ const out = [];
370
+ for (let i = 1; i <= SLOTS; i += 1) {
371
+ const p1 = ex[`param_1_data_${i}`];
372
+ const p2 = ex[`param_2_data_${i}`];
373
+ const has1 = nonEmpty(p1);
374
+ const has2 = nonEmpty(p2);
375
+ if (!has1 && !has2) continue;
376
+ if (has1 && has2) out.push(`${p1} @ ${p2}`);
377
+ else if (has1) out.push(String(p1));
378
+ else out.push(`@ ${p2}`);
379
+ }
380
+ return out;
381
+ }
382
+ function presentExercise$1(ex) {
383
+ const instruction = typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null;
384
+ return {
385
+ exerciseId: coerceInt(ex.exercise_id),
386
+ title: typeof ex.title === "string" ? ex.title : "",
387
+ instruction,
388
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type),
389
+ prescribed: prescribedSets(ex)
390
+ };
391
+ }
392
+ function presentBlock(set) {
393
+ const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
394
+ return {
395
+ order: coerceInt(set.order) ?? 0,
396
+ title: typeof set.title === "string" && set.title !== "" ? set.title : null,
397
+ instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
398
+ isTest: coerceInt(set.is_test) === 1,
399
+ exercises: exercises.filter(isRecord).map(presentExercise$1)
400
+ };
401
+ }
402
+ function str$1(v) {
403
+ return typeof v === "string" && v !== "" ? v : null;
404
+ }
405
+ /** Flatten one `/3.0/athlete/programworkout/range` item into a readable workout. */
406
+ function presentAthleteWorkout(raw) {
407
+ const rec = raw;
408
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
409
+ const workout = isRecord(ssw.workout) ? ssw.workout : {};
410
+ const sets = Array.isArray(workout.workoutSets) ? workout.workoutSets : [];
411
+ return {
412
+ id: coerceInt(rec.id),
413
+ date: str$1(rec.date) ?? "",
414
+ title: str$1(rec.workout_title) ?? "",
415
+ program: str$1(rec.program_title),
416
+ team: str$1(rec.team_title),
417
+ instruction: str$1(workout.instruction),
418
+ blocks: sets.filter(isRecord).map(presentBlock).sort((a, b) => a.order - b.order)
419
+ };
420
+ }
421
+ function presentAthleteWorkouts(list) {
422
+ return list.map(presentAthleteWorkout);
423
+ }
424
+ const MAX_PARAM_SLOTS = 10;
425
+ /**
426
+ * Build the body for `PUT /1.0/athlete/savedworkoutsetexercise/{id}`. The body uses
427
+ * snake_case keys matching the live API response shape. Each set slot (1-10) gets a
428
+ * `param_N_made` flag (1 if data is present, 0 otherwise) and `param_1_data_N` /
429
+ * `param_2_data_N` string values.
430
+ *
431
+ * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
432
+ * required from the live exercise record; everything else is derived from `results`.
433
+ *
434
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
435
+ */
436
+ function buildExerciseLogPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results) {
437
+ if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
438
+ const body = {
439
+ id: savedWorkoutSetExerciseId,
440
+ saved_workout_set_id: savedWorkoutSetId,
441
+ workout_set_exercise_id: workoutSetExerciseId,
442
+ completed: results.some((s) => s.param1 !== void 0 || s.param2 !== void 0) ? 1 : 0
443
+ };
444
+ for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
445
+ const slot = results[i - 1];
446
+ const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
447
+ const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
448
+ body[`param_${i}_made`] = p1 !== "" || p2 !== "" ? 1 : 0;
449
+ body[`param_1_data_${i}`] = p1;
450
+ body[`param_2_data_${i}`] = p2;
451
+ }
452
+ return body;
453
+ }
454
+ /**
455
+ * Locate the target saved workout set across all program workouts on the given day.
456
+ * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
457
+ * can look up `workout_set_exercise_id` by `savedWorkoutSetExerciseId`, and the raw set
458
+ * record so callers can build the set-completion PUT body via `buildSetCompletePayload`.
459
+ */
460
+ function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
461
+ for (const pw of workouts) {
462
+ const rec = pw;
463
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
464
+ const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
465
+ if (!sw) continue;
466
+ const sets = Array.isArray(sw.workoutSets) ? sw.workoutSets : [];
467
+ for (const s of sets) {
468
+ if (!isRecord(s)) continue;
469
+ if (coerceInt(s.id) === savedWorkoutSetId) {
470
+ const savedWorkoutId = coerceInt(sw.id);
471
+ if (!savedWorkoutId) continue;
472
+ return {
473
+ savedWorkoutId,
474
+ exercises: Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises.filter(isRecord) : [],
475
+ rawSet: s
476
+ };
477
+ }
478
+ }
479
+ }
480
+ throw new Error(`Saved workout set ${savedWorkoutSetId} not found on this date.`);
481
+ }
482
+ /**
483
+ * Build the body for `PUT /1.0/athlete/savedworkoutset/{id}` that marks the set completed.
484
+ *
485
+ * The API requires the **app's camelCase in-memory model**, not the snake_case shape the
486
+ * GET endpoints return. Key field mappings from the raw set record:
487
+ * saved_workout_id → sessionId
488
+ * workout_set_id → workoutSetId
489
+ * is_super_set (0/1) → isSuperSet (boolean)
490
+ * plain_text (0/1) → isPlainText (boolean)
491
+ * unit ("lb"/"kg") → isMetric (boolean)
492
+ * workoutSetExercises[].id → exercises (array of IDs)
493
+ *
494
+ * `exerciseIds` must be the savedWorkoutSetExercise IDs (not workout_set_exercise_ids).
495
+ *
496
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
497
+ */
498
+ function buildSetCompletePayload(rawSet, exerciseIds, complete) {
499
+ const id = coerceInt(rawSet.id);
500
+ const sessionId = coerceInt(rawSet.saved_workout_id);
501
+ const workoutSetId = coerceInt(rawSet.workout_set_id);
502
+ if (!id || !sessionId || !workoutSetId) throw new Error("Raw set is missing required id / saved_workout_id / workout_set_id.");
503
+ const unit = typeof rawSet.unit === "string" ? rawSet.unit : "";
504
+ return {
505
+ id,
506
+ sessionId,
507
+ workoutSetId,
508
+ completed: complete ? "1" : "0",
509
+ rx: coerceInt(rawSet.rx) ?? 0,
510
+ version: coerceInt(rawSet.version) ?? 0,
511
+ isMetric: unit.toLowerCase() === "kg",
512
+ isSuperSet: rawSet.is_super_set === 1 || rawSet.is_super_set === true,
513
+ isPlainText: rawSet.plain_text === 1 || rawSet.plain_text === true,
514
+ title: typeof rawSet.title === "string" ? rawSet.title : "",
515
+ instruction: typeof rawSet.instruction === "string" ? rawSet.instruction : "",
516
+ notes: rawSet.notes ?? null,
517
+ exercises: [...exerciseIds]
518
+ };
519
+ }
520
+ /**
521
+ * Record entered set results for one saved workout set.
522
+ *
523
+ * **Two-step write (both required for data to persist):**
524
+ * 1. For each exercise in `results`:
525
+ * `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}` with the
526
+ * per-slot `param_N_data_M` values. This is the only path that actually stores reps
527
+ * and weight — the `savedworkoutset` PUT accepts the same fields but silently discards
528
+ * them.
529
+ * 2. `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with `completed:"1"` to
530
+ * mark the block done and make the entry visible as a logged set in history.
531
+ *
532
+ * The date is used to locate the saved workout (via the range endpoint) so we can resolve
533
+ * `saved_workout_id` and `workout_set_exercise_id` for each exercise. Both are required
534
+ * by the respective PUT bodies.
535
+ */
536
+ async function logAthleteSet(client, args) {
537
+ const { exercises, rawSet } = findSavedWorkoutSet(await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId);
538
+ let exercisesLogged = 0;
539
+ for (const result of args.results) {
540
+ const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
541
+ if (!ex) throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${args.savedWorkoutSetId}.`);
542
+ const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
543
+ if (!workoutSetExerciseId) throw new Error(`Could not resolve workout_set_exercise_id for exercise ${result.savedWorkoutSetExerciseId}.`);
544
+ const body = buildExerciseLogPayload(result.savedWorkoutSetExerciseId, args.savedWorkoutSetId, workoutSetExerciseId, result.sets);
545
+ const res = await client.request("PUT", `/1.0/athlete/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}`, { body });
546
+ if (!res.ok) throw new Error(`Failed to log exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).`);
547
+ exercisesLogged += 1;
548
+ }
549
+ const setBody = buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true);
550
+ const setRes = await client.request("PUT", `/1.0/athlete/savedworkoutset/${args.savedWorkoutSetId}`, { body: setBody });
551
+ if (!setRes.ok) throw new Error(`Failed to mark workout set ${args.savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
552
+ return {
553
+ savedWorkoutSetId: args.savedWorkoutSetId,
554
+ exercisesLogged
555
+ };
556
+ }
557
+ /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
558
+ function presentExerciseHistory(detail) {
559
+ return {
560
+ liftPRs: (detail.liftPRs ?? []).map((p) => ({
561
+ description: p.description ?? null,
562
+ reps: p.reps ?? null,
563
+ weight: p.weight ?? null,
564
+ date: p.dateCompleted ?? null
565
+ })),
566
+ sessions: (detail.history ?? []).map((h) => ({
567
+ date: h.dateCompleted,
568
+ abr: h.abr ?? null,
569
+ estimated1RM: h.bestEstimated1RM ?? null,
570
+ sets: (h.sets ?? []).map((s) => ({
571
+ setNumber: s.setNumber,
572
+ value: s.formattedValue ?? null
573
+ }))
574
+ }))
575
+ };
576
+ }
276
577
  //#endregion
277
578
  //#region src/workout-encode.ts
278
579
  const LEADERBOARD_TYPE = {
@@ -840,4 +1141,4 @@ var ExerciseLibrary = class {
840
1141
  }
841
1142
  };
842
1143
  //#endregion
843
- export { ExerciseLibrary, LEADERBOARD_LABEL, LEADERBOARD_TYPE, MemoryLibraryCache, PARAM_NONE, PARAM_PCT_MAX, PARAM_REPS, PARAM_RPE, PARAM_UNIT, PARAM_WEIGHT, TrainHeroicAuthError, TrainHeroicClient, asExerciseList, buildBlockPayload, buildCommentPayload, buildSearchText, buildSession, checkResponse, chunk, coerceInt, coerceNum, collectAdvisories, deleteComment, exerciseUnits, fetchStreams, isRecord, loginTrainHeroic, makeExercise, presentExercise, publishSession, rankSearch, readLive, readSession, removeSession, repsList, resolveLeaderboard, sendComment, setSessionInstruction, unitAdvisory, unitLabel, unwrapEnvelope, withUnits };
1144
+ export { ExerciseLibrary, LEADERBOARD_LABEL, LEADERBOARD_TYPE, MemoryLibraryCache, PARAM_NONE, PARAM_PCT_MAX, PARAM_REPS, PARAM_RPE, PARAM_UNIT, PARAM_WEIGHT, TrainHeroicAuthError, TrainHeroicClient, asExerciseList, buildBlockPayload, buildCommentPayload, buildExerciseLogPayload, buildSearchText, buildSession, buildSetCompletePayload, checkResponse, chunk, coerceInt, coerceNum, collectAdvisories, deleteComment, exerciseUnits, fetchAthletePrefs, fetchAthleteProfileSummary, fetchAthleteUser, fetchAthleteWorkouts, fetchExerciseHistoryDetail, fetchExerciseHistoryList, fetchExerciseStats, fetchLeaderboard, fetchPersonalRecords, fetchStreams, fetchWorkingMaxes, findSavedWorkoutSet, isRecord, logAthleteSet, loginTrainHeroic, makeExercise, mapPool, presentAthleteWorkout, presentAthleteWorkouts, presentExercise, presentExerciseHistory, publishSession, rankSearch, readLive, readSession, removeSession, repsList, resolveAthleteUserId, resolveLeaderboard, searchExerciseHistory, sendComment, setSessionInstruction, unitAdvisory, unitLabel, unwrapEnvelope, withUnits };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "zod": "^4.4.3",
30
- "@trainheroic-unofficial/dto": "0.3.0"
30
+ "@trainheroic-unofficial/dto": "0.4.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^26.0.0",