@trainheroic-unofficial/athlete-mcp 1.7.1 → 1.7.3

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.
Files changed (2) hide show
  1. package/dist/server.mjs +110 -101
  2. package/package.json +6 -6
package/dist/server.mjs CHANGED
@@ -169,6 +169,26 @@ const loggedSetSchema = z.object({
169
169
  * always sequential).
170
170
  */
171
171
  const loggedSetWithSlotSchema = loggedSetSchema.extend({ slot: z.number().int().min(1).max(10).optional() });
172
+ function requireUniqueExerciseIds(results, ctx) {
173
+ const seen = /* @__PURE__ */ new Set();
174
+ results.forEach((result, index) => {
175
+ const id = String(result.savedWorkoutSetExerciseId).replace(/^0+(?=\d)/u, "");
176
+ if (seen.has(id)) ctx.addIssue({
177
+ code: "custom",
178
+ message: `savedWorkoutSetExerciseId ${id} appears more than once; combine its sets into one result.`,
179
+ path: [index, "savedWorkoutSetExerciseId"]
180
+ });
181
+ seen.add(id);
182
+ });
183
+ }
184
+ const loggedExerciseResultsSchema = z.array(z.object({
185
+ savedWorkoutSetExerciseId: idArgSchema,
186
+ sets: z.array(loggedSetWithSlotSchema).min(1)
187
+ })).min(1).superRefine(requireUniqueExerciseIds);
188
+ const prescribedExerciseResultsSchema = z.array(z.object({
189
+ savedWorkoutSetExerciseId: idArgSchema,
190
+ sets: z.array(loggedSetSchema).min(1)
191
+ })).min(1).superRefine(requireUniqueExerciseIds);
172
192
  /**
173
193
  * Args for the set-logging write. `date` (the workout's day) locates the saved
174
194
  * workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
@@ -180,20 +200,14 @@ const loggedSetWithSlotSchema = loggedSetSchema.extend({ slot: z.number().int().
180
200
  const logSetArgsSchema = z.object({
181
201
  date: dateString,
182
202
  savedWorkoutSetId: idArgSchema,
183
- results: z.array(z.object({
184
- savedWorkoutSetExerciseId: idArgSchema,
185
- sets: z.array(loggedSetWithSlotSchema).min(1)
186
- })).min(1)
203
+ results: loggedExerciseResultsSchema
187
204
  });
188
205
  logSetArgsSchema.extend({ athleteId: idArgSchema });
189
206
  z.object({
190
207
  date: dateString,
191
208
  savedWorkoutSetId: idArgSchema,
192
209
  athleteId: idArgSchema,
193
- results: z.array(z.object({
194
- savedWorkoutSetExerciseId: idArgSchema,
195
- sets: z.array(loggedSetSchema).min(1)
196
- })).min(1)
210
+ results: prescribedExerciseResultsSchema
197
211
  });
198
212
  /**
199
213
  * Args for the coach per-athlete exercise swap: replace the exercise prescribed in one of a
@@ -1129,6 +1143,75 @@ function presentExerciseHistory(detail) {
1129
1143
  };
1130
1144
  }
1131
1145
  //#endregion
1146
+ //#region ../js/src/exercise-set-payload.ts
1147
+ function slotData(exercise, key) {
1148
+ const value = exercise?.[key];
1149
+ return value === void 0 || value === null ? "" : String(value);
1150
+ }
1151
+ function slotIsActive(exercise, slot, targeted = false) {
1152
+ return targeted || slotData(exercise, `param_1_data_${slot}`) !== "" || slotData(exercise, `param_2_data_${slot}`) !== "" || coerceInt(exercise?.[`param_${slot}_made`]) === 1;
1153
+ }
1154
+ /** True when an existing saved-copy exercise has at least one active slot and all are performed. */
1155
+ function exerciseIsFullyLogged(exercise) {
1156
+ let anyActive = false;
1157
+ for (let slot = 1; slot <= 10; slot += 1) {
1158
+ if (!slotIsActive(exercise, slot)) continue;
1159
+ anyActive = true;
1160
+ if (coerceInt(exercise[`param_${slot}_made`]) !== 1) return false;
1161
+ }
1162
+ return anyActive;
1163
+ }
1164
+ /**
1165
+ * Build the typed body for `PUT /1.0/{role}/savedworkoutsetexercise/{id}`.
1166
+ *
1167
+ * A log marks only slots carrying reps as performed. The exercise completes only when every active
1168
+ * slot is performed; active slots are prescribed values, prior performed values, or slots targeted
1169
+ * by this write. Untargeted performed slots carry over, while untouched prescription values are
1170
+ * blanked so TrainHeroic cannot fabricate performed results from them. A prescription replaces the
1171
+ * full payload and never marks slots or the exercise complete.
1172
+ */
1173
+ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode, existing) {
1174
+ if (results.length > 10) throw new Error(`At most 10 sets are supported per exercise; got ${results.length}.`);
1175
+ const bySlot = /* @__PURE__ */ new Map();
1176
+ results.forEach((set, index) => {
1177
+ const slot = set.slot ?? index + 1;
1178
+ if (slot < 1 || slot > 10) throw new Error(`Set slot ${slot} is out of range; slots are 1–10.`);
1179
+ if (bySlot.has(slot)) throw new Error(`Two sets target slot ${slot}; each slot can be written once.`);
1180
+ bySlot.set(slot, set);
1181
+ });
1182
+ const logging = mode === "log";
1183
+ const body = {
1184
+ id: savedWorkoutSetExerciseId,
1185
+ saved_workout_set_id: savedWorkoutSetId,
1186
+ workout_set_exercise_id: workoutSetExerciseId,
1187
+ completed: 0
1188
+ };
1189
+ let anyMade = false;
1190
+ let allActiveSlotsMade = true;
1191
+ for (let slot = 1; slot <= 10; slot += 1) {
1192
+ const target = bySlot.get(slot);
1193
+ let param1 = "";
1194
+ let param2 = "";
1195
+ let made = 0;
1196
+ if (target) {
1197
+ param1 = target.param1 === void 0 ? "" : String(target.param1);
1198
+ param2 = target.param2 === void 0 ? "" : String(target.param2);
1199
+ made = logging && param1 !== "" ? 1 : 0;
1200
+ } else if (logging && coerceInt(existing?.[`param_${slot}_made`]) === 1) {
1201
+ param1 = slotData(existing, `param_1_data_${slot}`);
1202
+ param2 = slotData(existing, `param_2_data_${slot}`);
1203
+ made = 1;
1204
+ }
1205
+ anyMade ||= made === 1;
1206
+ if (slotIsActive(existing, slot, target !== void 0) && made !== 1) allActiveSlotsMade = false;
1207
+ body[`param_${slot}_made`] = made;
1208
+ body[`param_1_data_${slot}`] = param1;
1209
+ body[`param_2_data_${slot}`] = param2;
1210
+ }
1211
+ body.completed = logging && anyMade && allActiveSlotsMade ? 1 : 0;
1212
+ return body;
1213
+ }
1214
+ //#endregion
1132
1215
  //#region ../js/src/athlete-set-write.ts
