@trainheroic-unofficial/athlete-mcp 3.5.1 → 3.6.0
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 +472 -102
- package/package.json +3 -3
package/dist/server.mjs
CHANGED
|
@@ -830,12 +830,16 @@ function resultBudget() {
|
|
|
830
830
|
function isPlainObject(value) {
|
|
831
831
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
832
832
|
}
|
|
833
|
-
/**
|
|
834
|
-
|
|
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) {
|
|
835
839
|
let used = 2;
|
|
836
840
|
let k = 0;
|
|
837
|
-
for (const
|
|
838
|
-
const add =
|
|
841
|
+
for (const element of elements) {
|
|
842
|
+
const add = (JSON.stringify(element) ?? "null").length + (k > 0 ? 1 : 0);
|
|
839
843
|
if (used + add > charBudget) break;
|
|
840
844
|
used += add;
|
|
841
845
|
k += 1;
|
|
@@ -843,11 +847,12 @@ function largestPrefixCount(pieces, charBudget) {
|
|
|
843
847
|
return k;
|
|
844
848
|
}
|
|
845
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;
|
|
846
852
|
let best = null;
|
|
847
853
|
let bestLen = -1;
|
|
848
|
-
for (const
|
|
849
|
-
|
|
850
|
-
const len = (JSON.stringify(value) ?? "[]").length;
|
|
854
|
+
for (const key of arrayKeys) {
|
|
855
|
+
const len = (JSON.stringify(obj[key]) ?? "[]").length;
|
|
851
856
|
if (len > bestLen) {
|
|
852
857
|
best = key;
|
|
853
858
|
bestLen = len;
|
|
@@ -855,9 +860,6 @@ function largestArrayValuedKey(obj) {
|
|
|
855
860
|
}
|
|
856
861
|
return best;
|
|
857
862
|
}
|
|
858
|
-
function jsonValue(data) {
|
|
859
|
-
return JSON.parse(JSON.stringify(data) ?? "null");
|
|
860
|
-
}
|
|
861
863
|
function previewEnvelope(source, budget, hint) {
|
|
862
864
|
const total = source.length;
|
|
863
865
|
const makeValue = (preview, markerHint) => ({
|
|
@@ -884,8 +886,8 @@ function boundedResult(data, budget, hint) {
|
|
|
884
886
|
text: data,
|
|
885
887
|
value: data
|
|
886
888
|
};
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
+
const compact = JSON.stringify(data) ?? "null";
|
|
890
|
+
const value = JSON.parse(compact);
|
|
889
891
|
if (compact.length <= budget) {
|
|
890
892
|
const pretty = JSON.stringify(value, null, 2);
|
|
891
893
|
return {
|
|
@@ -894,7 +896,7 @@ function boundedResult(data, budget, hint) {
|
|
|
894
896
|
};
|
|
895
897
|
}
|
|
896
898
|
if (Array.isArray(value)) {
|
|
897
|
-
const truncated = clipArray(value, largestPrefixCount(value
|
|
899
|
+
const truncated = clipArray(value, largestPrefixCount(value, budget - MARKER_RESERVE), hint);
|
|
898
900
|
const text = JSON.stringify(truncated);
|
|
899
901
|
if (text.length <= budget) return {
|
|
900
902
|
text,
|
|
@@ -904,7 +906,7 @@ function boundedResult(data, budget, hint) {
|
|
|
904
906
|
const key = largestArrayValuedKey(value);
|
|
905
907
|
if (key !== null) {
|
|
906
908
|
const array = value[key];
|
|
907
|
-
const truncated = clipArray(array, largestPrefixCount(array
|
|
909
|
+
const truncated = clipArray(array, largestPrefixCount(array, budget - MARKER_RESERVE), hint ?? DEFAULT_OBJECT_HINT, key);
|
|
908
910
|
const text = JSON.stringify(truncated);
|
|
909
911
|
if (text.length <= budget) return {
|
|
910
912
|
text,
|
|
@@ -999,30 +1001,200 @@ function confirmGate(ctx, message, confirmArg) {
|
|
|
999
1001
|
const SERVER_INSTRUCTIONS = "Speak to the user in plain, everyday language about their training. Describe what you are doing in the TrainHeroic app's own terms (for example, say you are creating a workout rather than naming a tool). Do not surface internal tool names (the snake_case identifiers such as athlete_session_create), raw parameter names, or numeric ids in your replies unless the user explicitly asks for them; they are implementation details. The tool descriptions cross-reference each other by name only so you can chain them correctly. Keep that wiring to yourself.";
|
|
1000
1002
|
//#endregion
|
|
1001
1003
|
//#region ../js/src/http-error.ts
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1004
|
+
const MAX_REQUEST_KEYS = 50;
|
|
1005
|
+
const MAX_RESPONSE_DEPTH = 4;
|
|
1006
|
+
const MAX_RESPONSE_KEYS = 20;
|
|
1007
|
+
const MAX_RESPONSE_ITEMS = 10;
|
|
1008
|
+
const MAX_RESPONSE_NODES = 50;
|
|
1009
|
+
const RESPONSE_DIAGNOSTIC_KEYS = /* @__PURE__ */ new Set([
|
|
1010
|
+
"code",
|
|
1011
|
+
"detail",
|
|
1012
|
+
"details",
|
|
1013
|
+
"error",
|
|
1014
|
+
"error_code",
|
|
1015
|
+
"errors",
|
|
1016
|
+
"message",
|
|
1017
|
+
"reason",
|
|
1018
|
+
"status",
|
|
1019
|
+
"status_code",
|
|
1020
|
+
"success"
|
|
1021
|
+
]);
|
|
1022
|
+
const RESPONSE_CONTAINER_KEYS = /* @__PURE__ */ new Set([
|
|
1023
|
+
"data",
|
|
1024
|
+
"received",
|
|
1025
|
+
"response",
|
|
1026
|
+
"result"
|
|
1027
|
+
]);
|
|
1028
|
+
const RESPONSE_STATUS_KEYS = /* @__PURE__ */ new Set(["status", "status_code"]);
|
|
1029
|
+
const RESPONSE_BOOLEAN_KEYS = /* @__PURE__ */ new Set(["success"]);
|
|
1030
|
+
const PARAMETER_TYPES = /* @__PURE__ */ new Set([
|
|
1031
|
+
0,
|
|
1032
|
+
1,
|
|
1033
|
+
2,
|
|
1034
|
+
3,
|
|
1035
|
+
4,
|
|
1036
|
+
5,
|
|
1037
|
+
6,
|
|
1038
|
+
7,
|
|
1039
|
+
10,
|
|
1040
|
+
11,
|
|
1041
|
+
12,
|
|
1042
|
+
13,
|
|
1043
|
+
14,
|
|
1044
|
+
18
|
|
1045
|
+
]);
|
|
1046
|
+
function safeFieldName(key) {
|
|
1047
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") return "[Redacted key]";
|
|
1048
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,79}$/.test(key)) return "[Redacted key]";
|
|
1049
|
+
return key;
|
|
1050
|
+
}
|
|
1051
|
+
function boundedEntries(object, limit) {
|
|
1052
|
+
const entries = [];
|
|
1053
|
+
for (const key of Object.keys(object)) {
|
|
1054
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
1055
|
+
entries.push([key, descriptor && "value" in descriptor ? descriptor.value : void 0]);
|
|
1056
|
+
if (entries.length >= limit) break;
|
|
1057
|
+
}
|
|
1058
|
+
return entries;
|
|
1059
|
+
}
|
|
1060
|
+
function safeRequestValue(key, value) {
|
|
1061
|
+
if (key === "is_circuit" && typeof value === "boolean") return value;
|
|
1062
|
+
if (["param_1_type", "param_2_type"].includes(key) && typeof value === "number" && PARAMETER_TYPES.has(value)) return value;
|
|
1063
|
+
}
|
|
1064
|
+
function requestBodySummary(body) {
|
|
1065
|
+
if (Array.isArray(body)) return {
|
|
1066
|
+
type: "array",
|
|
1067
|
+
length: body.length
|
|
1068
|
+
};
|
|
1069
|
+
if (body && typeof body === "object") {
|
|
1070
|
+
const entries = boundedEntries(body, MAX_REQUEST_KEYS).sort(([left], [right]) => left.localeCompare(right));
|
|
1071
|
+
const keys = entries.map(([key]) => safeFieldName(key));
|
|
1072
|
+
const values = {};
|
|
1073
|
+
for (const [key, value] of entries) {
|
|
1074
|
+
const safeValue = safeRequestValue(key, value);
|
|
1075
|
+
if (safeValue !== void 0) values[safeFieldName(key)] = safeValue;
|
|
1076
|
+
}
|
|
1077
|
+
return Object.keys(values).length > 0 ? {
|
|
1078
|
+
type: "object",
|
|
1079
|
+
keys,
|
|
1080
|
+
values
|
|
1081
|
+
} : {
|
|
1082
|
+
type: "object",
|
|
1083
|
+
keys
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const primitive = typeof body;
|
|
1087
|
+
return primitive === "boolean" || primitive === "number" || primitive === "string" ? { type: primitive } : { type: "other" };
|
|
1088
|
+
}
|
|
1089
|
+
function sanitizedDiagnosticValue(value, depth, budget, field) {
|
|
1090
|
+
if (budget.nodes <= 0) return "[Truncated]";
|
|
1091
|
+
budget.nodes -= 1;
|
|
1092
|
+
if (value === null) return null;
|
|
1093
|
+
if (typeof value === "boolean") return field && RESPONSE_BOOLEAN_KEYS.has(field) ? value : "[Redacted]";
|
|
1094
|
+
if (typeof value === "number") return field && RESPONSE_STATUS_KEYS.has(field) && Number.isInteger(value) && value >= 100 && value <= 599 ? value : "[Redacted]";
|
|
1095
|
+
if (typeof value === "string") return "[Redacted]";
|
|
1096
|
+
if (depth >= MAX_RESPONSE_DEPTH) return "[Truncated]";
|
|
1097
|
+
if (Array.isArray(value)) {
|
|
1098
|
+
const result = [];
|
|
1099
|
+
for (const item of value.slice(0, MAX_RESPONSE_ITEMS)) {
|
|
1100
|
+
if (budget.nodes <= 0) {
|
|
1101
|
+
result.push("[Truncated]");
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
result.push(sanitizedDiagnosticValue(item, depth + 1, budget));
|
|
1105
|
+
}
|
|
1106
|
+
return result;
|
|
1107
|
+
}
|
|
1108
|
+
if (value && typeof value === "object") {
|
|
1109
|
+
const result = {};
|
|
1110
|
+
for (const [key, item] of boundedEntries(value, MAX_RESPONSE_KEYS)) {
|
|
1111
|
+
if (budget.nodes <= 0) {
|
|
1112
|
+
result.truncated = "[Truncated]";
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
const safeKey = safeFieldName(key);
|
|
1116
|
+
const safeValue = safeRequestValue(key, item);
|
|
1117
|
+
if (RESPONSE_DIAGNOSTIC_KEYS.has(key)) result[safeKey] = sanitizedDiagnosticValue(item, depth + 1, budget, key);
|
|
1118
|
+
else if (RESPONSE_CONTAINER_KEYS.has(key) && item && typeof item === "object") result[safeKey] = sanitizedDiagnosticValue(item, depth + 1, budget);
|
|
1119
|
+
else if (safeValue !== void 0) {
|
|
1120
|
+
budget.nodes -= 1;
|
|
1121
|
+
result[safeKey] = safeValue;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return result;
|
|
1125
|
+
}
|
|
1126
|
+
return `[${typeof value}]`;
|
|
1127
|
+
}
|
|
1128
|
+
function responseBodyDiagnostics(body, budget, depth = 0) {
|
|
1129
|
+
const activeBudget = budget ?? { nodes: MAX_RESPONSE_NODES };
|
|
1130
|
+
if (activeBudget.nodes <= 0 || depth >= MAX_RESPONSE_DEPTH) return "[Truncated]";
|
|
1131
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return sanitizedDiagnosticValue(body, depth, activeBudget);
|
|
1132
|
+
const object = body;
|
|
1133
|
+
const result = {};
|
|
1134
|
+
for (const [key, value] of boundedEntries(object, MAX_RESPONSE_KEYS)) {
|
|
1135
|
+
if (activeBudget.nodes <= 0) {
|
|
1136
|
+
result.truncated = "[Truncated]";
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
if (RESPONSE_DIAGNOSTIC_KEYS.has(key)) {
|
|
1140
|
+
result[safeFieldName(key)] = sanitizedDiagnosticValue(value, depth + 1, activeBudget, key);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (RESPONSE_CONTAINER_KEYS.has(key) && value && typeof value === "object") {
|
|
1144
|
+
activeBudget.nodes -= 1;
|
|
1145
|
+
const nested = responseBodyDiagnostics(value, activeBudget, depth + 1);
|
|
1146
|
+
if (nested === "[Truncated]" || nested && typeof nested === "object" && Object.keys(nested).length > 0) result[safeFieldName(key)] = nested;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (Object.keys(result).length > 0) return result;
|
|
1150
|
+
return { type: "object" };
|
|
1151
|
+
}
|
|
1152
|
+
function parseResponseText(text) {
|
|
1153
|
+
if (text.length === 0) return text;
|
|
1154
|
+
try {
|
|
1155
|
+
return JSON.parse(text);
|
|
1156
|
+
} catch {
|
|
1157
|
+
return text;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
function safeRequestBodySummary(body) {
|
|
1161
|
+
try {
|
|
1162
|
+
return requestBodySummary(body);
|
|
1163
|
+
} catch {
|
|
1164
|
+
return { type: "other" };
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
function safeResponseBodyDiagnostics(body) {
|
|
1168
|
+
try {
|
|
1169
|
+
return responseBodyDiagnostics(body);
|
|
1170
|
+
} catch {
|
|
1171
|
+
return "[Unavailable]";
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/** A final non-2xx response plus bounded, telemetry-safe request and response diagnostics. */
|
|
1007
1175
|
var TrainHeroicHttpError = class extends Error {
|
|
1008
1176
|
name = "TrainHeroicHttpError";
|
|
1009
1177
|
method;
|
|
1010
1178
|
status;
|
|
1011
1179
|
host;
|
|
1012
|
-
|
|
1180
|
+
requestBody;
|
|
1181
|
+
responseBody;
|
|
1182
|
+
constructor(method, url, status, diagnostics = {}) {
|
|
1013
1183
|
const parsed = new URL(url);
|
|
1014
1184
|
const normalizedMethod = method.toUpperCase();
|
|
1015
1185
|
super(`TrainHeroic ${normalizedMethod} request failed with HTTP ${status}`);
|
|
1016
1186
|
this.method = normalizedMethod;
|
|
1017
1187
|
this.status = status;
|
|
1018
1188
|
this.host = parsed.host;
|
|
1189
|
+
this.requestBody = diagnostics.requestBody === void 0 ? void 0 : safeRequestBodySummary(diagnostics.requestBody);
|
|
1190
|
+
this.responseBody = diagnostics.responseBody === void 0 ? void 0 : safeResponseBodyDiagnostics(diagnostics.responseBody);
|
|
1019
1191
|
}
|
|
1020
1192
|
};
|
|
1021
1193
|
/** Call observability hooks without allowing them to change SDK behavior. */
|
|
1022
|
-
function notifyHttpError(handler, method, url, status) {
|
|
1194
|
+
function notifyHttpError(handler, method, url, status, diagnostics = {}) {
|
|
1023
1195
|
if (!handler) return;
|
|
1024
1196
|
try {
|
|
1025
|
-
Promise.resolve(handler(new TrainHeroicHttpError(method, url, status))).catch(() => {});
|
|
1197
|
+
Promise.resolve(handler(new TrainHeroicHttpError(method, url, status, diagnostics))).catch(() => {});
|
|
1026
1198
|
} catch {}
|
|
1027
1199
|
}
|
|
1028
1200
|
//#endregion
|
|
@@ -1146,14 +1318,19 @@ var TrainHeroicClient = class {
|
|
|
1146
1318
|
session = await this.#ensureSession();
|
|
1147
1319
|
res = await this.#send(method, url, session, options.body);
|
|
1148
1320
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1321
|
+
const shouldReport = !res.ok && !options.expectedStatuses?.includes(res.status);
|
|
1322
|
+
let text;
|
|
1323
|
+
try {
|
|
1324
|
+
text = await res.text();
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
if (shouldReport) notifyHttpError(this.#onHttpError, method, url, res.status, { requestBody: options.body });
|
|
1327
|
+
throw error;
|
|
1156
1328
|
}
|
|
1329
|
+
const data = parseResponseText(text);
|
|
1330
|
+
if (shouldReport) notifyHttpError(this.#onHttpError, method, url, res.status, {
|
|
1331
|
+
requestBody: options.body,
|
|
1332
|
+
responseBody: data
|
|
1333
|
+
});
|
|
1157
1334
|
return {
|
|
1158
1335
|
status: res.status,
|
|
1159
1336
|
ok: res.ok,
|
|
@@ -1223,6 +1400,9 @@ function unitLabel(paramType) {
|
|
|
1223
1400
|
function exerciseUnits(param1, param2) {
|
|
1224
1401
|
return [unitLabel(param1), unitLabel(param2)];
|
|
1225
1402
|
}
|
|
1403
|
+
function buildSearchText(title) {
|
|
1404
|
+
return title.trim().toLowerCase();
|
|
1405
|
+
}
|
|
1226
1406
|
function isRecord(x) {
|
|
1227
1407
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1228
1408
|
}
|
|
@@ -1277,6 +1457,82 @@ function rankSearch(rows, query, limit) {
|
|
|
1277
1457
|
};
|
|
1278
1458
|
}).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
|
|
1279
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Map over items with a bounded number of concurrent workers. Used to fan out upstream
|
|
1462
|
+
* fetches (per-exercise history, the CLI export) without bursting the host all at once or,
|
|
1463
|
+
* on workerd, blowing the subrequest budget.
|
|
1464
|
+
*/
|
|
1465
|
+
async function mapPool(items, limit, fn) {
|
|
1466
|
+
assertPositiveInteger(limit, "Concurrency limit");
|
|
1467
|
+
const out = Array.from({ length: items.length });
|
|
1468
|
+
let next = 0;
|
|
1469
|
+
const state = { failure: null };
|
|
1470
|
+
const worker = async () => {
|
|
1471
|
+
while (next < items.length && state.failure === null) {
|
|
1472
|
+
const i = next;
|
|
1473
|
+
next += 1;
|
|
1474
|
+
try {
|
|
1475
|
+
out[i] = await fn(items[i], i);
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
state.failure ??= { error };
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
1482
|
+
if (state.failure !== null) throw state.failure.error;
|
|
1483
|
+
return out;
|
|
1484
|
+
}
|
|
1485
|
+
function createLimiter(max) {
|
|
1486
|
+
assertPositiveInteger(max, "Maximum concurrency");
|
|
1487
|
+
let active = 0;
|
|
1488
|
+
let cancelled = null;
|
|
1489
|
+
const waiting = [];
|
|
1490
|
+
const acquire = (finalizer) => new Promise((resolve, reject) => {
|
|
1491
|
+
if (cancelled !== null && !finalizer) reject(cancelled.error);
|
|
1492
|
+
else if (active < max) {
|
|
1493
|
+
active += 1;
|
|
1494
|
+
resolve();
|
|
1495
|
+
} else waiting.push({
|
|
1496
|
+
start: () => {
|
|
1497
|
+
active += 1;
|
|
1498
|
+
resolve();
|
|
1499
|
+
},
|
|
1500
|
+
abort: reject,
|
|
1501
|
+
finalizer
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
const release = () => {
|
|
1505
|
+
active -= 1;
|
|
1506
|
+
waiting.shift()?.start();
|
|
1507
|
+
};
|
|
1508
|
+
return {
|
|
1509
|
+
async run(task) {
|
|
1510
|
+
await acquire(false);
|
|
1511
|
+
try {
|
|
1512
|
+
return await task();
|
|
1513
|
+
} finally {
|
|
1514
|
+
release();
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
async runFinalizer(task) {
|
|
1518
|
+
await acquire(true);
|
|
1519
|
+
try {
|
|
1520
|
+
return await task();
|
|
1521
|
+
} finally {
|
|
1522
|
+
release();
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1525
|
+
cancel(error) {
|
|
1526
|
+
cancelled ??= { error };
|
|
1527
|
+
const queued = waiting.splice(0);
|
|
1528
|
+
for (const entry of queued) if (entry.finalizer) waiting.push(entry);
|
|
1529
|
+
else entry.abort(error);
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
function assertPositiveInteger(value, label) {
|
|
1534
|
+
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${label} must be a positive integer; received ${value}.`);
|
|
1535
|
+
}
|
|
1280
1536
|
//#endregion
|
|
1281
1537
|
//#region ../js/src/athlete.ts
|
|
1282
1538
|
async function getJson(client, path, label) {
|
|
@@ -1314,9 +1570,19 @@ function fetchAthleteCircuits(client, kind = "recent") {
|
|
|
1314
1570
|
function fetchAthleteProgrammingPrograms(client) {
|
|
1315
1571
|
return getArray(client, "/1.0/athlete/programming/programs", "athlete programming programs");
|
|
1316
1572
|
}
|
|
1317
|
-
/**
|
|
1573
|
+
/**
|
|
1574
|
+
* Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). Only
|
|
1575
|
+
* rows whose title carries every query token are candidates; rankSearch scores but never drops a
|
|
1576
|
+
* row, so ranking the whole catalog would pad a no-match query with the shortest titles up to
|
|
1577
|
+
* `limit`. Mirrors the coach `ExerciseLibrary.search` filter, and a blank query returns nothing.
|
|
1578
|
+
*/
|
|
1318
1579
|
async function searchExerciseHistory(client, query, limit = 20) {
|
|
1319
|
-
|
|
1580
|
+
const tokens = buildSearchText(query).split(/\s+/u).filter((t) => t.length > 0);
|
|
1581
|
+
if (tokens.length === 0) return [];
|
|
1582
|
+
return rankSearch((await fetchExerciseHistoryList(client)).filter((row) => {
|
|
1583
|
+
const text = buildSearchText(row.title);
|
|
1584
|
+
return tokens.every((t) => text.includes(t));
|
|
1585
|
+
}), query, limit);
|
|
1320
1586
|
}
|
|
1321
1587
|
function fetchExerciseHistoryDetail(client, exerciseId, userId) {
|
|
1322
1588
|
return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
|
|
@@ -1371,6 +1637,28 @@ function slotValues(ex, requireMade) {
|
|
|
1371
1637
|
}
|
|
1372
1638
|
return map;
|
|
1373
1639
|
}
|
|
1640
|
+
/**
|
|
1641
|
+
* The slots carrying data that the athlete has NOT marked performed (`param_i_made !== 1`). On a
|
|
1642
|
+
* saved-copy row these are the row's own targets: the pre-filled prescription, an athlete- or
|
|
1643
|
+
* coach-level override written with `made = 0` (`prescribeAthleteSet`), or a personal session's
|
|
1644
|
+
* only prescription, since personal work has no template tree at all.
|
|
1645
|
+
*/
|
|
1646
|
+
function unmadeSlotValues(ex) {
|
|
1647
|
+
const map = slotValues(ex, false);
|
|
1648
|
+
for (const i of map.keys()) if (coerceInt(ex[`param_${i}_made`]) === 1) map.delete(i);
|
|
1649
|
+
return map;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* The prescription for one exercise: the template row's slots, with any target the saved copy
|
|
1653
|
+
* still holds unperformed taking precedence per set index (that is what the athlete's app shows,
|
|
1654
|
+
* and where a per-athlete override lives). Without a template row the saved copy's unperformed
|
|
1655
|
+
* slots are the whole prescription.
|
|
1656
|
+
*/
|
|
1657
|
+
function prescriptionSlots(template, saved) {
|
|
1658
|
+
const map = template ? slotValues(template, false) : /* @__PURE__ */ new Map();
|
|
1659
|
+
if (saved) for (const [i, v] of unmadeSlotValues(saved)) map.set(i, v);
|
|
1660
|
+
return map;
|
|
1661
|
+
}
|
|
1374
1662
|
/** The joined display for a slot, kept verbatim (`"5 @ 225"`, `"AMRAP"`, `"@ 225"`). */
|
|
1375
1663
|
function fmtSlot(slot) {
|
|
1376
1664
|
const has1 = slot.p1 !== null;
|
|
@@ -1380,10 +1668,6 @@ function fmtSlot(slot) {
|
|
|
1380
1668
|
if (has2) return `@ ${slot.p2}`;
|
|
1381
1669
|
return "";
|
|
1382
1670
|
}
|
|
1383
|
-
/** Every slot carrying data, joined for display (the prescription reader): `["5 @ 225", "AMRAP"]`. */
|
|
1384
|
-
function prescribedStrings(ex) {
|
|
1385
|
-
return [...slotValues(ex, false).values()].map(fmtSlot);
|
|
1386
|
-
}
|
|
1387
1671
|
/** The slots the athlete logged (`param_i_made === 1`), joined for display (the performed reader). */
|
|
1388
1672
|
function performedStrings(ex) {
|
|
1389
1673
|
return [...slotValues(ex, true).values()].map(fmtSlot);
|
|
@@ -1419,6 +1703,15 @@ function performedSlotsByExerciseId(sets) {
|
|
|
1419
1703
|
}
|
|
1420
1704
|
return map;
|
|
1421
1705
|
}
|
|
1706
|
+
/** Each saved-copy row keyed by the prescription exercise id it was copied from. */
|
|
1707
|
+
function savedRowsByTemplateId(sets) {
|
|
1708
|
+
const map = /* @__PURE__ */ new Map();
|
|
1709
|
+
for (const { exercises } of sets) for (const ex of exercises) {
|
|
1710
|
+
const id = coerceInt(ex.workout_set_exercise_id);
|
|
1711
|
+
if (id !== null && !map.has(id)) map.set(id, ex);
|
|
1712
|
+
}
|
|
1713
|
+
return map;
|
|
1714
|
+
}
|
|
1422
1715
|
/** Per-exercise athlete notes keyed by the prescription template id (`workout_set_exercise_id`). */
|
|
1423
1716
|
function notesByTemplateId(sets) {
|
|
1424
1717
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1442,7 +1735,7 @@ function mergeExercise(prescribed, performed, meta) {
|
|
|
1442
1735
|
};
|
|
1443
1736
|
}
|
|
1444
1737
|
/** A prescription block, each exercise aligned with the slots the athlete logged against it. */
|
|
1445
|
-
function mergePrescriptionBlock(set, performedById, notesById) {
|
|
1738
|
+
function mergePrescriptionBlock(set, performedById, savedById, notesById) {
|
|
1446
1739
|
const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
|
|
1447
1740
|
return {
|
|
1448
1741
|
order: coerceInt(set.order) ?? 0,
|
|
@@ -1452,7 +1745,7 @@ function mergePrescriptionBlock(set, performedById, notesById) {
|
|
|
1452
1745
|
exercises: exercises.filter(isRecord).map((ex) => {
|
|
1453
1746
|
const id = coerceInt(ex.id);
|
|
1454
1747
|
const performed = (id !== null ? performedById.get(id) : void 0) ?? /* @__PURE__ */ new Map();
|
|
1455
|
-
return mergeExercise(
|
|
1748
|
+
return mergeExercise(prescriptionSlots(ex, id !== null ? savedById.get(id) : void 0), performed, {
|
|
1456
1749
|
exerciseId: coerceInt(ex.exercise_id),
|
|
1457
1750
|
title: typeof ex.title === "string" ? ex.title : "",
|
|
1458
1751
|
instruction: str(ex.instruction),
|
|
@@ -1462,14 +1755,18 @@ function mergePrescriptionBlock(set, performedById, notesById) {
|
|
|
1462
1755
|
})
|
|
1463
1756
|
};
|
|
1464
1757
|
}
|
|
1465
|
-
/**
|
|
1758
|
+
/**
|
|
1759
|
+
* A block straight from the saved copy (athlete-added or personal work; no template row). Its
|
|
1760
|
+
* unperformed slots are the prescription — a personal session's targets live only here — and its
|
|
1761
|
+
* performed slots are what was logged.
|
|
1762
|
+
*/
|
|
1466
1763
|
function mergeSavedBlock(set, exercises) {
|
|
1467
1764
|
return {
|
|
1468
1765
|
order: coerceInt(set.order) ?? 0,
|
|
1469
1766
|
title: str(set.title),
|
|
1470
1767
|
instruction: str(set.instruction),
|
|
1471
1768
|
isTest: coerceInt(set.is_test) === 1,
|
|
1472
|
-
exercises: exercises.map((ex) => mergeExercise(
|
|
1769
|
+
exercises: exercises.map((ex) => mergeExercise(prescriptionSlots(void 0, ex), slotValues(ex, true), {
|
|
1473
1770
|
exerciseId: coerceInt(ex.exercise_id),
|
|
1474
1771
|
title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
|
|
1475
1772
|
instruction: str(ex.instruction),
|
|
@@ -1492,8 +1789,9 @@ function mergeAthleteWorkout(raw) {
|
|
|
1492
1789
|
const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
|
|
1493
1790
|
const logged = savedSets(saved);
|
|
1494
1791
|
const performedById = performedSlotsByExerciseId(logged);
|
|
1792
|
+
const savedById = savedRowsByTemplateId(logged);
|
|
1495
1793
|
const notesById = notesByTemplateId(logged);
|
|
1496
|
-
const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, notesById)).sort((a, b) => a.order - b.order);
|
|
1794
|
+
const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, savedById, notesById)).sort((a, b) => a.order - b.order);
|
|
1497
1795
|
const prescribedIds = /* @__PURE__ */ new Set();
|
|
1498
1796
|
for (const s of prescriptionSets) {
|
|
1499
1797
|
const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
|
|
@@ -1505,7 +1803,7 @@ function mergeAthleteWorkout(raw) {
|
|
|
1505
1803
|
}
|
|
1506
1804
|
for (const { set, exercises } of logged) {
|
|
1507
1805
|
const extra = exercises.filter((ex) => {
|
|
1508
|
-
if (slotValues(ex,
|
|
1806
|
+
if (slotValues(ex, false).size === 0) return false;
|
|
1509
1807
|
const id = coerceInt(ex.workout_set_exercise_id);
|
|
1510
1808
|
return id === null || !prescribedIds.has(id);
|
|
1511
1809
|
});
|
|
@@ -1616,6 +1914,16 @@ function presentLogTargets(list) {
|
|
|
1616
1914
|
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
1617
1915
|
const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
1618
1916
|
if (!saved) continue;
|
|
1917
|
+
const workout = isRecord(ssw.workout) ? ssw.workout : {};
|
|
1918
|
+
const templatesById = /* @__PURE__ */ new Map();
|
|
1919
|
+
for (const tSet of Array.isArray(workout.workoutSets) ? workout.workoutSets : []) {
|
|
1920
|
+
if (!isRecord(tSet)) continue;
|
|
1921
|
+
const tExs = Array.isArray(tSet.workoutSetExercises) ? tSet.workoutSetExercises : [];
|
|
1922
|
+
for (const tEx of tExs) {
|
|
1923
|
+
const tId = isRecord(tEx) ? coerceInt(tEx.id) : null;
|
|
1924
|
+
if (tId !== null && isRecord(tEx)) templatesById.set(tId, tEx);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1619
1927
|
const date = str(rec.date) ?? "";
|
|
1620
1928
|
const workoutTitle = str(rec.workout_title) ?? "";
|
|
1621
1929
|
const program = str(rec.program_title);
|
|
@@ -1630,12 +1938,14 @@ function presentLogTargets(list) {
|
|
|
1630
1938
|
const exercises = (Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : []).filter(isRecord).map((ex) => {
|
|
1631
1939
|
const id = coerceInt(ex.id);
|
|
1632
1940
|
if (id === null) return null;
|
|
1941
|
+
const templateId = coerceInt(ex.workout_set_exercise_id);
|
|
1942
|
+
const template = templateId === null ? void 0 : templatesById.get(templateId);
|
|
1633
1943
|
return {
|
|
1634
1944
|
savedWorkoutSetExerciseId: id,
|
|
1635
1945
|
title: exerciseTitle(ex),
|
|
1636
1946
|
units: exerciseUnits(ex.param_1_type, ex.param_2_type),
|
|
1637
1947
|
notes: str(ex.notes),
|
|
1638
|
-
prescribed:
|
|
1948
|
+
prescribed: [...prescriptionSlots(template, ex).values()].map(fmtSlot),
|
|
1639
1949
|
performed: performedStrings(ex)
|
|
1640
1950
|
};
|
|
1641
1951
|
}).filter((e) => e !== null);
|
|
@@ -1712,6 +2022,28 @@ function presentExerciseHistory(detail) {
|
|
|
1712
2022
|
};
|
|
1713
2023
|
}
|
|
1714
2024
|
//#endregion
|
|
2025
|
+
//#region ../js/src/exercise-history.ts
|
|
2026
|
+
/**
|
|
2027
|
+
* Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
|
|
2028
|
+
* The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
|
|
2029
|
+
* first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. The one
|
|
2030
|
+
* window rule shared by the athlete's own history tool, the coach's per-roster-athlete history
|
|
2031
|
+
* tool, and the CLI, so a timestamped session cannot fall out of an inclusive upper bound.
|
|
2032
|
+
*/
|
|
2033
|
+
function historyInRange(presented, since, until) {
|
|
2034
|
+
if (since === void 0 && until === void 0) return presented;
|
|
2035
|
+
const sessions = presented.sessions.filter((s) => {
|
|
2036
|
+
const d = (s.date ?? "").slice(0, 10);
|
|
2037
|
+
if (since !== void 0 && d < since) return false;
|
|
2038
|
+
if (until !== void 0 && d > until) return false;
|
|
2039
|
+
return true;
|
|
2040
|
+
});
|
|
2041
|
+
return {
|
|
2042
|
+
...presented,
|
|
2043
|
+
sessions
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
//#endregion
|
|
1715
2047
|
//#region ../js/src/exercise-set-payload.ts
|
|
1716
2048
|
function slotData(exercise, key) {
|
|
1717
2049
|
const value = exercise?.[key];
|
|
@@ -1782,6 +2114,7 @@ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, w
|
|
|
1782
2114
|
}
|
|
1783
2115
|
//#endregion
|
|
1784
2116
|
//#region ../js/src/athlete-set-write.ts
|
|
2117
|
+
const WRITE_CONCURRENCY = 4;
|
|
1785
2118
|
/**
|
|
1786
2119
|
* Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
|
|
1787
2120
|
* {@link SetResult}[]. The dto schemas validate ids as a number or a numeric string and leave
|
|
@@ -1974,6 +2307,41 @@ async function swapAthleteExercise(client, args) {
|
|
|
1974
2307
|
originalTeamExerciseId: coerceInt(template.exercise_id)
|
|
1975
2308
|
};
|
|
1976
2309
|
}
|
|
2310
|
+
function prepareSetWrite(target, workouts, savedWorkoutSetId, results, mode) {
|
|
2311
|
+
const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
|
|
2312
|
+
assertUniqueExerciseResults(results);
|
|
2313
|
+
const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
|
|
2314
|
+
const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
|
|
2315
|
+
return {
|
|
2316
|
+
savedWorkoutSetId,
|
|
2317
|
+
exercises,
|
|
2318
|
+
rawSet,
|
|
2319
|
+
suffix,
|
|
2320
|
+
extra,
|
|
2321
|
+
writes: results.map((result) => {
|
|
2322
|
+
const ex = exercises.find((candidate) => coerceInt(candidate.id) === result.savedWorkoutSetExerciseId);
|
|
2323
|
+
if (!ex) {
|
|
2324
|
+
const valid = exercises.map((candidate) => {
|
|
2325
|
+
const id = coerceInt(candidate.id);
|
|
2326
|
+
return id === null ? null : `${id} (${exerciseTitle(candidate, "exercise")})`;
|
|
2327
|
+
}).filter((label) => label !== null);
|
|
2328
|
+
throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
|
|
2329
|
+
}
|
|
2330
|
+
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
2331
|
+
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.`);
|
|
2332
|
+
return {
|
|
2333
|
+
id: result.savedWorkoutSetExerciseId,
|
|
2334
|
+
body: {
|
|
2335
|
+
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
|
|
2336
|
+
...extra
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
})
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
function errorMessage(error) {
|
|
2343
|
+
return error instanceof Error ? error.message : String(error);
|
|
2344
|
+
}
|
|
1977
2345
|
/**
|
|
1978
2346
|
* Shared set-write behind {@link logAthleteSet}, {@link prescribeAthleteSet},
|
|
1979
2347
|
* {@link logForAthlete}, and {@link prescribeForAthlete}. `target` selects the surface: `athlete`
|
|
@@ -1987,36 +2355,40 @@ async function swapAthleteExercise(client, args) {
|
|
|
1987
2355
|
* the written ones); `"prescribe"` writes them as a prescription and skips Step 2, leaving the set
|
|
1988
2356
|
* open.
|
|
1989
2357
|
*/
|
|
1990
|
-
async function
|
|
1991
|
-
const { exercises, rawSet } =
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
const id = coerceInt(e.id);
|
|
2002
|
-
return id === null ? null : `${id} (${exerciseTitle(e, "exercise")})`;
|
|
2003
|
-
}).filter((x) => x !== null);
|
|
2004
|
-
throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
|
|
2005
|
-
}
|
|
2006
|
-
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
2007
|
-
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.`);
|
|
2008
|
-
const body = {
|
|
2009
|
-
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
|
|
2010
|
-
...extra
|
|
2358
|
+
async function executeSetWrite(client, target, prepared, mode, limit, sessionProgress) {
|
|
2359
|
+
const { savedWorkoutSetId, exercises, rawSet, suffix, extra, writes } = prepared;
|
|
2360
|
+
const put = (path, body, failureMessage, finalizer = false) => {
|
|
2361
|
+
const request = async () => {
|
|
2362
|
+
try {
|
|
2363
|
+
const res = await client.request("PUT", path, { body });
|
|
2364
|
+
if (!res.ok) throw new Error(failureMessage(res.status));
|
|
2365
|
+
} catch (error) {
|
|
2366
|
+
limit.cancel(error);
|
|
2367
|
+
throw error;
|
|
2368
|
+
}
|
|
2011
2369
|
};
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
|
|
2370
|
+
return finalizer ? limit.runFinalizer(request) : limit.run(request);
|
|
2371
|
+
};
|
|
2372
|
+
const confirmed = [];
|
|
2373
|
+
try {
|
|
2374
|
+
await mapPool(writes, WRITE_CONCURRENCY, async ({ id, body }) => {
|
|
2375
|
+
await put(`/1.0/${target.role}/savedworkoutsetexercise/${id}${suffix}`, body, (status) => {
|
|
2376
|
+
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.` : ""}`;
|
|
2377
|
+
});
|
|
2378
|
+
confirmed.push(id);
|
|
2379
|
+
if (sessionProgress) {
|
|
2380
|
+
const sessionConfirmed = sessionProgress.get(savedWorkoutSetId) ?? [];
|
|
2381
|
+
sessionConfirmed.push(id);
|
|
2382
|
+
sessionProgress.set(savedWorkoutSetId, sessionConfirmed);
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
if (sessionProgress || confirmed.length === 0) throw error;
|
|
2387
|
+
confirmed.sort((a, b) => a - b);
|
|
2388
|
+
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 });
|
|
2019
2389
|
}
|
|
2390
|
+
const exercisesWritten = writes.length;
|
|
2391
|
+
const projectedCompletion = new Map(writes.map((w) => [w.id, w.body.completed === 1]));
|
|
2020
2392
|
let setCompleted = false;
|
|
2021
2393
|
if (mode === "log") {
|
|
2022
2394
|
if (isSetFullyLogged(exercises, projectedCompletion)) {
|
|
@@ -2024,8 +2396,11 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
2024
2396
|
...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
|
|
2025
2397
|
...extra
|
|
2026
2398
|
};
|
|
2027
|
-
|
|
2028
|
-
|
|
2399
|
+
try {
|
|
2400
|
+
await put(`/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, setBody, (status) => `Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${status}).`, true);
|
|
2401
|
+
} catch (error) {
|
|
2402
|
+
throw new Error(`${errorMessage(error)} Exercise values were written successfully; retry the same request to complete the set.`, { cause: error });
|
|
2403
|
+
}
|
|
2029
2404
|
setCompleted = true;
|
|
2030
2405
|
}
|
|
2031
2406
|
}
|
|
@@ -2035,6 +2410,9 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
2035
2410
|
setCompleted
|
|
2036
2411
|
};
|
|
2037
2412
|
}
|
|
2413
|
+
async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode, limit = createLimiter(WRITE_CONCURRENCY)) {
|
|
2414
|
+
return executeSetWrite(client, target, prepareSetWrite(target, workouts, savedWorkoutSetId, results, mode), mode, limit);
|
|
2415
|
+
}
|
|
2038
2416
|
/**
|
|
2039
2417
|
* POST /v5/programWorkouts/personal — create a personal workout session for a given date.
|
|
2040
2418
|
* Returns the key ids: workoutId (needed for addExercisesToWorkout), programWorkoutId,
|
|
@@ -2180,7 +2558,7 @@ function findScheduledMatches(workouts, exerciseIds) {
|
|
|
2180
2558
|
return out;
|
|
2181
2559
|
}
|
|
2182
2560
|
/** Group resolved exercises by saved set and write each set via the given log target. */
|
|
2183
|
-
async function logResolvedExercises(client, target, workouts, resolved) {
|
|
2561
|
+
async function logResolvedExercises(client, target, workouts, resolved, retryGuidance) {
|
|
2184
2562
|
const bySet = /* @__PURE__ */ new Map();
|
|
2185
2563
|
for (const r of resolved) {
|
|
2186
2564
|
const list = bySet.get(r.savedWorkoutSetId) ?? [];
|
|
@@ -2190,15 +2568,28 @@ async function logResolvedExercises(client, target, workouts, resolved) {
|
|
|
2190
2568
|
});
|
|
2191
2569
|
bySet.set(r.savedWorkoutSetId, list);
|
|
2192
2570
|
}
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2571
|
+
const prepared = [...bySet].map(([savedWorkoutSetId, results]) => prepareSetWrite(target, workouts, savedWorkoutSetId, results, "log"));
|
|
2572
|
+
const limit = createLimiter(WRITE_CONCURRENCY);
|
|
2573
|
+
const confirmedBySet = /* @__PURE__ */ new Map();
|
|
2574
|
+
const succeeded = [];
|
|
2575
|
+
try {
|
|
2576
|
+
return await mapPool(prepared, WRITE_CONCURRENCY, async (set) => {
|
|
2577
|
+
const written = await executeSetWrite(client, target, set, "log", limit, confirmedBySet);
|
|
2578
|
+
succeeded.push(written.savedWorkoutSetId);
|
|
2579
|
+
return {
|
|
2580
|
+
savedWorkoutSetId: written.savedWorkoutSetId,
|
|
2581
|
+
exercisesLogged: written.exercisesWritten
|
|
2582
|
+
};
|
|
2199
2583
|
});
|
|
2584
|
+
} catch (error) {
|
|
2585
|
+
const completed = new Set(succeeded);
|
|
2586
|
+
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(", ")}`);
|
|
2587
|
+
if (incomplete.length === 0 && succeeded.length === 0) throw error;
|
|
2588
|
+
succeeded.sort((a, b) => a - b);
|
|
2589
|
+
const partial = incomplete.length === 0 ? "" : ` Confirmed exercise writes in incomplete sets before the failure: ${incomplete.join("; ")}.${retryGuidance ? ` ${retryGuidance}` : ""}`;
|
|
2590
|
+
const complete = succeeded.length === 0 ? "" : ` Set writes confirmed before the failure: ${succeeded.join(", ")}.`;
|
|
2591
|
+
throw new Error(`${errorMessage(error)}${partial}${complete}`, { cause: error });
|
|
2200
2592
|
}
|
|
2201
|
-
return out;
|
|
2202
2593
|
}
|
|
2203
2594
|
/**
|
|
2204
2595
|
* Log a whole session for the logged-in athlete by exercise, with no pre-existing prescription
|
|
@@ -2312,27 +2703,6 @@ async function setAthleteExerciseNote(client, args) {
|
|
|
2312
2703
|
notes: typeof data.notes === "string" ? data.notes : args.notes
|
|
2313
2704
|
};
|
|
2314
2705
|
}
|
|
2315
|
-
//#endregion
|
|
2316
|
-
//#region ../core/src/history.ts
|
|
2317
|
-
/**
|
|
2318
|
-
* Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
|
|
2319
|
-
* The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
|
|
2320
|
-
* first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. Shared by
|
|
2321
|
-
* the athlete's own history tool and the coach's per-roster-athlete history tool.
|
|
2322
|
-
*/
|
|
2323
|
-
function historyInRange(presented, since, until) {
|
|
2324
|
-
if (since === void 0 && until === void 0) return presented;
|
|
2325
|
-
const sessions = presented.sessions.filter((s) => {
|
|
2326
|
-
const d = (s.date ?? "").slice(0, 10);
|
|
2327
|
-
if (since !== void 0 && d < since) return false;
|
|
2328
|
-
if (until !== void 0 && d > until) return false;
|
|
2329
|
-
return true;
|
|
2330
|
-
});
|
|
2331
|
-
return {
|
|
2332
|
-
...presented,
|
|
2333
|
-
sessions
|
|
2334
|
-
};
|
|
2335
|
-
}
|
|
2336
2706
|
z.number().int().positive().max(36).optional();
|
|
2337
2707
|
//#endregion
|
|
2338
2708
|
//#region ../core/src/tools/athlete-training.ts
|
|
@@ -2765,7 +3135,7 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
2765
3135
|
}
|
|
2766
3136
|
//#endregion
|
|
2767
3137
|
//#region package.json
|
|
2768
|
-
var version = "3.
|
|
3138
|
+
var version = "3.6.0";
|
|
2769
3139
|
//#endregion
|
|
2770
3140
|
//#region src/server.ts
|
|
2771
3141
|
function main() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainheroic-unofficial/athlete-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
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.
|
|
25
|
-
"@trainheroic-unofficial/js": "3.
|
|
24
|
+
"@trainheroic-unofficial/core": "3.6.0",
|
|
25
|
+
"@trainheroic-unofficial/js": "3.6.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.3.0",
|