@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.
- package/dist/server.mjs +110 -101
- 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,102 +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 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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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",
|