@trainheroic-unofficial/js 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +132 -4
- package/dist/index.mjs +327 -10
- 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
|
|
|
@@ -69,8 +69,19 @@ declare const PARAM_RPE = 14;
|
|
|
69
69
|
declare function coerceInt(value: unknown): number | null;
|
|
70
70
|
declare function coerceNum(value: unknown): number | null;
|
|
71
71
|
declare function unitLabel(paramType: unknown): string | null;
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Fixed measurement units for an exercise, ordered by entry slot (param 1, then param 2).
|
|
74
|
+
* Positional, not semantic: some exercises reverse the slots, so the array is not labelled
|
|
75
|
+
* by role. A null entry is an unset slot.
|
|
76
|
+
*/
|
|
77
|
+
declare function exerciseUnits(param1: unknown, param2: unknown): Array<string | null>;
|
|
78
|
+
/** Present a row for display: drop the raw param-type codes, surface units positionally. */
|
|
73
79
|
declare function withUnits(row: ExerciseRow): ExerciseView;
|
|
80
|
+
/**
|
|
81
|
+
* Present a full raw exercise object for display: drop the raw param-type codes and add the
|
|
82
|
+
* positional `units` array. Keeps every other field of the raw object intact.
|
|
83
|
+
*/
|
|
84
|
+
declare function presentExercise(raw: Record<string, unknown>): Record<string, unknown>;
|
|
74
85
|
declare function buildSearchText(title: string): string;
|
|
75
86
|
/** Strip the {"success":1,"data":X} envelope some 2.0/coach endpoints use. */
|
|
76
87
|
declare function unwrapEnvelope(body: unknown): unknown;
|
|
@@ -84,9 +95,15 @@ declare function isRecord(x: unknown): x is Record<string, unknown>;
|
|
|
84
95
|
*/
|
|
85
96
|
declare function rankSearch<T extends {
|
|
86
97
|
title: string;
|
|
87
|
-
can_edit
|
|
98
|
+
can_edit?: number;
|
|
88
99
|
}>(rows: readonly T[], query: string, limit: number): T[];
|
|
89
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[]>;
|
|
90
107
|
/**
|
|
91
108
|
* The exercise-library surface the tools depend on, so the tools work over either a
|
|
92
109
|
* D1-backed mirror (hosted, multi-tenant) or an in-memory cache (local, single-user).
|
|
@@ -106,6 +123,117 @@ interface ExerciseIndex {
|
|
|
106
123
|
stats(): Promise<Record<string, unknown>>;
|
|
107
124
|
}
|
|
108
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
|
|
109
237
|
//#region src/workout-encode.d.ts
|
|
110
238
|
declare const LEADERBOARD_TYPE: Readonly<Record<string, number>>;
|
|
111
239
|
declare const LEADERBOARD_LABEL: Readonly<Record<number, string>>;
|
|
@@ -195,4 +323,4 @@ declare class ExerciseLibrary implements ExerciseIndex {
|
|
|
195
323
|
stats(): Promise<Record<string, unknown>>;
|
|
196
324
|
}
|
|
197
325
|
//#endregion
|
|
198
|
-
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, fetchStreams, isRecord, loginTrainHeroic, makeExercise, 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
|
@@ -175,12 +175,31 @@ function unitLabel(paramType) {
|
|
|
175
175
|
if (t === null) return null;
|
|
176
176
|
return PARAM_UNIT[t] ?? null;
|
|
177
177
|
}
|
|
178
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Fixed measurement units for an exercise, ordered by entry slot (param 1, then param 2).
|
|
180
|
+
* Positional, not semantic: some exercises reverse the slots, so the array is not labelled
|
|
181
|
+
* by role. A null entry is an unset slot.
|
|
182
|
+
*/
|
|
183
|
+
function exerciseUnits(param1, param2) {
|
|
184
|
+
return [unitLabel(param1), unitLabel(param2)];
|
|
185
|
+
}
|
|
186
|
+
/** Present a row for display: drop the raw param-type codes, surface units positionally. */
|
|
179
187
|
function withUnits(row) {
|
|
188
|
+
const { param_1_type, param_2_type, ...rest } = row;
|
|
189
|
+
return {
|
|
190
|
+
...rest,
|
|
191
|
+
units: exerciseUnits(param_1_type, param_2_type)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Present a full raw exercise object for display: drop the raw param-type codes and add the
|
|
196
|
+
* positional `units` array. Keeps every other field of the raw object intact.
|
|
197
|
+
*/
|
|
198
|
+
function presentExercise(raw) {
|
|
199
|
+
const { param_1_type, param_2_type, ...rest } = raw;
|
|
180
200
|
return {
|
|
181
|
-
...
|
|
182
|
-
|
|
183
|
-
param_2_unit: unitLabel(row.param_2_type)
|
|
201
|
+
...rest,
|
|
202
|
+
units: exerciseUnits(param_1_type, param_2_type)
|
|
184
203
|
};
|
|
185
204
|
}
|
|
186
205
|
function buildSearchText(title) {
|
|
@@ -242,7 +261,7 @@ function rankSearch(rows, query, limit) {
|
|
|
242
261
|
if (title.startsWith(q)) score += 100;
|
|
243
262
|
for (const tok of tokens) if (title.includes(tok)) score += 10;
|
|
244
263
|
score -= title.length * .05;
|
|
245
|
-
if (row.can_edit === 0) score += 1;
|
|
264
|
+
if ((row.can_edit ?? 0) === 0) score += 1;
|
|
246
265
|
return {
|
|
247
266
|
row,
|
|
248
267
|
score
|
|
@@ -254,6 +273,307 @@ function chunk(items, size) {
|
|
|
254
273
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
255
274
|
return out;
|
|
256
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
|
+
}
|
|
257
577
|
//#endregion
|
|
258
578
|
//#region src/workout-encode.ts
|
|
259
579
|
const LEADERBOARD_TYPE = {
|
|
@@ -741,10 +1061,7 @@ var ExerciseLibrary = class {
|
|
|
741
1061
|
await this.ensureFresh();
|
|
742
1062
|
const s = this.#byId.get(id);
|
|
743
1063
|
if (!s) return null;
|
|
744
|
-
|
|
745
|
-
full.param_1_unit = unitLabel(full.param_1_type);
|
|
746
|
-
full.param_2_unit = unitLabel(full.param_2_type);
|
|
747
|
-
return full;
|
|
1064
|
+
return presentExercise(s.raw);
|
|
748
1065
|
}
|
|
749
1066
|
async defaults(id) {
|
|
750
1067
|
const s = this.#byId.get(id);
|
|
@@ -824,4 +1141,4 @@ var ExerciseLibrary = class {
|
|
|
824
1141
|
}
|
|
825
1142
|
};
|
|
826
1143
|
//#endregion
|
|
827
|
-
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, fetchStreams, isRecord, loginTrainHeroic, makeExercise, 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",
|