@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.
- package/dist/server.mjs +110 -106
- 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:
|
|
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:
|
|
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
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
-
"@trainheroic-unofficial/js": "1.7.
|
|
24
|
+
"@trainheroic-unofficial/core": "1.7.3",
|
|
25
|
+
"@trainheroic-unofficial/js": "1.7.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@types/node": "^26.0
|
|
28
|
+
"@types/node": "^26.1.0",
|
|
29
29
|
"tsdown": "^0.22.3",
|
|
30
|
-
"tsx": "^4.
|
|
30
|
+
"tsx": "^4.23.0",
|
|
31
31
|
"typescript": "^6.0.3",
|
|
32
|
-
"vitest": "^4.1.
|
|
32
|
+
"vitest": "^4.1.10"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"start": "tsx src/server.ts",
|