@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.
Files changed (2) hide show
  1. package/dist/server.mjs +421 -79
  2. 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 (param 1 / param 2 by entry slot).
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(loggedSetSchema).min(1)
185
+ sets: z.array(loggedSetWithSlotSchema).min(1)
173
186
  })).min(1)
174
187
  });
175
188
  logSetArgsSchema.extend({ athleteId: idArgSchema });
176
- logSetArgsSchema.extend({ athleteId: idArgSchema });
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 <= SLOTS; i += 1) {
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 <= SLOTS; i += 1) {
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
- const MAX_PARAM_SLOTS = 10;
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 > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${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 hasData = results.some((s) => s.param1 !== void 0 || s.param2 !== void 0);
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
- for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
950
- const slot = results[i - 1];
951
- const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
952
- const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
953
- body[`param_${i}_made`] = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
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
- const title = typeof ex.exercise_title === "string" && ex.exercise_title || typeof ex.title === "string" && ex.title || "exercise";
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
- const title = typeof e.exercise_title === "string" && e.exercise_title || typeof e.title === "string" && e.title || "exercise";
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
- const setBody = {
1123
- ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1124
- ...extra
1125
- };
1126
- const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
1127
- if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
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
- const rec = pw;
1181
- if (rec.personal_cal === true) {
1182
- const workoutId = coerceInt(rec.workout_id);
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(written);
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 existing = findPersonalSessionWorkoutId(await fetchAthleteWorkouts(client, args.date, args.date));
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
- return {
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
- /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
1282
- function presentExerciseHistory(detail) {
1283
- return {
1284
- liftPRs: (detail.liftPRs ?? []).map((p) => ({
1285
- description: p.description ?? null,
1286
- reps: p.reps ?? null,
1287
- weight: p.weight ?? null,
1288
- units: p.units ?? null,
1289
- date: p.dateCompleted ?? null
1290
- })),
1291
- sessions: (detail.history ?? []).map((h) => ({
1292
- date: h.dateCompleted,
1293
- abr: h.abr ?? null,
1294
- estimated1RM: h.bestEstimated1RM ?? null,
1295
- sets: (h.sets ?? []).map((s) => ({
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
- /** Create a personal workout session and add exercises to it. */
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
- return jsonResult(await logAdHocSession(ctx.client, {
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, marking the set completed. Writes to the athlete's (coach-visible) training log and shows in exercise history. Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
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: mapped
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.4.0";
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.4.0",
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.4.0",
25
- "@trainheroic-unofficial/js": "1.4.0"
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",