@trainheroic-unofficial/athlete-mcp 3.5.0 → 3.5.2
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 +288 -87
- package/package.json +3 -3
package/dist/server.mjs
CHANGED
|
@@ -715,6 +715,12 @@ const exerciseSpecSchema = z.object({
|
|
|
715
715
|
instr: z.string().optional(),
|
|
716
716
|
param_1_type: z.number().optional(),
|
|
717
717
|
param_2_type: z.number().optional()
|
|
718
|
+
}).superRefine((exercise, ctx) => {
|
|
719
|
+
if (Array.isArray(exercise.reps) && Array.isArray(exercise.weight) && exercise.reps.length !== exercise.weight.length) ctx.addIssue({
|
|
720
|
+
code: "custom",
|
|
721
|
+
message: `Per-set reps and weight arrays must have the same length; received ${exercise.reps.length} reps and ${exercise.weight.length} weights.`,
|
|
722
|
+
path: ["weight"]
|
|
723
|
+
});
|
|
718
724
|
});
|
|
719
725
|
/** A block's Red-Zone leaderboard: a unit string/number, or an object with options. */
|
|
720
726
|
const leaderboardSpecSchema = z.union([
|
|
@@ -824,12 +830,16 @@ function resultBudget() {
|
|
|
824
830
|
function isPlainObject(value) {
|
|
825
831
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
826
832
|
}
|
|
827
|
-
/**
|
|
828
|
-
|
|
833
|
+
/**
|
|
834
|
+
* Largest count k such that the JSON of the first k elements fits. Elements are serialized one
|
|
835
|
+
* at a time and the walk stops at the first overflow, so an oversized 10k-row result costs the
|
|
836
|
+
* serialization of the rows that fit, not of every row.
|
|
837
|
+
*/
|
|
838
|
+
function largestPrefixCount(elements, charBudget) {
|
|
829
839
|
let used = 2;
|
|
830
840
|
let k = 0;
|
|
831
|
-
for (const
|
|
832
|
-
const add =
|
|
841
|
+
for (const element of elements) {
|
|
842
|
+
const add = (JSON.stringify(element) ?? "null").length + (k > 0 ? 1 : 0);
|
|
833
843
|
if (used + add > charBudget) break;
|
|
834
844
|
used += add;
|
|
835
845
|
k += 1;
|
|
@@ -837,11 +847,12 @@ function largestPrefixCount(pieces, charBudget) {
|
|
|
837
847
|
return k;
|
|
838
848
|
}
|
|
839
849
|
function largestArrayValuedKey(obj) {
|
|
850
|
+
const arrayKeys = Object.keys(obj).filter((key) => Array.isArray(obj[key]));
|
|
851
|
+
if (arrayKeys.length <= 1) return arrayKeys[0] ?? null;
|
|
840
852
|
let best = null;
|
|
841
853
|
let bestLen = -1;
|
|
842
|
-
for (const
|
|
843
|
-
|
|
844
|
-
const len = (JSON.stringify(value) ?? "[]").length;
|
|
854
|
+
for (const key of arrayKeys) {
|
|
855
|
+
const len = (JSON.stringify(obj[key]) ?? "[]").length;
|
|
845
856
|
if (len > bestLen) {
|
|
846
857
|
best = key;
|
|
847
858
|
bestLen = len;
|
|
@@ -849,9 +860,6 @@ function largestArrayValuedKey(obj) {
|
|
|
849
860
|
}
|
|
850
861
|
return best;
|
|
851
862
|
}
|
|
852
|
-
function jsonValue(data) {
|
|
853
|
-
return JSON.parse(JSON.stringify(data) ?? "null");
|
|
854
|
-
}
|
|
855
863
|
function previewEnvelope(source, budget, hint) {
|
|
856
864
|
const total = source.length;
|
|
857
865
|
const makeValue = (preview, markerHint) => ({
|
|
@@ -878,8 +886,8 @@ function boundedResult(data, budget, hint) {
|
|
|
878
886
|
text: data,
|
|
879
887
|
value: data
|
|
880
888
|
};
|
|
881
|
-
const
|
|
882
|
-
const
|
|
889
|
+
const compact = JSON.stringify(data) ?? "null";
|
|
890
|
+
const value = JSON.parse(compact);
|
|
883
891
|
if (compact.length <= budget) {
|
|
884
892
|
const pretty = JSON.stringify(value, null, 2);
|
|
885
893
|
return {
|
|
@@ -888,7 +896,7 @@ function boundedResult(data, budget, hint) {
|
|
|
888
896
|
};
|
|
889
897
|
}
|
|
890
898
|
if (Array.isArray(value)) {
|
|
891
|
-
const truncated = clipArray(value, largestPrefixCount(value
|
|
899
|
+
const truncated = clipArray(value, largestPrefixCount(value, budget - MARKER_RESERVE), hint);
|
|
892
900
|
const text = JSON.stringify(truncated);
|
|
893
901
|
if (text.length <= budget) return {
|
|
894
902
|
text,
|
|
@@ -898,7 +906,7 @@ function boundedResult(data, budget, hint) {
|
|
|
898
906
|
const key = largestArrayValuedKey(value);
|
|
899
907
|
if (key !== null) {
|
|
900
908
|
const array = value[key];
|
|
901
|
-
const truncated = clipArray(array, largestPrefixCount(array
|
|
909
|
+
const truncated = clipArray(array, largestPrefixCount(array, budget - MARKER_RESERVE), hint ?? DEFAULT_OBJECT_HINT, key);
|
|
902
910
|
const text = JSON.stringify(truncated);
|
|
903
911
|
if (text.length <= budget) return {
|
|
904
912
|
text,
|
|
@@ -1217,6 +1225,9 @@ function unitLabel(paramType) {
|
|
|
1217
1225
|
function exerciseUnits(param1, param2) {
|
|
1218
1226
|
return [unitLabel(param1), unitLabel(param2)];
|
|
1219
1227
|
}
|
|
1228
|
+
function buildSearchText(title) {
|
|
1229
|
+
return title.trim().toLowerCase();
|
|
1230
|
+
}
|
|
1220
1231
|
function isRecord(x) {
|
|
1221
1232
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1222
1233
|
}
|
|
@@ -1271,6 +1282,82 @@ function rankSearch(rows, query, limit) {
|
|
|
1271
1282
|
};
|
|
1272
1283
|
}).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
|
|
1273
1284
|
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Map over items with a bounded number of concurrent workers. Used to fan out upstream
|
|
1287
|
+
* fetches (per-exercise history, the CLI export) without bursting the host all at once or,
|
|
1288
|
+
* on workerd, blowing the subrequest budget.
|
|
1289
|
+
*/
|
|
1290
|
+
async function mapPool(items, limit, fn) {
|
|
1291
|
+
assertPositiveInteger(limit, "Concurrency limit");
|
|
1292
|
+
const out = Array.from({ length: items.length });
|
|
1293
|
+
let next = 0;
|
|
1294
|
+
const state = { failure: null };
|
|
1295
|
+
const worker = async () => {
|
|
1296
|
+
while (next < items.length && state.failure === null) {
|
|
1297
|
+
const i = next;
|
|
1298
|
+
next += 1;
|
|
1299
|
+
try {
|
|
1300
|
+
out[i] = await fn(items[i], i);
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
state.failure ??= { error };
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
1307
|
+
if (state.failure !== null) throw state.failure.error;
|
|
1308
|
+
return out;
|
|
1309
|
+
}
|
|
1310
|
+
function createLimiter(max) {
|
|
1311
|
+
assertPositiveInteger(max, "Maximum concurrency");
|
|
1312
|
+
let active = 0;
|
|
1313
|
+
let cancelled = null;
|
|
1314
|
+
const waiting = [];
|
|
1315
|
+
const acquire = (finalizer) => new Promise((resolve, reject) => {
|
|
1316
|
+
if (cancelled !== null && !finalizer) reject(cancelled.error);
|
|
1317
|
+
else if (active < max) {
|
|
1318
|
+
active += 1;
|
|
1319
|
+
resolve();
|
|
1320
|
+
} else waiting.push({
|
|
1321
|
+
start: () => {
|
|
1322
|
+
active += 1;
|
|
1323
|
+
resolve();
|
|
1324
|
+
},
|
|
1325
|
+
abort: reject,
|
|
1326
|
+
finalizer
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
const release = () => {
|
|
1330
|
+
active -= 1;
|
|
1331
|
+
waiting.shift()?.start();
|
|
1332
|
+
};
|
|
1333
|
+
return {
|
|
1334
|
+
async run(task) {
|
|
1335
|
+
await acquire(false);
|
|
1336
|
+
try {
|
|
1337
|
+
return await task();
|
|
1338
|
+
} finally {
|
|
1339
|
+
release();
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
async runFinalizer(task) {
|
|
1343
|
+
await acquire(true);
|
|
1344
|
+
try {
|
|
1345
|
+
return await task();
|
|
1346
|
+
} finally {
|
|
1347
|
+
release();
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
cancel(error) {
|
|
1351
|
+
cancelled ??= { error };
|
|
1352
|
+
const queued = waiting.splice(0);
|
|
1353
|
+
for (const entry of queued) if (entry.finalizer) waiting.push(entry);
|
|
1354
|
+
else entry.abort(error);
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
function assertPositiveInteger(value, label) {
|
|
1359
|
+
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${label} must be a positive integer; received ${value}.`);
|
|
1360
|
+
}
|
|
1274
1361
|
//#endregion
|
|
1275
1362
|
//#region ../js/src/athlete.ts
|
|
1276
1363
|
async function getJson(client, path, label) {
|
|
@@ -1308,9 +1395,19 @@ function fetchAthleteCircuits(client, kind = "recent") {
|
|
|
1308
1395
|
function fetchAthleteProgrammingPrograms(client) {
|
|
1309
1396
|
return getArray(client, "/1.0/athlete/programming/programs", "athlete programming programs");
|
|
1310
1397
|
}
|
|
1311
|
-
/**
|
|
1398
|
+
/**
|
|
1399
|
+
* Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). Only
|
|
1400
|
+
* rows whose title carries every query token are candidates; rankSearch scores but never drops a
|
|
1401
|
+
* row, so ranking the whole catalog would pad a no-match query with the shortest titles up to
|
|
1402
|
+
* `limit`. Mirrors the coach `ExerciseLibrary.search` filter, and a blank query returns nothing.
|
|
1403
|
+
*/
|
|
1312
1404
|
async function searchExerciseHistory(client, query, limit = 20) {
|
|
1313
|
-
|
|
1405
|
+
const tokens = buildSearchText(query).split(/\s+/u).filter((t) => t.length > 0);
|
|
1406
|
+
if (tokens.length === 0) return [];
|
|
1407
|
+
return rankSearch((await fetchExerciseHistoryList(client)).filter((row) => {
|
|
1408
|
+
const text = buildSearchText(row.title);
|
|
1409
|
+
return tokens.every((t) => text.includes(t));
|
|
1410
|
+
}), query, limit);
|
|
1314
1411
|
}
|
|
1315
1412
|
function fetchExerciseHistoryDetail(client, exerciseId, userId) {
|
|
1316
1413
|
return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
|
|
@@ -1365,6 +1462,28 @@ function slotValues(ex, requireMade) {
|
|
|
1365
1462
|
}
|
|
1366
1463
|
return map;
|
|
1367
1464
|
}
|
|
1465
|
+
/**
|
|
1466
|
+
* The slots carrying data that the athlete has NOT marked performed (`param_i_made !== 1`). On a
|
|
1467
|
+
* saved-copy row these are the row's own targets: the pre-filled prescription, an athlete- or
|
|
1468
|
+
* coach-level override written with `made = 0` (`prescribeAthleteSet`), or a personal session's
|
|
1469
|
+
* only prescription, since personal work has no template tree at all.
|
|
1470
|
+
*/
|
|
1471
|
+
function unmadeSlotValues(ex) {
|
|
1472
|
+
const map = slotValues(ex, false);
|
|
1473
|
+
for (const i of map.keys()) if (coerceInt(ex[`param_${i}_made`]) === 1) map.delete(i);
|
|
1474
|
+
return map;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* The prescription for one exercise: the template row's slots, with any target the saved copy
|
|
1478
|
+
* still holds unperformed taking precedence per set index (that is what the athlete's app shows,
|
|
1479
|
+
* and where a per-athlete override lives). Without a template row the saved copy's unperformed
|
|
1480
|
+
* slots are the whole prescription.
|
|
1481
|
+
*/
|
|
1482
|
+
function prescriptionSlots(template, saved) {
|
|
1483
|
+
const map = template ? slotValues(template, false) : /* @__PURE__ */ new Map();
|
|
1484
|
+
if (saved) for (const [i, v] of unmadeSlotValues(saved)) map.set(i, v);
|
|
1485
|
+
return map;
|
|
1486
|
+
}
|
|
1368
1487
|
/** The joined display for a slot, kept verbatim (`"5 @ 225"`, `"AMRAP"`, `"@ 225"`). */
|
|
1369
1488
|
function fmtSlot(slot) {
|
|
1370
1489
|
const has1 = slot.p1 !== null;
|
|
@@ -1374,10 +1493,6 @@ function fmtSlot(slot) {
|
|
|
1374
1493
|
if (has2) return `@ ${slot.p2}`;
|
|
1375
1494
|
return "";
|
|
1376
1495
|
}
|
|
1377
|
-
/** Every slot carrying data, joined for display (the prescription reader): `["5 @ 225", "AMRAP"]`. */
|
|
1378
|
-
function prescribedStrings(ex) {
|
|
1379
|
-
return [...slotValues(ex, false).values()].map(fmtSlot);
|
|
1380
|
-
}
|
|
1381
1496
|
/** The slots the athlete logged (`param_i_made === 1`), joined for display (the performed reader). */
|
|
1382
1497
|
function performedStrings(ex) {
|
|
1383
1498
|
return [...slotValues(ex, true).values()].map(fmtSlot);
|
|
@@ -1413,6 +1528,15 @@ function performedSlotsByExerciseId(sets) {
|
|
|
1413
1528
|
}
|
|
1414
1529
|
return map;
|
|
1415
1530
|
}
|
|
1531
|
+
/** Each saved-copy row keyed by the prescription exercise id it was copied from. */
|
|
1532
|
+
function savedRowsByTemplateId(sets) {
|
|
1533
|
+
const map = /* @__PURE__ */ new Map();
|
|
1534
|
+
for (const { exercises } of sets) for (const ex of exercises) {
|
|
1535
|
+
const id = coerceInt(ex.workout_set_exercise_id);
|
|
1536
|
+
if (id !== null && !map.has(id)) map.set(id, ex);
|
|
1537
|
+
}
|
|
1538
|
+
return map;
|
|
1539
|
+
}
|
|
1416
1540
|
/** Per-exercise athlete notes keyed by the prescription template id (`workout_set_exercise_id`). */
|
|
1417
1541
|
function notesByTemplateId(sets) {
|
|
1418
1542
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1436,7 +1560,7 @@ function mergeExercise(prescribed, performed, meta) {
|
|
|
1436
1560
|
};
|
|
1437
1561
|
}
|
|
1438
1562
|
/** A prescription block, each exercise aligned with the slots the athlete logged against it. */
|
|
1439
|
-
function mergePrescriptionBlock(set, performedById, notesById) {
|
|
1563
|
+
function mergePrescriptionBlock(set, performedById, savedById, notesById) {
|
|
1440
1564
|
const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
|
|
1441
1565
|
return {
|
|
1442
1566
|
order: coerceInt(set.order) ?? 0,
|
|
@@ -1446,7 +1570,7 @@ function mergePrescriptionBlock(set, performedById, notesById) {
|
|
|
1446
1570
|
exercises: exercises.filter(isRecord).map((ex) => {
|
|
1447
1571
|
const id = coerceInt(ex.id);
|
|
1448
1572
|
const performed = (id !== null ? performedById.get(id) : void 0) ?? /* @__PURE__ */ new Map();
|
|
1449
|
-
return mergeExercise(
|
|
1573
|
+
return mergeExercise(prescriptionSlots(ex, id !== null ? savedById.get(id) : void 0), performed, {
|
|
1450
1574
|
exerciseId: coerceInt(ex.exercise_id),
|
|
1451
1575
|
title: typeof ex.title === "string" ? ex.title : "",
|
|
1452
1576
|
instruction: str(ex.instruction),
|
|
@@ -1456,14 +1580,18 @@ function mergePrescriptionBlock(set, performedById, notesById) {
|
|
|
1456
1580
|
})
|
|
1457
1581
|
};
|
|
1458
1582
|
}
|
|
1459
|
-
/**
|
|
1583
|
+
/**
|
|
1584
|
+
* A block straight from the saved copy (athlete-added or personal work; no template row). Its
|
|
1585
|
+
* unperformed slots are the prescription — a personal session's targets live only here — and its
|
|
1586
|
+
* performed slots are what was logged.
|
|
1587
|
+
*/
|
|
1460
1588
|
function mergeSavedBlock(set, exercises) {
|
|
1461
1589
|
return {
|
|
1462
1590
|
order: coerceInt(set.order) ?? 0,
|
|
1463
1591
|
title: str(set.title),
|
|
1464
1592
|
instruction: str(set.instruction),
|
|
1465
1593
|
isTest: coerceInt(set.is_test) === 1,
|
|
1466
|
-
exercises: exercises.map((ex) => mergeExercise(
|
|
1594
|
+
exercises: exercises.map((ex) => mergeExercise(prescriptionSlots(void 0, ex), slotValues(ex, true), {
|
|
1467
1595
|
exerciseId: coerceInt(ex.exercise_id),
|
|
1468
1596
|
title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
|
|
1469
1597
|
instruction: str(ex.instruction),
|
|
@@ -1486,8 +1614,9 @@ function mergeAthleteWorkout(raw) {
|
|
|
1486
1614
|
const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
|
|
1487
1615
|
const logged = savedSets(saved);
|
|
1488
1616
|
const performedById = performedSlotsByExerciseId(logged);
|
|
1617
|
+
const savedById = savedRowsByTemplateId(logged);
|
|
1489
1618
|
const notesById = notesByTemplateId(logged);
|
|
1490
|
-
const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, notesById)).sort((a, b) => a.order - b.order);
|
|
1619
|
+
const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, savedById, notesById)).sort((a, b) => a.order - b.order);
|
|
1491
1620
|
const prescribedIds = /* @__PURE__ */ new Set();
|
|
1492
1621
|
for (const s of prescriptionSets) {
|
|
1493
1622
|
const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
|
|
@@ -1499,7 +1628,7 @@ function mergeAthleteWorkout(raw) {
|
|
|
1499
1628
|
}
|
|
1500
1629
|
for (const { set, exercises } of logged) {
|
|
1501
1630
|
const extra = exercises.filter((ex) => {
|
|
1502
|
-
if (slotValues(ex,
|
|
1631
|
+
if (slotValues(ex, false).size === 0) return false;
|
|
1503
1632
|
const id = coerceInt(ex.workout_set_exercise_id);
|
|
1504
1633
|
return id === null || !prescribedIds.has(id);
|
|
1505
1634
|
});
|
|
@@ -1610,6 +1739,16 @@ function presentLogTargets(list) {
|
|
|
1610
1739
|
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
1611
1740
|
const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
1612
1741
|
if (!saved) continue;
|
|
1742
|
+
const workout = isRecord(ssw.workout) ? ssw.workout : {};
|
|
1743
|
+
const templatesById = /* @__PURE__ */ new Map();
|
|
1744
|
+
for (const tSet of Array.isArray(workout.workoutSets) ? workout.workoutSets : []) {
|
|
1745
|
+
if (!isRecord(tSet)) continue;
|
|
1746
|
+
const tExs = Array.isArray(tSet.workoutSetExercises) ? tSet.workoutSetExercises : [];
|
|
1747
|
+
for (const tEx of tExs) {
|
|
1748
|
+
const tId = isRecord(tEx) ? coerceInt(tEx.id) : null;
|
|
1749
|
+
if (tId !== null && isRecord(tEx)) templatesById.set(tId, tEx);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1613
1752
|
const date = str(rec.date) ?? "";
|
|
1614
1753
|
const workoutTitle = str(rec.workout_title) ?? "";
|
|
1615
1754
|
const program = str(rec.program_title);
|
|
@@ -1624,12 +1763,14 @@ function presentLogTargets(list) {
|
|
|
1624
1763
|
const exercises = (Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : []).filter(isRecord).map((ex) => {
|
|
1625
1764
|
const id = coerceInt(ex.id);
|
|
1626
1765
|
if (id === null) return null;
|
|
1766
|
+
const templateId = coerceInt(ex.workout_set_exercise_id);
|
|
1767
|
+
const template = templateId === null ? void 0 : templatesById.get(templateId);
|
|
1627
1768
|
return {
|
|
1628
1769
|
savedWorkoutSetExerciseId: id,
|
|
1629
1770
|
title: exerciseTitle(ex),
|
|
1630
1771
|
units: exerciseUnits(ex.param_1_type, ex.param_2_type),
|
|
1631
1772
|
notes: str(ex.notes),
|
|
1632
|
-
prescribed:
|
|
1773
|
+
prescribed: [...prescriptionSlots(template, ex).values()].map(fmtSlot),
|
|
1633
1774
|
performed: performedStrings(ex)
|
|
1634
1775
|
};
|
|
1635
1776
|
}).filter((e) => e !== null);
|
|
@@ -1706,6 +1847,28 @@ function presentExerciseHistory(detail) {
|
|
|
1706
1847
|
};
|
|
1707
1848
|
}
|
|
1708
1849
|
//#endregion
|
|
1850
|
+
//#region ../js/src/exercise-history.ts
|
|
1851
|
+
/**
|
|
1852
|
+
* Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
|
|
1853
|
+
* The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
|
|
1854
|
+
* first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. The one
|
|
1855
|
+
* window rule shared by the athlete's own history tool, the coach's per-roster-athlete history
|
|
1856
|
+
* tool, and the CLI, so a timestamped session cannot fall out of an inclusive upper bound.
|
|
1857
|
+
*/
|
|
1858
|
+
function historyInRange(presented, since, until) {
|
|
1859
|
+
if (since === void 0 && until === void 0) return presented;
|
|
1860
|
+
const sessions = presented.sessions.filter((s) => {
|
|
1861
|
+
const d = (s.date ?? "").slice(0, 10);
|
|
1862
|
+
if (since !== void 0 && d < since) return false;
|
|
1863
|
+
if (until !== void 0 && d > until) return false;
|
|
1864
|
+
return true;
|
|
1865
|
+
});
|
|
1866
|
+
return {
|
|
1867
|
+
...presented,
|
|
1868
|
+
sessions
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
//#endregion
|
|
1709
1872
|
//#region ../js/src/exercise-set-payload.ts
|
|
1710
1873
|
function slotData(exercise, key) {
|
|
1711
1874
|
const value = exercise?.[key];
|
|
@@ -1776,6 +1939,7 @@ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, w
|
|
|
1776
1939
|
}
|
|
1777
1940
|
//#endregion
|
|
1778
1941
|
//#region ../js/src/athlete-set-write.ts
|
|
1942
|
+
const WRITE_CONCURRENCY = 4;
|
|
1779
1943
|
/**
|
|
1780
1944
|
* Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
|
|
1781
1945
|
* {@link SetResult}[]. The dto schemas validate ids as a number or a numeric string and leave
|
|
@@ -1968,6 +2132,41 @@ async function swapAthleteExercise(client, args) {
|
|
|
1968
2132
|
originalTeamExerciseId: coerceInt(template.exercise_id)
|
|
1969
2133
|
};
|
|
1970
2134
|
}
|
|
2135
|
+
function prepareSetWrite(target, workouts, savedWorkoutSetId, results, mode) {
|
|
2136
|
+
const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
|
|
2137
|
+
assertUniqueExerciseResults(results);
|
|
2138
|
+
const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
|
|
2139
|
+
const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
|
|
2140
|
+
return {
|
|
2141
|
+
savedWorkoutSetId,
|
|
2142
|
+
exercises,
|
|
2143
|
+
rawSet,
|
|
2144
|
+
suffix,
|
|
2145
|
+
extra,
|
|
2146
|
+
writes: results.map((result) => {
|
|
2147
|
+
const ex = exercises.find((candidate) => coerceInt(candidate.id) === result.savedWorkoutSetExerciseId);
|
|
2148
|
+
if (!ex) {
|
|
2149
|
+
const valid = exercises.map((candidate) => {
|
|
2150
|
+
const id = coerceInt(candidate.id);
|
|
2151
|
+
return id === null ? null : `${id} (${exerciseTitle(candidate, "exercise")})`;
|
|
2152
|
+
}).filter((label) => label !== null);
|
|
2153
|
+
throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
|
|
2154
|
+
}
|
|
2155
|
+
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
2156
|
+
if (!workoutSetExerciseId) throw new Error(`savedWorkoutSetExercise ${result.savedWorkoutSetExerciseId} is missing its workout_set_exercise_id (the prescription-template pointer the write needs). This is the savedWorkoutSetExerciseId, not an exercise_id — re-read the ids from athlete_saved_workouts.`);
|
|
2157
|
+
return {
|
|
2158
|
+
id: result.savedWorkoutSetExerciseId,
|
|
2159
|
+
body: {
|
|
2160
|
+
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
|
|
2161
|
+
...extra
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
})
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
function errorMessage(error) {
|
|
2168
|
+
return error instanceof Error ? error.message : String(error);
|
|
2169
|
+
}
|
|
1971
2170
|
/**
|
|
1972
2171
|
* Shared set-write behind {@link logAthleteSet}, {@link prescribeAthleteSet},
|
|
1973
2172
|
* {@link logForAthlete}, and {@link prescribeForAthlete}. `target` selects the surface: `athlete`
|
|
@@ -1981,36 +2180,40 @@ async function swapAthleteExercise(client, args) {
|
|
|
1981
2180
|
* the written ones); `"prescribe"` writes them as a prescription and skips Step 2, leaving the set
|
|
1982
2181
|
* open.
|
|
1983
2182
|
*/
|
|
1984
|
-
async function
|
|
1985
|
-
const { exercises, rawSet } =
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
const id = coerceInt(e.id);
|
|
1996
|
-
return id === null ? null : `${id} (${exerciseTitle(e, "exercise")})`;
|
|
1997
|
-
}).filter((x) => x !== null);
|
|
1998
|
-
throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
|
|
1999
|
-
}
|
|
2000
|
-
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
2001
|
-
if (!workoutSetExerciseId) throw new Error(`savedWorkoutSetExercise ${result.savedWorkoutSetExerciseId} is missing its workout_set_exercise_id (the prescription-template pointer the write needs). This is the savedWorkoutSetExerciseId, not an exercise_id — re-read the ids from athlete_saved_workouts.`);
|
|
2002
|
-
const body = {
|
|
2003
|
-
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
|
|
2004
|
-
...extra
|
|
2183
|
+
async function executeSetWrite(client, target, prepared, mode, limit, sessionProgress) {
|
|
2184
|
+
const { savedWorkoutSetId, exercises, rawSet, suffix, extra, writes } = prepared;
|
|
2185
|
+
const put = (path, body, failureMessage, finalizer = false) => {
|
|
2186
|
+
const request = async () => {
|
|
2187
|
+
try {
|
|
2188
|
+
const res = await client.request("PUT", path, { body });
|
|
2189
|
+
if (!res.ok) throw new Error(failureMessage(res.status));
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
limit.cancel(error);
|
|
2192
|
+
throw error;
|
|
2193
|
+
}
|
|
2005
2194
|
};
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
|
|
2195
|
+
return finalizer ? limit.runFinalizer(request) : limit.run(request);
|
|
2196
|
+
};
|
|
2197
|
+
const confirmed = [];
|
|
2198
|
+
try {
|
|
2199
|
+
await mapPool(writes, WRITE_CONCURRENCY, async ({ id, body }) => {
|
|
2200
|
+
await put(`/1.0/${target.role}/savedworkoutsetexercise/${id}${suffix}`, body, (status) => {
|
|
2201
|
+
return `Failed to write exercise ${id} (HTTP ${status}).${target.role === "coach" && (status === 401 || status === 403) ? ` Athlete ${target.athleteId} appears to be read-only for changes — TrainHeroic's seeded demo/sample athletes return ${status} here; writes only persist for real (invited) athletes.` : ""}`;
|
|
2202
|
+
});
|
|
2203
|
+
confirmed.push(id);
|
|
2204
|
+
if (sessionProgress) {
|
|
2205
|
+
const sessionConfirmed = sessionProgress.get(savedWorkoutSetId) ?? [];
|
|
2206
|
+
sessionConfirmed.push(id);
|
|
2207
|
+
sessionProgress.set(savedWorkoutSetId, sessionConfirmed);
|
|
2208
|
+
}
|
|
2209
|
+
});
|
|
2210
|
+
} catch (error) {
|
|
2211
|
+
if (sessionProgress || confirmed.length === 0) throw error;
|
|
2212
|
+
confirmed.sort((a, b) => a - b);
|
|
2213
|
+
throw new Error(`${errorMessage(error)} Confirmed exercise writes before the failure: ${confirmed.join(", ")}. The set was not marked complete; retry the same request to reconcile it.`, { cause: error });
|
|
2013
2214
|
}
|
|
2215
|
+
const exercisesWritten = writes.length;
|
|
2216
|
+
const projectedCompletion = new Map(writes.map((w) => [w.id, w.body.completed === 1]));
|
|
2014
2217
|
let setCompleted = false;
|
|
2015
2218
|
if (mode === "log") {
|
|
2016
2219
|
if (isSetFullyLogged(exercises, projectedCompletion)) {
|
|
@@ -2018,8 +2221,11 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
2018
2221
|
...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
|
|
2019
2222
|
...extra
|
|
2020
2223
|
};
|
|
2021
|
-
|
|
2022
|
-
|
|
2224
|
+
try {
|
|
2225
|
+
await put(`/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, setBody, (status) => `Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${status}).`, true);
|
|
2226
|
+
} catch (error) {
|
|
2227
|
+
throw new Error(`${errorMessage(error)} Exercise values were written successfully; retry the same request to complete the set.`, { cause: error });
|
|
2228
|
+
}
|
|
2023
2229
|
setCompleted = true;
|
|
2024
2230
|
}
|
|
2025
2231
|
}
|
|
@@ -2029,6 +2235,9 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
2029
2235
|
setCompleted
|
|
2030
2236
|
};
|
|
2031
2237
|
}
|
|
2238
|
+
async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode, limit = createLimiter(WRITE_CONCURRENCY)) {
|
|
2239
|
+
return executeSetWrite(client, target, prepareSetWrite(target, workouts, savedWorkoutSetId, results, mode), mode, limit);
|
|
2240
|
+
}
|
|
2032
2241
|
/**
|
|
2033
2242
|
* POST /v5/programWorkouts/personal — create a personal workout session for a given date.
|
|
2034
2243
|
* Returns the key ids: workoutId (needed for addExercisesToWorkout), programWorkoutId,
|
|
@@ -2174,7 +2383,7 @@ function findScheduledMatches(workouts, exerciseIds) {
|
|
|
2174
2383
|
return out;
|
|
2175
2384
|
}
|
|
2176
2385
|
/** Group resolved exercises by saved set and write each set via the given log target. */
|
|
2177
|
-
async function logResolvedExercises(client, target, workouts, resolved) {
|
|
2386
|
+
async function logResolvedExercises(client, target, workouts, resolved, retryGuidance) {
|
|
2178
2387
|
const bySet = /* @__PURE__ */ new Map();
|
|
2179
2388
|
for (const r of resolved) {
|
|
2180
2389
|
const list = bySet.get(r.savedWorkoutSetId) ?? [];
|
|
@@ -2184,15 +2393,28 @@ async function logResolvedExercises(client, target, workouts, resolved) {
|
|
|
2184
2393
|
});
|
|
2185
2394
|
bySet.set(r.savedWorkoutSetId, list);
|
|
2186
2395
|
}
|
|
2187
|
-
const
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2396
|
+
const prepared = [...bySet].map(([savedWorkoutSetId, results]) => prepareSetWrite(target, workouts, savedWorkoutSetId, results, "log"));
|
|
2397
|
+
const limit = createLimiter(WRITE_CONCURRENCY);
|
|
2398
|
+
const confirmedBySet = /* @__PURE__ */ new Map();
|
|
2399
|
+
const succeeded = [];
|
|
2400
|
+
try {
|
|
2401
|
+
return await mapPool(prepared, WRITE_CONCURRENCY, async (set) => {
|
|
2402
|
+
const written = await executeSetWrite(client, target, set, "log", limit, confirmedBySet);
|
|
2403
|
+
succeeded.push(written.savedWorkoutSetId);
|
|
2404
|
+
return {
|
|
2405
|
+
savedWorkoutSetId: written.savedWorkoutSetId,
|
|
2406
|
+
exercisesLogged: written.exercisesWritten
|
|
2407
|
+
};
|
|
2193
2408
|
});
|
|
2409
|
+
} catch (error) {
|
|
2410
|
+
const completed = new Set(succeeded);
|
|
2411
|
+
const incomplete = [...confirmedBySet].filter(([savedWorkoutSetId]) => !completed.has(savedWorkoutSetId)).sort(([a], [b]) => a - b).map(([savedWorkoutSetId, ids]) => `set ${savedWorkoutSetId}: ${ids.toSorted((a, b) => a - b).join(", ")}`);
|
|
2412
|
+
if (incomplete.length === 0 && succeeded.length === 0) throw error;
|
|
2413
|
+
succeeded.sort((a, b) => a - b);
|
|
2414
|
+
const partial = incomplete.length === 0 ? "" : ` Confirmed exercise writes in incomplete sets before the failure: ${incomplete.join("; ")}.${retryGuidance ? ` ${retryGuidance}` : ""}`;
|
|
2415
|
+
const complete = succeeded.length === 0 ? "" : ` Set writes confirmed before the failure: ${succeeded.join(", ")}.`;
|
|
2416
|
+
throw new Error(`${errorMessage(error)}${partial}${complete}`, { cause: error });
|
|
2194
2417
|
}
|
|
2195
|
-
return out;
|
|
2196
2418
|
}
|
|
2197
2419
|
/**
|
|
2198
2420
|
* Log a whole session for the logged-in athlete by exercise, with no pre-existing prescription
|
|
@@ -2306,27 +2528,6 @@ async function setAthleteExerciseNote(client, args) {
|
|
|
2306
2528
|
notes: typeof data.notes === "string" ? data.notes : args.notes
|
|
2307
2529
|
};
|
|
2308
2530
|
}
|
|
2309
|
-
//#endregion
|
|
2310
|
-
//#region ../core/src/history.ts
|
|
2311
|
-
/**
|
|
2312
|
-
* Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
|
|
2313
|
-
* The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
|
|
2314
|
-
* first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. Shared by
|
|
2315
|
-
* the athlete's own history tool and the coach's per-roster-athlete history tool.
|
|
2316
|
-
*/
|
|
2317
|
-
function historyInRange(presented, since, until) {
|
|
2318
|
-
if (since === void 0 && until === void 0) return presented;
|
|
2319
|
-
const sessions = presented.sessions.filter((s) => {
|
|
2320
|
-
const d = (s.date ?? "").slice(0, 10);
|
|
2321
|
-
if (since !== void 0 && d < since) return false;
|
|
2322
|
-
if (until !== void 0 && d > until) return false;
|
|
2323
|
-
return true;
|
|
2324
|
-
});
|
|
2325
|
-
return {
|
|
2326
|
-
...presented,
|
|
2327
|
-
sessions
|
|
2328
|
-
};
|
|
2329
|
-
}
|
|
2330
2531
|
z.number().int().positive().max(36).optional();
|
|
2331
2532
|
//#endregion
|
|
2332
2533
|
//#region ../core/src/tools/athlete-training.ts
|
|
@@ -2759,7 +2960,7 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
2759
2960
|
}
|
|
2760
2961
|
//#endregion
|
|
2761
2962
|
//#region package.json
|
|
2762
|
-
var version = "3.5.
|
|
2963
|
+
var version = "3.5.2";
|
|
2763
2964
|
//#endregion
|
|
2764
2965
|
//#region src/server.ts
|
|
2765
2966
|
function main() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainheroic-unofficial/athlete-mcp",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/server": "2.0.0",
|
|
23
23
|
"zod": "^4.4.3",
|
|
24
|
-
"@trainheroic-unofficial/core": "3.5.
|
|
25
|
-
"@trainheroic-unofficial/js": "3.5.
|
|
24
|
+
"@trainheroic-unofficial/core": "3.5.2",
|
|
25
|
+
"@trainheroic-unofficial/js": "3.5.2"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.3.0",
|