@trainheroic-unofficial/athlete-mcp 1.4.0 → 1.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 +421 -79
- package/package.json +3 -3
package/dist/server.mjs
CHANGED
|
@@ -160,20 +160,41 @@ const loggedSetSchema = z.object({
|
|
|
160
160
|
param2: z.union([z.number(), z.string()]).optional()
|
|
161
161
|
});
|
|
162
162
|
/**
|
|
163
|
+
* A logged set that can name the prescribed position it fills. `slot` is the 1-based set
|
|
164
|
+
* index in the prescription (10 max) the result should land in; omit it to fill the next
|
|
165
|
+
* sequential position (the first entry is set 1, the second set 2, and so on). Targeting a
|
|
166
|
+
* slot lets a partial log land in the right positions of a multi-set prescription — e.g.
|
|
167
|
+
* three top singles into the 4th/5th/6th positions of an `8,5,3,1,1,1` ramp. Used by the
|
|
168
|
+
* by-set logging write (not the by-exercise session log, where each exercise's sets are
|
|
169
|
+
* always sequential).
|
|
170
|
+
*/
|
|
171
|
+
const loggedSetWithSlotSchema = loggedSetSchema.extend({ slot: z.number().int().min(1).max(10).optional() });
|
|
172
|
+
/**
|
|
163
173
|
* Args for the set-logging write. `date` (the workout's day) locates the saved
|
|
164
174
|
* workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
|
|
165
|
-
* gives, per exercise in it, the entered value of each set
|
|
175
|
+
* gives, per exercise in it, the entered value of each set. Each set fills the next position by
|
|
176
|
+
* default, or names its `slot` (1-based) to place a partial log at specific positions. A partial
|
|
177
|
+
* log keeps positions already logged in an earlier call and leaves the positions it does not
|
|
178
|
+
* write empty.
|
|
166
179
|
*/
|
|
167
180
|
const logSetArgsSchema = z.object({
|
|
168
181
|
date: dateString,
|
|
169
182
|
savedWorkoutSetId: idArgSchema,
|
|
170
183
|
results: z.array(z.object({
|
|
171
184
|
savedWorkoutSetExerciseId: idArgSchema,
|
|
172
|
-
sets: z.array(
|
|
185
|
+
sets: z.array(loggedSetWithSlotSchema).min(1)
|
|
173
186
|
})).min(1)
|
|
174
187
|
});
|
|
175
188
|
logSetArgsSchema.extend({ athleteId: idArgSchema });
|
|
176
|
-
|
|
189
|
+
z.object({
|
|
190
|
+
date: dateString,
|
|
191
|
+
savedWorkoutSetId: idArgSchema,
|
|
192
|
+
athleteId: idArgSchema,
|
|
193
|
+
results: z.array(z.object({
|
|
194
|
+
savedWorkoutSetExerciseId: idArgSchema,
|
|
195
|
+
sets: z.array(loggedSetSchema).min(1)
|
|
196
|
+
})).min(1)
|
|
197
|
+
});
|
|
177
198
|
z.object({
|
|
178
199
|
savedWorkoutSetExerciseId: idArgSchema,
|
|
179
200
|
exerciseId: idArgSchema
|
|
@@ -193,6 +214,16 @@ const logSessionArgsSchema = z.object({
|
|
|
193
214
|
})).min(1)
|
|
194
215
|
});
|
|
195
216
|
logSessionArgsSchema.extend({ athleteId: idArgSchema });
|
|
217
|
+
/**
|
|
218
|
+
* Args for removing a personal (athlete-created) workout session. `programWorkoutId` is the
|
|
219
|
+
* range item's top-level `id`; `date` is that session's day, used to look the item back up so the
|
|
220
|
+
* write can confirm it is a personal session (`personal_cal === true`) before deleting. A
|
|
221
|
+
* coach-scheduled workout is never removed this way.
|
|
222
|
+
*/
|
|
223
|
+
const athleteSessionRemoveArgsSchema = z.object({
|
|
224
|
+
programWorkoutId: idArgSchema,
|
|
225
|
+
date: dateString
|
|
226
|
+
});
|
|
196
227
|
z.looseObject({
|
|
197
228
|
title: z.string().min(1),
|
|
198
229
|
param_1_type: z.number().optional(),
|
|
@@ -635,6 +666,26 @@ function exerciseUnits(param1, param2) {
|
|
|
635
666
|
function isRecord(x) {
|
|
636
667
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
637
668
|
}
|
|
669
|
+
/** A non-empty string, or null. Narrows a loosely-typed API field to a usable string. */
|
|
670
|
+
function str(v) {
|
|
671
|
+
return typeof v === "string" && v !== "" ? v : null;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* The display title of a saved-copy exercise row, preferring `exercise_title`, then `title`, then
|
|
675
|
+
* the given fallback. The saved-workout shape carries the title under either key depending on the
|
|
676
|
+
* endpoint, so this is the one place that reconciles them.
|
|
677
|
+
*/
|
|
678
|
+
function exerciseTitle(ex, fallback = "") {
|
|
679
|
+
return typeof ex.exercise_title === "string" && ex.exercise_title || typeof ex.title === "string" && ex.title || fallback;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* True when a program-workout range item is a personal (athlete-created) session rather than a
|
|
683
|
+
* coach-scheduled one. The API marks these with `personal_cal === true`; this is the single
|
|
684
|
+
* definition every consumer (presenter, find-or-create, the remove guard) reads through.
|
|
685
|
+
*/
|
|
686
|
+
function isPersonalSession(pw) {
|
|
687
|
+
return isRecord(pw) && pw.personal_cal === true;
|
|
688
|
+
}
|
|
638
689
|
/**
|
|
639
690
|
* Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
|
|
640
691
|
* exact title, then prefix, then count of matched tokens, with shorter titles and
|
|
@@ -720,7 +771,6 @@ function fetchLeaderboard(client, workoutId, opts = {}) {
|
|
|
720
771
|
const query = qs.toString();
|
|
721
772
|
return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
|
|
722
773
|
}
|
|
723
|
-
const SLOTS = 10;
|
|
724
774
|
function nonEmpty(value) {
|
|
725
775
|
return value !== void 0 && value !== null && String(value).trim() !== "";
|
|
726
776
|
}
|
|
@@ -731,7 +781,7 @@ function nonEmpty(value) {
|
|
|
731
781
|
*/
|
|
732
782
|
function prescribedSets(ex) {
|
|
733
783
|
const out = [];
|
|
734
|
-
for (let i = 1; i <=
|
|
784
|
+
for (let i = 1; i <= 10; i += 1) {
|
|
735
785
|
const p1 = ex[`param_1_data_${i}`];
|
|
736
786
|
const p2 = ex[`param_2_data_${i}`];
|
|
737
787
|
const has1 = nonEmpty(p1);
|
|
@@ -752,7 +802,7 @@ function prescribedSets(ex) {
|
|
|
752
802
|
*/
|
|
753
803
|
function performedSets(ex) {
|
|
754
804
|
const out = [];
|
|
755
|
-
for (let i = 1; i <=
|
|
805
|
+
for (let i = 1; i <= 10; i += 1) {
|
|
756
806
|
if (coerceInt(ex[`param_${i}_made`]) !== 1) continue;
|
|
757
807
|
const p1 = ex[`param_1_data_${i}`];
|
|
758
808
|
const p2 = ex[`param_2_data_${i}`];
|
|
@@ -786,9 +836,6 @@ function presentBlock(set, performedById) {
|
|
|
786
836
|
exercises: exercises.filter(isRecord).map((ex) => presentExercise(ex, performedById))
|
|
787
837
|
};
|
|
788
838
|
}
|
|
789
|
-
function str(v) {
|
|
790
|
-
return typeof v === "string" && v !== "" ? v : null;
|
|
791
|
-
}
|
|
792
839
|
/** Every logged set (programmed + athlete-added) in the saved copy, paired with its exercises. */
|
|
793
840
|
function savedSets(saved) {
|
|
794
841
|
const out = [];
|
|
@@ -879,6 +926,7 @@ function presentAthleteWorkout(raw) {
|
|
|
879
926
|
team: str(rec.team_title),
|
|
880
927
|
instruction: str(workout.instruction),
|
|
881
928
|
logged: blocks.some((b) => b.exercises.some((e) => e.performed.length > 0)),
|
|
929
|
+
personal: isPersonalSession(rec),
|
|
882
930
|
blocks
|
|
883
931
|
};
|
|
884
932
|
}
|
|
@@ -886,6 +934,81 @@ function presentAthleteWorkouts(list) {
|
|
|
886
934
|
return list.map(presentAthleteWorkout);
|
|
887
935
|
}
|
|
888
936
|
/**
|
|
937
|
+
* Narrow a coach-athlete workout range to a single program or team. The range endpoint returns
|
|
938
|
+
* every program the athlete is enrolled in on a date; for a high-enrollment athlete that is many
|
|
939
|
+
* workouts, so a caller wanting one program's session narrows it here. Match by `programId`/`teamId`
|
|
940
|
+
* (exact, on the raw `program_id`/`team_id`) or by `programTitle` (case-insensitive substring on the
|
|
941
|
+
* `program_title`/`team_title`) — the title match lets a caller target a program by name without
|
|
942
|
+
* first resolving its id. Any one match keeps the workout; with no filter the list is unchanged.
|
|
943
|
+
*/
|
|
944
|
+
function selectWorkoutsByProgram(list, filter) {
|
|
945
|
+
const { programId, teamId, programTitle } = filter;
|
|
946
|
+
const needle = programTitle?.trim().toLowerCase();
|
|
947
|
+
if (programId === void 0 && teamId === void 0 && !needle) return [...list];
|
|
948
|
+
return list.filter((pw) => {
|
|
949
|
+
const rec = pw;
|
|
950
|
+
if (programId !== void 0 && coerceInt(rec.program_id) === programId) return true;
|
|
951
|
+
if (teamId !== void 0 && coerceInt(rec.team_id) === teamId) return true;
|
|
952
|
+
if (needle) {
|
|
953
|
+
if (`${str(rec.program_title) ?? ""} ${str(rec.team_title) ?? ""}`.toLowerCase().includes(needle)) return true;
|
|
954
|
+
}
|
|
955
|
+
return false;
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Project a workout range to just the ids a set-log write needs, read from the SAME saved-copy
|
|
960
|
+
* location {@link findSavedWorkoutSet} matches against (`summarizedSavedWorkout.saved_workout`).
|
|
961
|
+
* This is the self-service path for logging: instead of grepping the multi-KB `--raw` blob for
|
|
962
|
+
* which of several id fields maps to `--set`, callers read `savedWorkoutSetId` and each
|
|
963
|
+
* `savedWorkoutSetExerciseId` straight off these rows. Each target also carries its program/team
|
|
964
|
+
* so a caller can pick the right session when the athlete is on several. One row per saved set,
|
|
965
|
+
* dropping any set with no resolvable id.
|
|
966
|
+
*/
|
|
967
|
+
function presentLogTargets(list) {
|
|
968
|
+
const targets = [];
|
|
969
|
+
for (const pw of list) {
|
|
970
|
+
const rec = pw;
|
|
971
|
+
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
972
|
+
const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
973
|
+
if (!saved) continue;
|
|
974
|
+
const date = str(rec.date) ?? "";
|
|
975
|
+
const workoutTitle = str(rec.workout_title) ?? "";
|
|
976
|
+
const program = str(rec.program_title);
|
|
977
|
+
const programId = coerceInt(rec.program_id);
|
|
978
|
+
const team = str(rec.team_title);
|
|
979
|
+
const teamId = coerceInt(rec.team_id);
|
|
980
|
+
const sets = Array.isArray(saved.workoutSets) ? saved.workoutSets : [];
|
|
981
|
+
for (const s of sets) {
|
|
982
|
+
if (!isRecord(s)) continue;
|
|
983
|
+
const savedWorkoutSetId = coerceInt(s.id);
|
|
984
|
+
if (savedWorkoutSetId === null) continue;
|
|
985
|
+
const exercises = (Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : []).filter(isRecord).map((ex) => {
|
|
986
|
+
const id = coerceInt(ex.id);
|
|
987
|
+
if (id === null) return null;
|
|
988
|
+
return {
|
|
989
|
+
savedWorkoutSetExerciseId: id,
|
|
990
|
+
title: exerciseTitle(ex),
|
|
991
|
+
units: exerciseUnits(ex.param_1_type, ex.param_2_type),
|
|
992
|
+
prescribed: prescribedSets(ex),
|
|
993
|
+
performed: performedSets(ex)
|
|
994
|
+
};
|
|
995
|
+
}).filter((e) => e !== null);
|
|
996
|
+
targets.push({
|
|
997
|
+
date,
|
|
998
|
+
workoutTitle,
|
|
999
|
+
program,
|
|
1000
|
+
programId,
|
|
1001
|
+
team,
|
|
1002
|
+
teamId,
|
|
1003
|
+
savedWorkoutSetId,
|
|
1004
|
+
setTitle: str(s.title),
|
|
1005
|
+
exercises
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return targets;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
889
1012
|
* Narrow a presented workout list for the common "what did I actually do" reads. `loggedOnly`
|
|
890
1013
|
* keeps only workouts the athlete logged a set on (the reliable signal, not the API's
|
|
891
1014
|
* completion flag). `limit` keeps the most recent N by date (newest first). Both are pure
|
|
@@ -913,12 +1036,58 @@ function summarizeAthleteWorkouts(list) {
|
|
|
913
1036
|
program: w.program,
|
|
914
1037
|
team: w.team,
|
|
915
1038
|
logged: w.logged,
|
|
1039
|
+
personal: w.personal,
|
|
916
1040
|
exerciseCount,
|
|
917
1041
|
performedCount
|
|
918
1042
|
};
|
|
919
1043
|
});
|
|
920
1044
|
}
|
|
921
|
-
|
|
1045
|
+
/** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
|
|
1046
|
+
function presentExerciseHistory(detail) {
|
|
1047
|
+
return {
|
|
1048
|
+
liftPRs: (detail.liftPRs ?? []).map((p) => ({
|
|
1049
|
+
description: p.description ?? null,
|
|
1050
|
+
reps: p.reps ?? null,
|
|
1051
|
+
weight: p.weight ?? null,
|
|
1052
|
+
units: p.units ?? null,
|
|
1053
|
+
date: p.dateCompleted ?? null
|
|
1054
|
+
})),
|
|
1055
|
+
sessions: (detail.history ?? []).map((h) => ({
|
|
1056
|
+
date: h.dateCompleted,
|
|
1057
|
+
abr: h.abr ?? null,
|
|
1058
|
+
estimated1RM: h.bestEstimated1RM ?? null,
|
|
1059
|
+
sets: (h.sets ?? []).map((s) => ({
|
|
1060
|
+
setNumber: s.setNumber,
|
|
1061
|
+
value: s.formattedValue ?? null
|
|
1062
|
+
}))
|
|
1063
|
+
}))
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region ../js/src/athlete-set-write.ts
|
|
1068
|
+
/**
|
|
1069
|
+
* Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
|
|
1070
|
+
* {@link SetResult}[]. The dto schemas validate ids as a number or a numeric string and leave
|
|
1071
|
+
* empty param slots optional; this narrows the id to a number and copies only the present per-set
|
|
1072
|
+
* values (so the per-set type stays free of `undefined` under exactOptionalPropertyTypes). Shared
|
|
1073
|
+
* by the MCP tools and the CLI so the mapping lives in one place rather than once per surface.
|
|
1074
|
+
*/
|
|
1075
|
+
function toSetResults(results) {
|
|
1076
|
+
return results.map((r) => {
|
|
1077
|
+
const id = coerceInt(r.savedWorkoutSetExerciseId);
|
|
1078
|
+
if (id === null) throw new Error(`Invalid savedWorkoutSetExerciseId: ${String(r.savedWorkoutSetExerciseId)}`);
|
|
1079
|
+
return {
|
|
1080
|
+
savedWorkoutSetExerciseId: id,
|
|
1081
|
+
sets: r.sets.map((s) => {
|
|
1082
|
+
const set = {};
|
|
1083
|
+
if (s.param1 !== void 0) set.param1 = s.param1;
|
|
1084
|
+
if (s.param2 !== void 0) set.param2 = s.param2;
|
|
1085
|
+
if (s.slot !== void 0) set.slot = s.slot;
|
|
1086
|
+
return set;
|
|
1087
|
+
})
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
922
1091
|
/**
|
|
923
1092
|
* Build the body for `PUT /1.0/{role}/savedworkoutsetexercise/{id}`. The body uses snake_case
|
|
924
1093
|
* keys matching the live API response shape. Each set slot (1-10) carries `param_1_data_N` /
|
|
@@ -926,36 +1095,96 @@ const MAX_PARAM_SLOTS = 10;
|
|
|
926
1095
|
*
|
|
927
1096
|
* `mode` selects which write this is — the same endpoint serves both:
|
|
928
1097
|
* - `"log"`: the values ARE a performed result, so `param_N_made` is 1 where the slot has data
|
|
929
|
-
* and the exercise `completed` flag is 1 when any set has data.
|
|
1098
|
+
* and the exercise `completed` flag is 1 when any set has logged data.
|
|
930
1099
|
* - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
|
|
931
1100
|
* `completed` left at 0 so the set is not marked done. This matches what the app sends when a
|
|
932
1101
|
* coach edits an athlete's prescribed reps/weight.
|
|
933
1102
|
*
|
|
1103
|
+
* Each set fills a 1-based slot: its explicit `slot`, or its sequential position in `results`
|
|
1104
|
+
* when `slot` is omitted. A `log` carrying the live exercise record in `existing` keeps the slots
|
|
1105
|
+
* it does not write that were ALREADY performed (`param_N_made === 1`), so logging a second part of
|
|
1106
|
+
* a set does not wipe the earlier-logged sets. A slot holding only un-logged prescription pre-fill
|
|
1107
|
+
* (`param_N_made === 0`) is left blank rather than carried over: marking the set completed makes
|
|
1108
|
+
* the server flag every data-bearing slot performed, so preserving that pre-fill would fabricate
|
|
1109
|
+
* sets the athlete never did. The prescription is unaffected (it lives in the separate `workout`
|
|
1110
|
+
* copy, not this saved copy). A `prescribe` ignores `existing` and replaces the whole prescription.
|
|
1111
|
+
*
|
|
934
1112
|
* Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
|
|
935
|
-
* required from the live exercise record; everything else is derived from `results
|
|
1113
|
+
* required from the live exercise record; everything else is derived from `results` and the
|
|
1114
|
+
* preserved slots of `existing`.
|
|
936
1115
|
*
|
|
937
1116
|
* Exported for unit testing — callers should use `logAthleteSet` / `prescribeForAthlete` instead.
|
|
938
1117
|
*/
|
|
939
|
-
function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode) {
|
|
940
|
-
if (results.length >
|
|
1118
|
+
function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode, existing) {
|
|
1119
|
+
if (results.length > 10) throw new Error(`At most 10 sets are supported per exercise; got ${results.length}.`);
|
|
1120
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
1121
|
+
results.forEach((set, i) => {
|
|
1122
|
+
const slot = set.slot ?? i + 1;
|
|
1123
|
+
if (slot < 1 || slot > 10) throw new Error(`Set slot ${slot} is out of range; slots are 1–10.`);
|
|
1124
|
+
if (bySlot.has(slot)) throw new Error(`Two sets target slot ${slot}; each slot can be written once.`);
|
|
1125
|
+
bySlot.set(slot, set);
|
|
1126
|
+
});
|
|
941
1127
|
const performed = mode === "log";
|
|
942
|
-
const
|
|
1128
|
+
const carryOver = performed && existing !== void 0;
|
|
943
1129
|
const body = {
|
|
944
1130
|
id: savedWorkoutSetExerciseId,
|
|
945
1131
|
saved_workout_set_id: savedWorkoutSetId,
|
|
946
|
-
workout_set_exercise_id: workoutSetExerciseId
|
|
947
|
-
completed: performed && hasData ? 1 : 0
|
|
1132
|
+
workout_set_exercise_id: workoutSetExerciseId
|
|
948
1133
|
};
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
1134
|
+
let anyMade = false;
|
|
1135
|
+
for (let i = 1; i <= 10; i += 1) {
|
|
1136
|
+
const target = bySlot.get(i);
|
|
1137
|
+
let p1;
|
|
1138
|
+
let p2;
|
|
1139
|
+
let made;
|
|
1140
|
+
if (target) {
|
|
1141
|
+
p1 = target.param1 !== void 0 ? String(target.param1) : "";
|
|
1142
|
+
p2 = target.param2 !== void 0 ? String(target.param2) : "";
|
|
1143
|
+
made = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
|
|
1144
|
+
} else if (carryOver && coerceInt(existing?.[`param_${i}_made`]) === 1) {
|
|
1145
|
+
p1 = existingSlotData(existing, `param_1_data_${i}`);
|
|
1146
|
+
p2 = existingSlotData(existing, `param_2_data_${i}`);
|
|
1147
|
+
made = 1;
|
|
1148
|
+
} else {
|
|
1149
|
+
p1 = "";
|
|
1150
|
+
p2 = "";
|
|
1151
|
+
made = 0;
|
|
1152
|
+
}
|
|
1153
|
+
if (made === 1) anyMade = true;
|
|
1154
|
+
body[`param_${i}_made`] = made;
|
|
954
1155
|
body[`param_1_data_${i}`] = p1;
|
|
955
1156
|
body[`param_2_data_${i}`] = p2;
|
|
956
1157
|
}
|
|
1158
|
+
body.completed = performed && anyMade ? 1 : 0;
|
|
957
1159
|
return body;
|
|
958
1160
|
}
|
|
1161
|
+
/** Read a saved-copy slot value (`param_1_data_N` / `param_2_data_N`) as the string the body uses. */
|
|
1162
|
+
function existingSlotData(existing, key) {
|
|
1163
|
+
const v = existing?.[key];
|
|
1164
|
+
return v === void 0 || v === null ? "" : String(v);
|
|
1165
|
+
}
|
|
1166
|
+
/** True when a saved-copy exercise already carries a performed slot (any `param_N_made` === 1). */
|
|
1167
|
+
function exerciseHasLoggedData(ex) {
|
|
1168
|
+
for (let i = 1; i <= 10; i += 1) if (coerceInt(ex[`param_${i}_made`]) === 1) return true;
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
/** True when a result carries at least one non-empty param value (so it produces a performed slot). */
|
|
1172
|
+
function resultHasData(result) {
|
|
1173
|
+
return result.sets.some((s) => s.param1 !== void 0 && String(s.param1) !== "" || s.param2 !== void 0 && String(s.param2) !== "");
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Whether every exercise in a saved workout set now has logged data — either written with data in
|
|
1177
|
+
* this call (`loggedIds`) or already carrying a performed slot. Gates the set-completion PUT: a
|
|
1178
|
+
* superset/circuit stays open until the last exercise is logged, so completing it on a partial log
|
|
1179
|
+
* does not flip its still-empty siblings to "done". An exercise written with only empty values does
|
|
1180
|
+
* not count (it would not be marked performed), so an all-empty log never completes the set.
|
|
1181
|
+
*/
|
|
1182
|
+
function isSetFullyLogged(exercises, loggedIds) {
|
|
1183
|
+
return exercises.every((ex) => {
|
|
1184
|
+
const id = coerceInt(ex.id);
|
|
1185
|
+
return id !== null && loggedIds.has(id) || exerciseHasLoggedData(ex);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
959
1188
|
/**
|
|
960
1189
|
* Locate the target saved workout set across all program workouts on the given day.
|
|
961
1190
|
* Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
|
|
@@ -986,8 +1215,7 @@ function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
|
|
|
986
1215
|
if (setId !== null) {
|
|
987
1216
|
const exLabels = exercises.map((ex) => {
|
|
988
1217
|
const exId = coerceInt(ex.id);
|
|
989
|
-
|
|
990
|
-
return exId === null ? null : `${exId} (${title})`;
|
|
1218
|
+
return exId === null ? null : `${exId} (${exerciseTitle(ex, "exercise")})`;
|
|
991
1219
|
}).filter((x) => x !== null);
|
|
992
1220
|
available.push(`set ${setId} → exercise ids: ${exLabels.join(", ") || "none"}`);
|
|
993
1221
|
}
|
|
@@ -1054,7 +1282,8 @@ async function logAthleteSet(client, args) {
|
|
|
1054
1282
|
const r = await writeSetResults(client, { role: "athlete" }, await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId, args.results, "log");
|
|
1055
1283
|
return {
|
|
1056
1284
|
savedWorkoutSetId: r.savedWorkoutSetId,
|
|
1057
|
-
exercisesLogged: r.exercisesWritten
|
|
1285
|
+
exercisesLogged: r.exercisesWritten,
|
|
1286
|
+
setCompleted: r.setCompleted
|
|
1058
1287
|
};
|
|
1059
1288
|
}
|
|
1060
1289
|
/**
|
|
@@ -1075,7 +1304,8 @@ async function logForAthlete(client, args) {
|
|
|
1075
1304
|
}, workouts, args.savedWorkoutSetId, args.results, "log");
|
|
1076
1305
|
return {
|
|
1077
1306
|
savedWorkoutSetId: r.savedWorkoutSetId,
|
|
1078
|
-
exercisesLogged: r.exercisesWritten
|
|
1307
|
+
exercisesLogged: r.exercisesWritten,
|
|
1308
|
+
setCompleted: r.setCompleted
|
|
1079
1309
|
};
|
|
1080
1310
|
}
|
|
1081
1311
|
/**
|
|
@@ -1100,15 +1330,14 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
1100
1330
|
if (!ex) {
|
|
1101
1331
|
const valid = exercises.map((e) => {
|
|
1102
1332
|
const id = coerceInt(e.id);
|
|
1103
|
-
|
|
1104
|
-
return id === null ? null : `${id} (${title})`;
|
|
1333
|
+
return id === null ? null : `${id} (${exerciseTitle(e, "exercise")})`;
|
|
1105
1334
|
}).filter((x) => x !== null);
|
|
1106
1335
|
throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
|
|
1107
1336
|
}
|
|
1108
1337
|
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
1109
1338
|
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.`);
|
|
1110
1339
|
const body = {
|
|
1111
|
-
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode),
|
|
1340
|
+
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
|
|
1112
1341
|
...extra
|
|
1113
1342
|
};
|
|
1114
1343
|
const res = await client.request("PUT", `/1.0/${target.role}/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}${suffix}`, { body });
|
|
@@ -1118,17 +1347,22 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
1118
1347
|
}
|
|
1119
1348
|
exercisesWritten += 1;
|
|
1120
1349
|
}
|
|
1350
|
+
let setCompleted = false;
|
|
1121
1351
|
if (mode === "log") {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1352
|
+
if (isSetFullyLogged(exercises, new Set(results.filter(resultHasData).map((r) => r.savedWorkoutSetExerciseId)))) {
|
|
1353
|
+
const setBody = {
|
|
1354
|
+
...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
|
|
1355
|
+
...extra
|
|
1356
|
+
};
|
|
1357
|
+
const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
|
|
1358
|
+
if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
|
|
1359
|
+
setCompleted = true;
|
|
1360
|
+
}
|
|
1128
1361
|
}
|
|
1129
1362
|
return {
|
|
1130
1363
|
savedWorkoutSetId,
|
|
1131
|
-
exercisesWritten
|
|
1364
|
+
exercisesWritten,
|
|
1365
|
+
setCompleted
|
|
1132
1366
|
};
|
|
1133
1367
|
}
|
|
1134
1368
|
/**
|
|
@@ -1171,17 +1405,30 @@ async function addExercisesToWorkout(client, workoutId, exercises) {
|
|
|
1171
1405
|
return res.data;
|
|
1172
1406
|
}
|
|
1173
1407
|
/**
|
|
1408
|
+
* DELETE /v5/programWorkouts/{programWorkoutId} — remove a personal (athlete-created) workout
|
|
1409
|
+
* session from the athlete's own calendar. Re-reads the day and removes ONLY a personal session
|
|
1410
|
+
* (`personal_cal`): it throws if no workout with that id is on the date, or if the target is a
|
|
1411
|
+
* coach-scheduled workout (which is not the athlete's to delete). This guard lives here, at the
|
|
1412
|
+
* delete itself, so no caller can issue the DELETE against a coach workout by skipping a check.
|
|
1413
|
+
* Throws on a non-ok response.
|
|
1414
|
+
*/
|
|
1415
|
+
async function removePersonalWorkout(client, args) {
|
|
1416
|
+
const target = (await fetchAthleteWorkouts(client, args.date, args.date)).find((pw) => coerceInt(pw.id) === args.programWorkoutId);
|
|
1417
|
+
if (target === void 0) throw new Error(`No workout with id ${args.programWorkoutId} on ${args.date}. Get the id and date from athlete_workouts.`);
|
|
1418
|
+
if (!isPersonalSession(target)) throw new Error(`Workout ${args.programWorkoutId} on ${args.date} is a coach-scheduled workout, not a personal session, so it can't be removed. To change logged results on a scheduled workout, use athlete_log_set.`);
|
|
1419
|
+
const res = await client.request("DELETE", `/v5/programWorkouts/${args.programWorkoutId}`);
|
|
1420
|
+
if (!res.ok) throw new Error(`Remove personal workout failed (HTTP ${res.status}).`);
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1174
1423
|
* Find a personal-calendar session already on the given day. The range marks these with
|
|
1175
1424
|
* `personal_cal === true`; the addable id is the program workout's `workout_id`. Returns the
|
|
1176
1425
|
* first one's workoutId, or null when the day has no personal session.
|
|
1177
1426
|
*/
|
|
1178
1427
|
function findPersonalSessionWorkoutId(workouts) {
|
|
1179
1428
|
for (const pw of workouts) {
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
if (workoutId !== null) return workoutId;
|
|
1184
|
-
}
|
|
1429
|
+
if (!isPersonalSession(pw)) continue;
|
|
1430
|
+
const workoutId = coerceInt(pw.workout_id);
|
|
1431
|
+
if (workoutId !== null) return workoutId;
|
|
1185
1432
|
}
|
|
1186
1433
|
return null;
|
|
1187
1434
|
}
|
|
@@ -1212,6 +1459,59 @@ function indexAddedExercises(added) {
|
|
|
1212
1459
|
}
|
|
1213
1460
|
return out;
|
|
1214
1461
|
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Flatten a day's saved sets into `{ pw, savedWorkoutSetId, ex }` rows (both saved-set keys),
|
|
1464
|
+
* carrying the parent program-workout so a caller can read its identity (`personal_cal`, program
|
|
1465
|
+
* title) without re-walking. The one saved-copy traversal the by-exercise paths share.
|
|
1466
|
+
*/
|
|
1467
|
+
function eachPrescribedExercise(workouts) {
|
|
1468
|
+
const rows = [];
|
|
1469
|
+
for (const pw of workouts) {
|
|
1470
|
+
const rec = pw;
|
|
1471
|
+
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
1472
|
+
const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
1473
|
+
if (!sw) continue;
|
|
1474
|
+
const sets = [...Array.isArray(sw.workoutSets) ? sw.workoutSets : [], ...Array.isArray(sw.addedWorkoutSets) ? sw.addedWorkoutSets : []];
|
|
1475
|
+
for (const s of sets) {
|
|
1476
|
+
if (!isRecord(s)) continue;
|
|
1477
|
+
const savedWorkoutSetId = coerceInt(s.id);
|
|
1478
|
+
if (savedWorkoutSetId === null) continue;
|
|
1479
|
+
const exercises = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
|
|
1480
|
+
for (const ex of exercises) if (isRecord(ex)) rows.push({
|
|
1481
|
+
pw: rec,
|
|
1482
|
+
savedWorkoutSetId,
|
|
1483
|
+
ex
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return rows;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Find prescribed exercises on coach-scheduled (non-personal) workouts that day matching any of the
|
|
1491
|
+
* given exercise ids. Used to warn after an ad-hoc log that the same lift was already on the
|
|
1492
|
+
* athlete's scheduled workout — a caller can then point at {@link logAthleteSet}. Personal sessions
|
|
1493
|
+
* (`personal_cal === true`) are skipped: the ad-hoc log itself lands there, so they are not an
|
|
1494
|
+
* alternative. One row per matching saved-set exercise, dropping any with an unresolvable id.
|
|
1495
|
+
*/
|
|
1496
|
+
function findScheduledMatches(workouts, exerciseIds) {
|
|
1497
|
+
const out = [];
|
|
1498
|
+
for (const { pw, savedWorkoutSetId, ex } of eachPrescribedExercise(workouts)) {
|
|
1499
|
+
if (isPersonalSession(pw)) continue;
|
|
1500
|
+
const exerciseId = coerceInt(ex.exercise_id);
|
|
1501
|
+
const savedWorkoutSetExerciseId = coerceInt(ex.id);
|
|
1502
|
+
if (exerciseId === null || savedWorkoutSetExerciseId === null) continue;
|
|
1503
|
+
if (!exerciseIds.has(exerciseId)) continue;
|
|
1504
|
+
out.push({
|
|
1505
|
+
exerciseId,
|
|
1506
|
+
title: exerciseTitle(ex),
|
|
1507
|
+
program: str(pw.program_title),
|
|
1508
|
+
workoutTitle: str(pw.workout_title) ?? "",
|
|
1509
|
+
savedWorkoutSetId,
|
|
1510
|
+
savedWorkoutSetExerciseId
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
return out;
|
|
1514
|
+
}
|
|
1215
1515
|
/** Group resolved exercises by saved set and write each set via the given log target. */
|
|
1216
1516
|
async function logResolvedExercises(client, target, date, resolved) {
|
|
1217
1517
|
const bySet = /* @__PURE__ */ new Map();
|
|
@@ -1235,7 +1535,10 @@ async function logResolvedExercises(client, target, date, resolved) {
|
|
|
1235
1535
|
savedWorkoutSetId,
|
|
1236
1536
|
results
|
|
1237
1537
|
});
|
|
1238
|
-
out.push(
|
|
1538
|
+
out.push({
|
|
1539
|
+
savedWorkoutSetId: written.savedWorkoutSetId,
|
|
1540
|
+
exercisesLogged: written.exercisesLogged
|
|
1541
|
+
});
|
|
1239
1542
|
}
|
|
1240
1543
|
return out;
|
|
1241
1544
|
}
|
|
@@ -1248,7 +1551,8 @@ async function logResolvedExercises(client, target, date, resolved) {
|
|
|
1248
1551
|
*/
|
|
1249
1552
|
async function logAdHocSession(client, args) {
|
|
1250
1553
|
if (args.exercises.length === 0) throw new Error("Provide at least one exercise to log.");
|
|
1251
|
-
const
|
|
1554
|
+
const day = await fetchAthleteWorkouts(client, args.date, args.date);
|
|
1555
|
+
const existing = findPersonalSessionWorkoutId(day);
|
|
1252
1556
|
const created = existing === null;
|
|
1253
1557
|
const workoutId = existing ?? (await createPersonalWorkout(client, args.date)).workoutId;
|
|
1254
1558
|
const withOrder = args.exercises.map((e, i) => ({
|
|
@@ -1272,32 +1576,30 @@ async function logAdHocSession(client, args) {
|
|
|
1272
1576
|
};
|
|
1273
1577
|
});
|
|
1274
1578
|
const sets = await logResolvedExercises(client, { role: "athlete" }, args.date, resolved);
|
|
1275
|
-
|
|
1579
|
+
const result = {
|
|
1276
1580
|
date: args.date,
|
|
1277
1581
|
created,
|
|
1278
1582
|
sets
|
|
1279
1583
|
};
|
|
1584
|
+
const scheduledAlternatives = findScheduledMatches(day, new Set(args.exercises.map((e) => e.exerciseId)));
|
|
1585
|
+
if (scheduledAlternatives.length > 0) result.scheduledAlternatives = scheduledAlternatives;
|
|
1586
|
+
return result;
|
|
1280
1587
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
setNumber: s.setNumber,
|
|
1297
|
-
value: s.formattedValue ?? null
|
|
1298
|
-
}))
|
|
1299
|
-
}))
|
|
1300
|
-
};
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region ../js/src/util.ts
|
|
1590
|
+
/**
|
|
1591
|
+
* Drop the keys whose value is `undefined`, so the result can be passed to a function or
|
|
1592
|
+
* spread into an object under `exactOptionalPropertyTypes` without the per-key
|
|
1593
|
+
* `...(x !== undefined ? { x } : {})` dance. Required keys stay required in the result type;
|
|
1594
|
+
* optional ones lose `undefined` from their value type.
|
|
1595
|
+
*
|
|
1596
|
+
* Example:
|
|
1597
|
+
* definedProps({ metric, teamId: maybeId, date }) // omits teamId/date if they're undefined
|
|
1598
|
+
*/
|
|
1599
|
+
function definedProps(obj) {
|
|
1600
|
+
const result = {};
|
|
1601
|
+
for (const [key, value] of Object.entries(obj)) if (value !== void 0) result[key] = value;
|
|
1602
|
+
return result;
|
|
1301
1603
|
}
|
|
1302
1604
|
//#endregion
|
|
1303
1605
|
//#region ../core/src/history.ts
|
|
@@ -1375,6 +1677,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
|
|
|
1375
1677
|
}));
|
|
1376
1678
|
}
|
|
1377
1679
|
const ATHLETE_WORKOUTS_DESC = "Workouts in an inclusive YYYY-MM-DD window, flattened to blocks/exercises. Each exercise carries both its `prescribed` sets (what the program called for) and `performed` sets (what the athlete actually logged); each workout has a top-level `logged` flag. Use `performed`/`logged` to tell what was recorded or done. That is the reliable signal: a session can hold logged sets while the API's own completion flags stay 0, and an empty `performed` means nothing was logged for that exercise. For 'did I record anything / what did I do', set loggedOnly:true to return only sessions with logged sets (it keeps whole sessions that have any logged set, so individual exercises inside can still show empty `performed`; it also shrinks a large result); limit returns the most recent N workouts (newest first). For a high-level overview ('what's on my schedule this week', 'what have I been training lately') set summary:true to get one compact row per session (date, program, title, logged flag, and exerciseCount/performedCount — read performedCount against exerciseCount, e.g. 1 of 12 logged) instead of every set — a multi-program week of full detail is large, so prefer summary first, then re-query a single day without it to drill into that day's sets. For 'what's my next workout', query a forward window from today; if it comes back empty, the most recent session is likely yesterday's still-unlogged one, so widen the window backward a day or two. Both filters apply to the presented view, not raw. raw:true returns the untouched API objects. This is a date-windowed fetch, NOT an aggregate: for lifetime totals (all-time session count, total volume, first/last logged date) call athlete_profile instead of summing windows — a multi-year range here can time out. Narrow the window if the result is truncated.";
|
|
1680
|
+
const ATHLETE_LOG_TARGETS_DESC = "The savedWorkoutSetId + savedWorkoutSetExerciseId that athlete_log_set needs, read straight off your scheduled/logged workouts in an inclusive YYYY-MM-DD window — no raw needed. This is the self-service path for logging into a COACH-SCHEDULED workout: read the ids here, then pass them to athlete_log_set. The default view is COMPACT: one row per saved set, each carrying its program/programId, the savedWorkoutSetId, and every exercise's savedWorkoutSetExerciseId with prescribed/performed values. When several workouts fall on the same day (you're on more than one program), narrow to one with program (a case-insensitive title substring, e.g. 'bodybuilding' — no id lookup needed), or programId/teamId if you have the id. raw:true returns the untouched API objects, but that blob is large and can be truncated when several workouts share a date — prefer the program filter + the default view. For reading what you actually did (not the log ids), use athlete_workouts.";
|
|
1378
1681
|
const ATHLETE_EXERCISE_HISTORY_DESC = "Per-exercise PRs and the dated session time-series (sets performed, estimated 1RM). The returned `sessions` run all-time, newest first; pass since/until (YYYY-MM-DD, inclusive) to keep only sessions in that window — use it for 'last 3 months' / 'this year' questions so the result stays small. estimated1RM is a formula off the session's best set and reads high when a session mixes a heavy single with high-rep backdown work, so treat it as approximate, not a logged single. `liftPRs` are always all-time and ignore since/until, but each carries the date it was set, so filter those dates yourself to answer 'PRs set this year'. Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.";
|
|
1379
1682
|
const ATHLETE_EXERCISES_DESC = "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name; use the returned id with athlete_exercise_history, athlete_personal_records, or athlete_exercise_stats (all of which require an exercise id). A common name returns several variants (e.g. plain 'Bench Press' id 1162 vs 'BARBELL BENCH PRESS'); each variant keeps its own separate history and PR board, so prefer the plain canonical entry, and if a PR or trend looks incomplete, the work may be split across variants — check the others.";
|
|
1380
1683
|
function runAthleteWorkouts(ctx, args) {
|
|
@@ -1452,7 +1755,31 @@ function registerExerciseTools(server, ctx, userId) {
|
|
|
1452
1755
|
annotations: READ
|
|
1453
1756
|
}, ({ exerciseId, date }) => attempt(async () => jsonResult(await fetchExerciseStats(ctx.client, toId(exerciseId), await userId(), date))));
|
|
1454
1757
|
}
|
|
1455
|
-
/**
|
|
1758
|
+
/** The compact log-id read: savedWorkoutSetId + savedWorkoutSetExerciseId for athlete_log_set. */
|
|
1759
|
+
function registerLogTargetsTool(server, ctx) {
|
|
1760
|
+
server.registerTool("athlete_log_targets", {
|
|
1761
|
+
title: "Saved-workout log ids (for athlete_log_set)",
|
|
1762
|
+
description: ATHLETE_LOG_TARGETS_DESC,
|
|
1763
|
+
inputSchema: {
|
|
1764
|
+
startDate: dateString,
|
|
1765
|
+
endDate: dateString,
|
|
1766
|
+
program: z.string().optional(),
|
|
1767
|
+
programId: idParam.optional(),
|
|
1768
|
+
teamId: idParam.optional(),
|
|
1769
|
+
raw: z.boolean().optional()
|
|
1770
|
+
},
|
|
1771
|
+
annotations: READ
|
|
1772
|
+
}, ({ startDate, endDate, program, programId, teamId, raw }) => attempt(async () => {
|
|
1773
|
+
const workouts = selectWorkoutsByProgram(await fetchAthleteWorkouts(ctx.client, startDate, endDate), definedProps({
|
|
1774
|
+
programTitle: program,
|
|
1775
|
+
programId: programId === void 0 ? void 0 : toId(programId),
|
|
1776
|
+
teamId: teamId === void 0 ? void 0 : toId(teamId)
|
|
1777
|
+
}));
|
|
1778
|
+
if (raw === true) return jsonResult(workouts, { hint: "Each set's id is the savedWorkoutSetId; each savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId. Large — if truncated, pass program (a title substring) or use the default compact view." });
|
|
1779
|
+
return jsonResult(presentLogTargets(workouts), { hint: "One row per saved set: savedWorkoutSetId plus each savedWorkoutSetExerciseId, with the program it belongs to. Pass both to athlete_log_set. Filter with program (title substring), programId, or teamId if you're on several programs." });
|
|
1780
|
+
}));
|
|
1781
|
+
}
|
|
1782
|
+
/** Create a personal workout session, add exercises, and remove a stray personal session. */
|
|
1456
1783
|
function registerSessionTools(server, ctx) {
|
|
1457
1784
|
server.registerTool("athlete_session_create", {
|
|
1458
1785
|
title: "Create personal workout session",
|
|
@@ -1486,6 +1813,27 @@ function registerSessionTools(server, ctx) {
|
|
|
1486
1813
|
}));
|
|
1487
1814
|
return jsonResult(await addExercisesToWorkout(ctx.client, toId(workoutId), mapped), { hint: "Each top-level id is a savedWorkoutSetId; savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId for athlete_log_set." });
|
|
1488
1815
|
}));
|
|
1816
|
+
server.registerTool("athlete_session_remove", {
|
|
1817
|
+
title: "Remove a personal workout session",
|
|
1818
|
+
description: "Delete a personal (self-created) workout session from your calendar — use it to clean up a stray ad-hoc session, e.g. one athlete_log_session created when you meant to log into a coach-scheduled workout. Give the programWorkoutId (the `id` of the session in athlete_workouts, where personal:true marks a personal session) and its date. ONLY personal sessions can be removed: the tool re-reads that day and refuses a coach-scheduled workout. Requires confirmation (elicitation or confirm:true).",
|
|
1819
|
+
inputSchema: {
|
|
1820
|
+
...athleteSessionRemoveArgsSchema.shape,
|
|
1821
|
+
confirm: z.boolean().optional()
|
|
1822
|
+
},
|
|
1823
|
+
annotations: DESTRUCTIVE
|
|
1824
|
+
}, ({ programWorkoutId, date, confirm }, extra) => attempt(async () => {
|
|
1825
|
+
const id = toId(programWorkoutId);
|
|
1826
|
+
if (!await confirmGate(server, extra.requestId, `Permanently remove personal session ${id} on ${date}? This deletes the session and anything logged in it.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
1827
|
+
await removePersonalWorkout(ctx.client, {
|
|
1828
|
+
programWorkoutId: id,
|
|
1829
|
+
date
|
|
1830
|
+
});
|
|
1831
|
+
return jsonResult({
|
|
1832
|
+
removed: true,
|
|
1833
|
+
programWorkoutId: id,
|
|
1834
|
+
date
|
|
1835
|
+
});
|
|
1836
|
+
}));
|
|
1489
1837
|
}
|
|
1490
1838
|
/** Map validated logSession exercises to the SDK's SessionExercise[] (ids coerced, slots trimmed). */
|
|
1491
1839
|
function mapSessionExercises(exercises) {
|
|
@@ -1516,14 +1864,16 @@ function registerLogTool(server, ctx) {
|
|
|
1516
1864
|
annotations: DESTRUCTIVE
|
|
1517
1865
|
}, ({ date, exercises, confirm }, extra) => attempt(async () => {
|
|
1518
1866
|
if (!await confirmGate(server, extra.requestId, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
1519
|
-
|
|
1867
|
+
const res = await logAdHocSession(ctx.client, {
|
|
1520
1868
|
date,
|
|
1521
1869
|
exercises: mapSessionExercises(exercises)
|
|
1522
|
-
})
|
|
1870
|
+
});
|
|
1871
|
+
if (res.scheduledAlternatives !== void 0 && res.scheduledAlternatives.length > 0) return jsonResult(res, { hint: "Heads up: one or more of these lifts were already on a coach-scheduled workout today (see scheduledAlternatives). This logged a SEPARATE personal session. To log into the scheduled workout instead, use athlete_log_set with the savedWorkoutSetId / savedWorkoutSetExerciseId shown there — and athlete_session_remove to delete this personal session if it was a mistake." });
|
|
1872
|
+
return jsonResult(res);
|
|
1523
1873
|
}));
|
|
1524
1874
|
server.registerTool("athlete_log_set", {
|
|
1525
1875
|
title: "Log completed set results",
|
|
1526
|
-
description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day
|
|
1876
|
+
description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day. Writes to the athlete's (coach-visible) training log and shows in exercise history. Each set fills the next position in order; give a set a 1-based `slot` to place it at a specific position — e.g. log three top singles into positions 4-6 of an 8,5,3,1,1,1 ramp. A partial log records only the positions you send (plus any logged in an earlier call) and leaves the rest unlogged, so it does not mark untouched sets as performed. In a superset/circuit the block is marked done only once every exercise in it has logged results, so logging one exercise leaves its siblings untouched (the response's `setCompleted` reports whether the block was completed). Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_log_targets (filter by program when several workouts share a date). Requires confirmation (elicitation or confirm:true).",
|
|
1527
1877
|
inputSchema: {
|
|
1528
1878
|
...logSetArgsSchema.shape,
|
|
1529
1879
|
confirm: z.boolean().optional()
|
|
@@ -1531,19 +1881,10 @@ function registerLogTool(server, ctx) {
|
|
|
1531
1881
|
annotations: DESTRUCTIVE
|
|
1532
1882
|
}, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
|
|
1533
1883
|
if (!await confirmGate(server, extra.requestId, `Log results to saved workout set ${toId(savedWorkoutSetId)} on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
1534
|
-
const mapped = results.map((r) => ({
|
|
1535
|
-
savedWorkoutSetExerciseId: toId(r.savedWorkoutSetExerciseId),
|
|
1536
|
-
sets: r.sets.map((s) => {
|
|
1537
|
-
const slot = {};
|
|
1538
|
-
if (s.param1 !== void 0) slot.param1 = s.param1;
|
|
1539
|
-
if (s.param2 !== void 0) slot.param2 = s.param2;
|
|
1540
|
-
return slot;
|
|
1541
|
-
})
|
|
1542
|
-
}));
|
|
1543
1884
|
return jsonResult(await logAthleteSet(ctx.client, {
|
|
1544
1885
|
date,
|
|
1545
1886
|
savedWorkoutSetId: toId(savedWorkoutSetId),
|
|
1546
|
-
results:
|
|
1887
|
+
results: toSetResults(results)
|
|
1547
1888
|
}));
|
|
1548
1889
|
}));
|
|
1549
1890
|
}
|
|
@@ -1569,12 +1910,13 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
1569
1910
|
};
|
|
1570
1911
|
registerProfileTools(server, ctx, whoami, userId);
|
|
1571
1912
|
registerExerciseTools(server, ctx, userId);
|
|
1913
|
+
registerLogTargetsTool(server, ctx);
|
|
1572
1914
|
registerSessionTools(server, ctx);
|
|
1573
1915
|
registerLogTool(server, ctx);
|
|
1574
1916
|
}
|
|
1575
1917
|
//#endregion
|
|
1576
1918
|
//#region package.json
|
|
1577
|
-
var version = "1.
|
|
1919
|
+
var version = "1.6.0";
|
|
1578
1920
|
//#endregion
|
|
1579
1921
|
//#region src/server.ts
|
|
1580
1922
|
async function main() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainheroic-unofficial/athlete-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
23
23
|
"zod": "^4.4.3",
|
|
24
|
-
"@trainheroic-unofficial/core": "1.
|
|
25
|
-
"@trainheroic-unofficial/js": "1.
|
|
24
|
+
"@trainheroic-unofficial/core": "1.6.0",
|
|
25
|
+
"@trainheroic-unofficial/js": "1.6.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.0",
|