1133
1216
  /**
1134
1217
  * Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
@@ -1154,102 +1237,25 @@ function toSetResults(results) {
1154
1237
  });
1155
1238
  }
1156
1239
  /**
1157
- * Build the body for `PUT /1.0/{role}/savedworkoutsetexercise/{id}`. The body uses snake_case
1158
- * keys matching the live API response shape. Each set slot (1-10) carries `param_1_data_N` /
1159
- * `param_2_data_N` string values plus a `param_N_made` flag.
1160
- *
1161
- * `mode` selects which write this is — the same endpoint serves both:
1162
- * - `"log"`: the values ARE a performed result, so `param_N_made` is 1 where the slot has data
1163
- * and the exercise `completed` flag is 1 when any set has logged data.
1164
- * - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
1165
- * `completed` left at 0 so the set is not marked done. This matches what the app sends when a
1166
- * coach edits an athlete's prescribed reps/weight.
1167
- *
1168
- * Each set fills a 1-based slot: its explicit `slot`, or its sequential position in `results`
1169
- * when `slot` is omitted. A `log` carrying the live exercise record in `existing` keeps the slots
1170
- * it does not write that were ALREADY performed (`param_N_made === 1`), so logging a second part of
1171
- * a set does not wipe the earlier-logged sets. A slot holding only un-logged prescription pre-fill
1172
- * (`param_N_made === 0`) is left blank rather than carried over: marking the set completed makes
1173
- * the server flag every data-bearing slot performed, so preserving that pre-fill would fabricate
1174
- * sets the athlete never did. The prescription is unaffected (it lives in the separate `workout`
1175
- * copy, not this saved copy). A `prescribe` ignores `existing` and replaces the whole prescription.
1176
- *
1177
- * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
1178
- * required from the live exercise record; everything else is derived from `results` and the
1179
- * preserved slots of `existing`.
1180
- *
1181
- * Exported for unit testing — callers should use `logAthleteSet` / `prescribeForAthlete` instead.
1240
+ * Whether every exercise in a saved workout set is fully logged — either completed by its projected
1241
+ * payload in this call (`projectedCompletion`) or already carrying made flags for every active slot.
1242
+ * This gates the set-completion PUT so a partial exercise cannot close a superset/circuit and cause
1243
+ * TrainHeroic to backfill its omitted slots or untouched siblings.
1182
1244
  */
