@trainheroic-unofficial/athlete-mcp 1.7.2 → 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 -106
  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,107 +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 reps
1163
- * entered (`param1`) and the exercise `completed` flag is 1 when any set is performed. A slot
1164
- * carrying only a weight (`param2`) with no reps is a target, not a performed set, so it stays
1165
- * un-made — matching the app, which shows no completion checkmark until reps are logged.
1166
- * - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
1167
- * `completed` left at 0 so the set is not marked done. This matches what the app sends when a
1168
- * coach edits an athlete's prescribed reps/weight.
1169
- *
1170
- * Each set fills a 1-based slot: its explicit `slot`, or its sequential position in `results`
1171
- * when `slot` is omitted. A `log` carrying the live exercise record in `existing` keeps the slots
1172
- * it does not write that were ALREADY performed (`param_N_made === 1`), so logging a second part of
1173
- * a set does not wipe the earlier-logged sets. A slot holding only un-logged prescription pre-fill
1174
- * (`param_N_made === 0`) is left blank rather than carried over: marking the set completed makes
1175
- * the server flag every data-bearing slot performed, so preserving that pre-fill would fabricate
1176
- * sets the athlete never did. The prescription is unaffected (it lives in the separate `workout`
1177
- * copy, not this saved copy). A `prescribe` ignores `existing` and replaces the whole prescription.
1178
- *
1179
- * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
1180
- * required from the live exercise record; everything else is derived from `results` and the
1181
- * preserved slots of `existing`.
1182
- *
1183
- * 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.
1184
1244
  */
1185
- function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode, existing) {
1186
- if (results.length > 10) throw new Error(`At most 10 sets are supported per exercise; got ${results.length}.`);
1187
- const bySlot = /* @__PURE__ */ new Map();
1188
- results.forEach((set, i) => {
1189
- const slot = set.slot ?? i + 1;
1190
- if (slot < 1 || slot > 10) throw new Error(`Set slot ${slot} is out of range; slots are 1–10.`);
1191
- if (bySlot.has(slot)) throw new Error(`Two sets target slot ${slot}; each slot can be written once.`);
1192
- bySlot.set(slot, set);
1193
- });
1194
- const performed = mode === "log";
1195
- const carryOver = performed && existing !== void 0;
1196
- const body = {
1197
- id: savedWorkoutSetExerciseId,
1198
- saved_workout_set_id: savedWorkoutSetId,
1199
- workout_set_exercise_id: workoutSetExerciseId
1200
- };
1201
- let anyMade = false;
1202
- for (let i = 1; i <= 10; i += 1) {
1203
- const target = bySlot.get(i);
1204
- let p1;
1205
- let p2;
1206
- let made;
1207
- if (target) {
1208
- p1 = target.param1 !== void 0 ? String(target.param1) : "";
1209
- p2 = target.param2 !== void 0 ? String(target.param2) : "";
1210
- made = performed && p1 !== "" ? 1 : 0;
1211
- } else if (carryOver && coerceInt(existing?.[`param_${i}_made`]) === 1) {
1212
- p1 = existingSlotData(existing, `param_1_data_${i}`);
1213
- p2 = existingSlotData(existing, `param_2_data_${i}`);
1214
- made = 1;
1215
- } else {
1216
- p1 = "";
1217
- p2 = "";
1218
- made = 0;
1219
- }
1220
- if (made === 1) anyMade = true;
1221
- body[`param_${i}_made`] = made;
1222
- body[`param_1_data_${i}`] = p1;
1223
- body[`param_2_data_${i}`] = p2;
1224
- }
1225
- body.completed = performed && anyMade ? 1 : 0;
1226
- return body;
1227
- }
1228
- /** Read a saved-copy slot value (`param_1_data_N` / `param_2_data_N`) as the string the body uses. */
1229
- function existingSlotData(existing, key) {
1230
- const v = existing?.[key];
1231
- return v === void 0 || v === null ? "" : String(v);
1232
- }
1233
- /** True when a saved-copy exercise already carries a performed slot (any `param_N_made` === 1). */
1234
- function exerciseHasLoggedData(ex) {
1235
- for (let i = 1; i <= 10; i += 1) if (coerceInt(ex[`param_${i}_made`]) === 1) return true;
1236
- return false;
1237
- }
1238
- /** True when a result carries at least one set with reps entered (so it produces a performed slot).
1239
- * A weight-only set (`param2` set, `param1` blank) is a target, not a performed result, and so does
1240
- * not count — mirroring the `made` rule in {@link buildExerciseSetPayload}. */
1241
- function resultHasPerformedSet(result) {
1242
- return result.sets.some((s) => s.param1 !== void 0 && String(s.param1) !== "");
1243
- }
1244
- /**
1245
- * Whether every exercise in a saved workout set now has logged data — either written with data in
1246
- * this call (`loggedIds`) or already carrying a performed slot. Gates the set-completion PUT: a
1247
- * superset/circuit stays open until the last exercise is logged, so completing it on a partial log
1248
- * does not flip its still-empty siblings to "done". An exercise written with no reps — empty values,
1249
- * or a weight (`param2`) with no reps (`param1`) — does not count (it would not be marked performed),
1250
- * so a no-reps log never completes the set.
1251
- */
1252
- function isSetFullyLogged(exercises, loggedIds) {
1245
+ function isSetFullyLogged(exercises, projectedCompletion) {
1253
1246
  return exercises.every((ex) => {
1254
1247
  const id = coerceInt(ex.id);
1255
- return id !== null && loggedIds.has(id) || exerciseHasLoggedData(ex);
1248
+ if (id !== null && projectedCompletion.has(id)) return projectedCompletion.get(id) === true;
1249
+ return exerciseIsFullyLogged(ex);
1256
1250
  });
1257
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
+ }
1258
1259
  /**
1259
1260
  * Locate the target saved workout set across all program workouts on the given day.
1260
1261
  * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
@@ -1427,9 +1428,11 @@ async function swapAthleteExercise(client, args) {
1427
1428
  */
1428
1429
  async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode) {
1429
1430
  const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
1431
+ assertUniqueExerciseResults(results);
1430
1432
  const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
1431
1433
  const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
1432
1434
  let exercisesWritten = 0;
1435
+ const projectedCompletion = /* @__PURE__ */ new Map();
1433
1436
  for (const result of results) {
1434
1437
  const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
1435
1438
  if (!ex) {
@@ -1450,11 +1453,12 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1450
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.` : "";
1451
1454
  throw new Error(`Failed to write exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).${readOnly}`);
1452
1455
  }
1456
+ projectedCompletion.set(result.savedWorkoutSetExerciseId, body.completed === 1);
1453
1457
  exercisesWritten += 1;
1454
1458
  }
1455
1459
  let setCompleted = false;
1456
1460
  if (mode === "log") {
1457
- if (isSetFullyLogged(exercises, new Set(results.filter(resultHasPerformedSet).map((r) => r.savedWorkoutSetExerciseId)))) {
1461
+ if (isSetFullyLogged(exercises, projectedCompletion)) {
1458
1462
  const setBody = {
1459
1463
  ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1460
1464
  ...extra
@@ -2057,7 +2061,7 @@ function registerAthleteTrainingTools(server, ctx) {
2057
2061
  }
2058
2062
  //#endregion
2059
2063
  //#region package.json
2060
- var version = "1.7.2";
2064
+ var version = "1.7.3";
2061
2065
  //#endregion
2062
2066
  //#region src/server.ts
2063
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.2",
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.2",
25
- "@trainheroic-unofficial/js": "1.7.2"
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",