@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 +120 -3
- package/dist/index.mjs +303 -2
- package/package.json +2 -2
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
|
|
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
|
+
"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.
|
|
30
|
+
"@trainheroic-unofficial/dto": "0.4.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^26.0.0",
|