1183
- function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode, existing) {
1184
- if (results.length > 10) throw new Error(`At most 10 sets are supported per exercise; got ${results.length}.`);
1185
- const bySlot = /* @__PURE__ */ new Map();
1186
- results.forEach((set, i) => {
1187
- const slot = set.slot ?? i + 1;
1188
- if (slot < 1 || slot > 10) throw new Error(`Set slot ${slot} is out of range; slots are 1–10.`);
1189
- if (bySlot.has(slot)) throw new Error(`Two sets target slot ${slot}; each slot can be written once.`);
1190
- bySlot.set(slot, set);
1191
- });
1192
- const performed = mode === "log";
1193
- const carryOver = performed && existing !== void 0;
1194
- const body = {
1195
- id: savedWorkoutSetExerciseId,
1196
- saved_workout_set_id: savedWorkoutSetId,
1197
- workout_set_exercise_id: workoutSetExerciseId
1198
- };
1199
- let anyMade = false;
1200
- for (let i = 1; i <= 10; i += 1) {
1201
- const target = bySlot.get(i);
1202
- let p1;
1203
- let p2;
1204
- let made;
1205
- if (target) {
1206
- p1 = target.param1 !== void 0 ? String(target.param1) : "";
1207
- p2 = target.param2 !== void 0 ? String(target.param2) : "";
1208
- made = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
1209
- } else if (carryOver && coerceInt(existing?.[`param_${i}_made`]) === 1) {
1210
- p1 = existingSlotData(existing, `param_1_data_${i}`);
1211
- p2 = existingSlotData(existing, `param_2_data_${i}`);
1212
- made = 1;
1213
- } else {
1214
- p1 = "";
1215
- p2 = "";
1216
- made = 0;
1217
- }
1218
- if (made === 1) anyMade = true;
1219
- body[`param_${i}_made`] = made;
1220
- body[`param_1_data_${i}`] = p1;
1221
- body[`param_2_data_${i}`] = p2;
1222
- }
1223
- body.completed = performed && anyMade ? 1 : 0;
1224
- return body;
1225
- }
1226
- /** Read a saved-copy slot value (`param_1_data_N` / `param_2_data_N`) as the string the body uses. */
1227
- function existingSlotData(existing, key) {
1228
- const v = existing?.[key];
1229
- return v === void 0 || v === null ? "" : String(v);
1230
- }
1231
- /** True when a saved-copy exercise already carries a performed slot (any `param_N_made` === 1). */
1232
- function exerciseHasLoggedData(ex) {
1233
- for (let i = 1; i <= 10; i += 1) if (coerceInt(ex[`param_${i}_made`]) === 1) return true;
1234
- return false;
1235
- }
1236
- /** True when a result carries at least one non-empty param value (so it produces a performed slot). */
1237
- function resultHasData(result) {
1238
- return result.sets.some((s) => s.param1 !== void 0 && String(s.param1) !== "" || s.param2 !== void 0 && String(s.param2) !== "");
1239
- }
1240
- /**
1241
- * Whether every exercise in a saved workout set now has logged data — either written with data in
1242
- * this call (`loggedIds`) or already carrying a performed slot. Gates the set-completion PUT: a
1243
- * superset/circuit stays open until the last exercise is logged, so completing it on a partial log
1244
- * does not flip its still-empty siblings to "done". An exercise written with only empty values does
1245
- * not count (it would not be marked performed), so an all-empty log never completes the set.
1246
- */
1247
- function isSetFullyLogged(exercises, loggedIds) {
1245
+ function isSetFullyLogged(exercises, projectedCompletion) {
1248
1246
  return exercises.every((ex) => {
1249
1247
  const id = coerceInt(ex.id);
1250
- return id !== null && loggedIds.has(id) || exerciseHasLoggedData(ex);
1248
+ if (id !== null && projectedCompletion.has(id)) return projectedCompletion.get(id) === true;
1249
+ return exerciseIsFullyLogged(ex);
1251
1250
  });
1252
1251
  }
1252
+ function assertUniqueExerciseResults(results) {
1253
+ const seen = /* @__PURE__ */ new Set();
1254
+ for (const result of results) {
1255
+ if (seen.has(result.savedWorkoutSetExerciseId)) throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} appears more than once; combine its sets into one result.`);
1256
+ seen.add(result.savedWorkoutSetExerciseId);
1257
+ }
1258
+ }
1253
1259
  /**
1254
1260
  * Locate the target saved workout set across all program workouts on the given day.
1255
1261
  * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
@@ -1422,9 +1428,11 @@ async function swapAthleteExercise(client, args) {
1422
1428
  */
1423
1429
  async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode) {
1424
1430
  const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
1431
+ assertUniqueExerciseResults(results);
1425
1432
  const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
1426
1433
  const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
1427
1434
  let exercisesWritten = 0;
1435
+ const projectedCompletion = /* @__PURE__ */ new Map();
1428
1436
  for (const result of results) {
1429
1437
  const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
1430
1438
  if (!ex) {
@@ -1445,11 +1453,12 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1445
1453
  const readOnly = target.role === "coach" && (res.status === 401 || res.status === 403) ? ` Athlete ${target.athleteId} appears to be read-only for changes — TrainHeroic's seeded demo/sample athletes return ${res.status} here; writes only persist for real (invited) athletes.` : "";
1446
1454
  throw new Error(`Failed to write exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).${readOnly}`);
1447
1455
  }
1456
+ projectedCompletion.set(result.savedWorkoutSetExerciseId, body.completed === 1);
1448
1457
  exercisesWritten += 1;
1449
1458
  }
1450
1459
  let setCompleted = false;
1451
1460
  if (mode === "log") {
1452
- if (isSetFullyLogged(exercises, new Set(results.filter(resultHasData).map((r) => r.savedWorkoutSetExerciseId)))) {
1461
+ if (isSetFullyLogged(exercises, projectedCompletion)) {
1453
1462
  const setBody = {
1454
1463
  ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1455
1464
  ...extra
@@ -2052,7 +2061,7 @@ function registerAthleteTrainingTools(server, ctx) {
2052
2061
  }
2053
2062
  //#endregion
2054
2063
  //#region package.json
2055
- var version = "1.7.1";
2064
+ var version = "1.7.3";
2056
2065
  //#endregion
2057
2066
  //#region src/server.ts
2058
2067
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,15 +21,15 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.29.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "1.7.1",
25
- "@trainheroic-unofficial/js": "1.7.1"
24
+ "@trainheroic-unofficial/core": "1.7.3",
25
+ "@trainheroic-unofficial/js": "1.7.3"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^26.0.1",
28
+ "@types/node": "^26.1.0",
29
29
  "tsdown": "^0.22.3",
30
- "tsx": "^4.22.4",
30
+ "tsx": "^4.23.0",
31
31
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.9"
32
+ "vitest": "^4.1.10"
33
33
  },
34
34
  "scripts": {
35
35
  "start": "tsx src/server.ts",