incremnt 0.1.18 → 0.2.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/README.md +63 -9
- package/package.json +3 -1
- package/src/browse.js +1103 -0
- package/src/contract.js +3 -1
- package/src/format.js +132 -0
- package/src/lib.js +13 -1
- package/src/logo.js +49 -33
- package/src/mcp.js +37 -4
- package/src/openrouter.js +241 -68
- package/src/prompt-security.js +70 -0
- package/src/queries.js +396 -142
- package/src/sync-service.js +2163 -56
package/src/queries.js
CHANGED
|
@@ -369,18 +369,36 @@ export function programDetail(snapshot, programId) {
|
|
|
369
369
|
days: (program.days ?? []).map((day, index) => ({
|
|
370
370
|
dayIndex: index,
|
|
371
371
|
title: day.title ?? null,
|
|
372
|
-
exercises: (day.exercises ?? []).map((exercise) =>
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
372
|
+
exercises: (day.exercises ?? []).map((exercise) => {
|
|
373
|
+
const rec = (snapshot.exerciseRecommendations ?? {})[exercise.name];
|
|
374
|
+
return {
|
|
375
|
+
name: exercise.name,
|
|
376
|
+
muscleGroup: exercise.muscleGroup ?? null,
|
|
377
|
+
sets: (exercise.sets ?? []).map((set) => ({
|
|
378
|
+
reps: set.reps ?? null,
|
|
379
|
+
weight: set.weight ?? null
|
|
380
|
+
})),
|
|
381
|
+
...(rec ? { recommendation: formatRecommendation(rec) } : {})
|
|
382
|
+
};
|
|
383
|
+
})
|
|
380
384
|
}))
|
|
381
385
|
};
|
|
382
386
|
}
|
|
383
387
|
|
|
388
|
+
function formatRecommendation(rec) {
|
|
389
|
+
if (!rec || !rec.kind) return null;
|
|
390
|
+
const amount = rec.amount ?? 0;
|
|
391
|
+
const unit = rec.unit === 'reps' ? 'reps' : 'kg';
|
|
392
|
+
switch (rec.kind) {
|
|
393
|
+
case 'increaseWeight': return `+${amount} ${unit}`;
|
|
394
|
+
case 'decreaseWeight': return `-${amount} ${unit}`;
|
|
395
|
+
case 'increaseReps': return rec.targetReps ? `target reps: ${rec.targetReps.join(', ')}` : `+${amount} reps`;
|
|
396
|
+
case 'deload': return amount > 0 ? `deload -${amount} ${unit}` : 'deload';
|
|
397
|
+
case 'retry': return 'retry same weight';
|
|
398
|
+
default: return rec.kind;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
384
402
|
export function goalsList(snapshot) {
|
|
385
403
|
return (snapshot.strengthPlans ?? []).map(plan => ({
|
|
386
404
|
planId: plan.id,
|
|
@@ -469,7 +487,7 @@ export function cycleSummaryShow(snapshot, summaryId) {
|
|
|
469
487
|
};
|
|
470
488
|
}
|
|
471
489
|
|
|
472
|
-
export function cycleSummaryContext(snapshot, programId) {
|
|
490
|
+
export function cycleSummaryContext(snapshot, programId, { exclude = new Set() } = {}) {
|
|
473
491
|
const programs = snapshot.programs ?? [];
|
|
474
492
|
const program = programId
|
|
475
493
|
? programs.find((p) => p.id === programId)
|
|
@@ -624,10 +642,16 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
624
642
|
const totalVolume = weekSessions.reduce((sum, s) => sum + (s.summary?.totalVolume ?? s.volume ?? 0), 0);
|
|
625
643
|
const matchingCycleSummary = programCycleSummaries[idx] ?? null;
|
|
626
644
|
|
|
645
|
+
const sessionVolumes = weekSessions.map((s) => ({
|
|
646
|
+
dayName: s.dayName ?? null,
|
|
647
|
+
volume: s.summary?.totalVolume ?? s.volume ?? 0
|
|
648
|
+
}));
|
|
649
|
+
|
|
627
650
|
return {
|
|
628
651
|
weekNumber: wk,
|
|
629
652
|
sessionCount: weekSessions.length,
|
|
630
653
|
totalVolume: Math.round(totalVolume),
|
|
654
|
+
sessionVolumes,
|
|
631
655
|
previousAISummary: matchingCycleSummary?.aiSummary ?? null
|
|
632
656
|
};
|
|
633
657
|
});
|
|
@@ -705,11 +729,21 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
705
729
|
(p) => p.status === 'active' && p.programId === program.id
|
|
706
730
|
);
|
|
707
731
|
if (activePlan) {
|
|
708
|
-
|
|
732
|
+
const programExerciseNames = new Set(
|
|
733
|
+
(program.days ?? [])
|
|
734
|
+
.flatMap((day) => day.exercises ?? [])
|
|
735
|
+
.map((exercise) => canonicalExerciseName(exercise.name ?? exercise.exerciseName))
|
|
736
|
+
);
|
|
737
|
+
goalProgress = (activePlan.liftGoals ?? [])
|
|
738
|
+
.filter((g) => {
|
|
739
|
+
if (programExerciseNames.size === 0) return true;
|
|
740
|
+
return programExerciseNames.has(canonicalExerciseName(g.exerciseDisplayName));
|
|
741
|
+
})
|
|
742
|
+
.map((g) => {
|
|
709
743
|
const range = g.targetE1RM - g.startingE1RM;
|
|
710
744
|
const gained = g.currentBestE1RM - g.startingE1RM;
|
|
711
745
|
const progressPct =
|
|
712
|
-
range > 0 ? Math.round((gained / range) * 100) : null;
|
|
746
|
+
range > 0 ? Math.max(0, Math.round((gained / range) * 100)) : null;
|
|
713
747
|
return {
|
|
714
748
|
exerciseName: g.exerciseDisplayName,
|
|
715
749
|
progressPercent: progressPct,
|
|
@@ -736,15 +770,15 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
736
770
|
const cycleStart = String(cycleSessions[0] ? completionDateForSession(cycleSessions[0]) : '');
|
|
737
771
|
const cycleEnd = String(cycleSessions[cycleSessions.length - 1]
|
|
738
772
|
? completionDateForSession(cycleSessions[cycleSessions.length - 1]) : '');
|
|
739
|
-
const cycleCardio = (snapshot.healthMetrics?.otherWorkouts ?? [])
|
|
773
|
+
const cycleCardio = exclude.has('otherWorkouts') ? [] : (snapshot.healthMetrics?.otherWorkouts ?? [])
|
|
740
774
|
.filter((w) => w.date >= cycleStart && w.date <= cycleEnd);
|
|
741
|
-
const cycleRestingHR = (snapshot.healthMetrics?.restingHR ?? [])
|
|
775
|
+
const cycleRestingHR = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.restingHR ?? [])
|
|
742
776
|
.filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
|
|
743
|
-
const cycleHRV = (snapshot.healthMetrics?.hrv ?? [])
|
|
777
|
+
const cycleHRV = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.hrv ?? [])
|
|
744
778
|
.filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
|
|
745
|
-
const cycleVO2Max = (snapshot.healthMetrics?.vo2Max ?? [])
|
|
779
|
+
const cycleVO2Max = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.vo2Max ?? [])
|
|
746
780
|
.filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
|
|
747
|
-
const cycleSleep = (snapshot.healthMetrics?.sleep ?? [])
|
|
781
|
+
const cycleSleep = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.sleep ?? [])
|
|
748
782
|
.filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
|
|
749
783
|
|
|
750
784
|
const avgRestingHR = cycleRestingHR.length > 0
|
|
@@ -759,7 +793,7 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
759
793
|
const avgSleepMins = cycleSleep.length > 0
|
|
760
794
|
? Math.round(cycleSleep.reduce((s, m) => s + m.durationMins, 0) / cycleSleep.length)
|
|
761
795
|
: null;
|
|
762
|
-
const cycleBodyWeight = (snapshot.healthMetrics?.bodyWeight ?? [])
|
|
796
|
+
const cycleBodyWeight = exclude.has('bodyWeight') ? [] : (snapshot.healthMetrics?.bodyWeight ?? [])
|
|
763
797
|
.filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
|
|
764
798
|
const latestBodyWeightKg = cycleBodyWeight.length > 0
|
|
765
799
|
? Math.round(cycleBodyWeight.at(-1).value * 10) / 10
|
|
@@ -859,11 +893,12 @@ export function cycleSummaryContext(snapshot, programId) {
|
|
|
859
893
|
latestVO2Max,
|
|
860
894
|
avgSleepMins,
|
|
861
895
|
latestBodyWeightKg,
|
|
862
|
-
prioritySignals: rankPrioritySignals(cycleSignals)
|
|
896
|
+
prioritySignals: rankPrioritySignals(cycleSignals),
|
|
897
|
+
excludeNote: buildExcludeNote(exclude)
|
|
863
898
|
};
|
|
864
899
|
}
|
|
865
900
|
|
|
866
|
-
export function checkpointContext(snapshot, programId, checkpointWeek) {
|
|
901
|
+
export function checkpointContext(snapshot, programId, checkpointWeek, { exclude = new Set() } = {}) {
|
|
867
902
|
const programs = snapshot.programs ?? [];
|
|
868
903
|
const program = programId
|
|
869
904
|
? programs.find((p) => p.id === programId)
|
|
@@ -948,11 +983,12 @@ export function checkpointContext(snapshot, programId, checkpointWeek) {
|
|
|
948
983
|
checkpointWeek,
|
|
949
984
|
totalWeeks,
|
|
950
985
|
exercises,
|
|
951
|
-
previousCycleNotes
|
|
986
|
+
previousCycleNotes,
|
|
987
|
+
excludeNote: buildExcludeNote(exclude)
|
|
952
988
|
};
|
|
953
989
|
}
|
|
954
990
|
|
|
955
|
-
export function workoutSummaryContext(snapshot, sessionId) {
|
|
991
|
+
export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set() } = {}) {
|
|
956
992
|
const sessions = snapshot.sessions ?? [];
|
|
957
993
|
const session = sessions.find((s) => s.id === sessionId);
|
|
958
994
|
if (!session) return null;
|
|
@@ -999,7 +1035,8 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
999
1035
|
muscleGroup: exercise.muscleGroup ?? null,
|
|
1000
1036
|
completedSets: completeSets.length,
|
|
1001
1037
|
isBodyweight: bw,
|
|
1002
|
-
topSet: topSet ? { weight: topSet.weight, reps: topSet.reps } : null
|
|
1038
|
+
topSet: topSet ? { weight: topSet.weight, reps: topSet.reps } : null,
|
|
1039
|
+
allSets: completeSets.map((s) => ({ weight: Number(s.weight), reps: Number(s.reps) }))
|
|
1003
1040
|
};
|
|
1004
1041
|
});
|
|
1005
1042
|
|
|
@@ -1025,6 +1062,7 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
1025
1062
|
const priorBestBWReps = new Map(); // key -> best reps for bodyweight exercises
|
|
1026
1063
|
const exerciseSessionCounts = new Map();
|
|
1027
1064
|
const priorBestReps = new Map(); // key -> Map(weight -> bestReps)
|
|
1065
|
+
const exerciseRecentWeights = new Map(); // key -> [{date, topWeight}], last 3 sessions
|
|
1028
1066
|
for (const s of sessions) {
|
|
1029
1067
|
if (s.id === sessionId) continue;
|
|
1030
1068
|
const sDate = String(completionDateForSession(s));
|
|
@@ -1053,13 +1091,32 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
1053
1091
|
const currentBest = repsMap.get(w);
|
|
1054
1092
|
if (!currentBest || r > currentBest) repsMap.set(w, r);
|
|
1055
1093
|
}
|
|
1094
|
+
// Track per-session top weight for recent weight history (non-bodyweight only)
|
|
1095
|
+
const exerciseCompleteSets = (exercise.sets ?? []).filter((s) => s.isComplete);
|
|
1096
|
+
const maxWeight = exerciseCompleteSets.reduce((max, s) => Math.max(max, Number(s.weight)), 0);
|
|
1097
|
+
if (maxWeight > 0) {
|
|
1098
|
+
if (!exerciseRecentWeights.has(key)) exerciseRecentWeights.set(key, []);
|
|
1099
|
+
const history = exerciseRecentWeights.get(key);
|
|
1100
|
+
if (!history.find((h) => h.date === sDate)) {
|
|
1101
|
+
history.push({ date: sDate, topWeight: maxWeight });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1056
1104
|
}
|
|
1057
1105
|
}
|
|
1058
1106
|
|
|
1059
|
-
//
|
|
1107
|
+
// Sort recent weight histories by date descending, keep last 3 sessions
|
|
1108
|
+
for (const [, history] of exerciseRecentWeights) {
|
|
1109
|
+
history.sort((a, b) => b.date.localeCompare(a.date));
|
|
1110
|
+
if (history.length > 3) history.length = 3;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Attach prior session count and recent weights to each exercise
|
|
1060
1114
|
for (const ex of exercises) {
|
|
1061
1115
|
const key = normalizeExerciseName(ex.exerciseName);
|
|
1062
1116
|
ex.priorSessions = exerciseSessionCounts.get(key) ?? 0;
|
|
1117
|
+
if (!ex.isBodyweight) {
|
|
1118
|
+
ex.recentWeights = exerciseRecentWeights.get(key) ?? [];
|
|
1119
|
+
}
|
|
1063
1120
|
}
|
|
1064
1121
|
|
|
1065
1122
|
// Detect PRs — skip first-time exercises (every set is trivially a "PR")
|
|
@@ -1130,46 +1187,101 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
1130
1187
|
}
|
|
1131
1188
|
}
|
|
1132
1189
|
|
|
1133
|
-
//
|
|
1134
|
-
let
|
|
1190
|
+
// Resolve planned exercise list — prefer the logged point-in-time prescription snapshot.
|
|
1191
|
+
let plannedExerciseList = [];
|
|
1135
1192
|
if (session.prescriptionSnapshot?.exercises?.length > 0) {
|
|
1136
|
-
|
|
1193
|
+
plannedExerciseList = session.prescriptionSnapshot.exercises;
|
|
1137
1194
|
} else if (session.programId) {
|
|
1138
1195
|
const program = (snapshot.programs ?? []).find(p => p.id === session.programId);
|
|
1139
1196
|
const matchingDay = Number.isInteger(session.programDayIndex)
|
|
1140
1197
|
? program?.days?.[session.programDayIndex]
|
|
1141
1198
|
: program?.days?.find(d => d.title === dayName);
|
|
1142
1199
|
if (matchingDay) {
|
|
1143
|
-
|
|
1200
|
+
plannedExerciseList = matchingDay.exercises ?? [];
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Plan comparison
|
|
1205
|
+
const planComparison = plannedExerciseList.length > 0
|
|
1206
|
+
? buildPlanComparison(session, exercises, plannedExerciseList)
|
|
1207
|
+
: undefined;
|
|
1208
|
+
|
|
1209
|
+
// Attach planned weight/reps to each exercise for the AI coach context
|
|
1210
|
+
if (plannedExerciseList.length > 0) {
|
|
1211
|
+
const plannedByName = new Map(
|
|
1212
|
+
plannedExerciseList.map((ex) => [normalizeExerciseName(ex.exerciseName ?? ex.name), ex])
|
|
1213
|
+
);
|
|
1214
|
+
for (const ex of exercises) {
|
|
1215
|
+
const planned = plannedByName.get(normalizeExerciseName(ex.exerciseName));
|
|
1216
|
+
if (!planned) continue;
|
|
1217
|
+
const sets = planned.targetSets ?? planned.sets ?? [];
|
|
1218
|
+
const firstWeightedSet = sets.find((s) => s.weight != null && Number(s.weight) > 0);
|
|
1219
|
+
if (firstWeightedSet) {
|
|
1220
|
+
ex.plannedWeight = firstWeightedSet.weight;
|
|
1221
|
+
ex.plannedReps = firstWeightedSet.reps ?? null;
|
|
1222
|
+
ex.plannedSetCount = sets.length;
|
|
1223
|
+
}
|
|
1144
1224
|
}
|
|
1145
1225
|
}
|
|
1146
1226
|
|
|
1147
1227
|
const isAdhoc = !session.programId;
|
|
1148
1228
|
|
|
1149
|
-
// Include
|
|
1229
|
+
// Include cardio from the 3 days before this session — only the immediately preceding
|
|
1230
|
+
// window has meaningful acute recovery relevance.
|
|
1150
1231
|
const sessionDateStr = String(sessionDate);
|
|
1151
|
-
const
|
|
1232
|
+
const threeDaysBefore = new Date(new Date(sessionDateStr).getTime() - 3 * 24 * 60 * 60 * 1000)
|
|
1152
1233
|
.toISOString().slice(0, 10);
|
|
1153
|
-
const nearbyCardio = (snapshot.healthMetrics?.otherWorkouts ?? [])
|
|
1154
|
-
.filter((w) => w.date >=
|
|
1234
|
+
const nearbyCardio = exclude.has('otherWorkouts') ? [] : (snapshot.healthMetrics?.otherWorkouts ?? [])
|
|
1235
|
+
.filter((w) => w.date >= threeDaysBefore && w.date <= sessionDateStr);
|
|
1155
1236
|
|
|
1156
|
-
const restingHROnDay = (snapshot.healthMetrics?.restingHR ?? [])
|
|
1237
|
+
const restingHROnDay = exclude.has('recovery') ? null : (snapshot.healthMetrics?.restingHR ?? [])
|
|
1157
1238
|
.find((m) => m.date === sessionDateStr);
|
|
1158
|
-
const hrvOnDay = (snapshot.healthMetrics?.hrv ?? [])
|
|
1239
|
+
const hrvOnDay = exclude.has('recovery') ? null : (snapshot.healthMetrics?.hrv ?? [])
|
|
1159
1240
|
.find((m) => m.date === sessionDateStr);
|
|
1160
|
-
const sleepNight = (snapshot.healthMetrics?.sleep ?? [])
|
|
1241
|
+
const sleepNight = exclude.has('recovery') ? null : (snapshot.healthMetrics?.sleep ?? [])
|
|
1161
1242
|
.find((m) => m.date === sessionDateStr);
|
|
1162
|
-
|
|
1163
|
-
|
|
1243
|
+
|
|
1244
|
+
// 14-day baselines for contextualising session-day vitals
|
|
1245
|
+
const fourteenDaysBefore = new Date(new Date(sessionDateStr).getTime() - 14 * 24 * 60 * 60 * 1000)
|
|
1246
|
+
.toISOString().slice(0, 10);
|
|
1247
|
+
const recentHRVReadings = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.hrv ?? [])
|
|
1248
|
+
.filter((m) => m.date >= fourteenDaysBefore && m.date < sessionDateStr);
|
|
1249
|
+
const hrvBaseline = recentHRVReadings.length >= 3
|
|
1250
|
+
? Math.round(recentHRVReadings.reduce((s, m) => s + m.value, 0) / recentHRVReadings.length)
|
|
1251
|
+
: null;
|
|
1252
|
+
const recentRestingHRReadings = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.restingHR ?? [])
|
|
1253
|
+
.filter((m) => m.date >= fourteenDaysBefore && m.date < sessionDateStr);
|
|
1254
|
+
const restingHRBaseline = recentRestingHRReadings.length >= 3
|
|
1255
|
+
? Math.round(recentRestingHRReadings.reduce((s, m) => s + m.value, 0) / recentRestingHRReadings.length)
|
|
1256
|
+
: null;
|
|
1257
|
+
|
|
1258
|
+
const vo2MaxRecent = exclude.has('recovery') ? [] : (snapshot.healthMetrics?.vo2Max ?? [])
|
|
1259
|
+
.filter((m) => m.date >= threeDaysBefore && m.date <= sessionDateStr);
|
|
1164
1260
|
const vo2MaxLatest = vo2MaxRecent.length > 0
|
|
1165
1261
|
? Math.round(vo2MaxRecent.at(-1).value * 10) / 10
|
|
1166
1262
|
: null;
|
|
1167
|
-
const
|
|
1168
|
-
.
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1263
|
+
const sevenDaysBefore = new Date(new Date(sessionDateStr).getTime() - 7 * 24 * 60 * 60 * 1000)
|
|
1264
|
+
.toISOString().slice(0, 10);
|
|
1265
|
+
const recentBodyWeight = exclude.has('bodyWeight') ? null : (snapshot.healthMetrics?.bodyWeight ?? [])
|
|
1266
|
+
.filter((m) => m.date >= sevenDaysBefore && m.date <= sessionDateStr)
|
|
1267
|
+
.sort((a, b) => b.date.localeCompare(a.date))[0] ?? null;
|
|
1268
|
+
const bodyWeightKg = recentBodyWeight
|
|
1269
|
+
? Math.round(recentBodyWeight.value * 10) / 10
|
|
1171
1270
|
: null;
|
|
1172
1271
|
|
|
1272
|
+
// Readiness context (gated on recovery exclusion)
|
|
1273
|
+
const readinessContext = exclude.has('recovery') ? null : (() => {
|
|
1274
|
+
const snap = session.readinessBandSnapshot;
|
|
1275
|
+
if (!snap) return null;
|
|
1276
|
+
return {
|
|
1277
|
+
band: snap.band,
|
|
1278
|
+
dominantSignal: snap.dominantSignal,
|
|
1279
|
+
adaptationApplied: snap.adaptationApplied,
|
|
1280
|
+
userOverrode: snap.userOverrode ?? false,
|
|
1281
|
+
tsbValue: snap.tsbValue ?? null
|
|
1282
|
+
};
|
|
1283
|
+
})();
|
|
1284
|
+
|
|
1173
1285
|
const isFirstWorkout = earlierSessions.length === 0;
|
|
1174
1286
|
|
|
1175
1287
|
const result = {
|
|
@@ -1187,10 +1299,13 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
1187
1299
|
repPrs: repPrs.length > 0 ? repPrs : [],
|
|
1188
1300
|
nearbyCardio: nearbyCardio.length > 0 ? nearbyCardio : null,
|
|
1189
1301
|
restingHROnDay: restingHROnDay?.value ?? null,
|
|
1302
|
+
restingHRBaseline,
|
|
1190
1303
|
hrvOnDay: hrvOnDay?.value ?? null,
|
|
1304
|
+
hrvBaseline,
|
|
1191
1305
|
vo2MaxLatest,
|
|
1192
1306
|
sleepNight: sleepNight ?? null,
|
|
1193
|
-
bodyWeightKg
|
|
1307
|
+
bodyWeightKg,
|
|
1308
|
+
readiness: readinessContext
|
|
1194
1309
|
};
|
|
1195
1310
|
if (programChange) result.programChange = programChange;
|
|
1196
1311
|
if (planComparison) result.planComparison = planComparison;
|
|
@@ -1290,16 +1405,37 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
1290
1405
|
});
|
|
1291
1406
|
}
|
|
1292
1407
|
result.prioritySignals = rankPrioritySignals(workoutSignals);
|
|
1408
|
+
result.excludeNote = buildExcludeNote(exclude);
|
|
1293
1409
|
|
|
1294
1410
|
return result;
|
|
1295
1411
|
}
|
|
1296
1412
|
|
|
1297
|
-
export function askContext(snapshot) {
|
|
1413
|
+
export function askContext(snapshot, { exclude = new Set() } = {}) {
|
|
1298
1414
|
const sessions = snapshot.sessions ?? [];
|
|
1299
1415
|
const lines = [];
|
|
1300
1416
|
|
|
1417
|
+
lines.push(`Today's date: ${new Date().toISOString().slice(0, 10)}.`);
|
|
1301
1418
|
lines.push(`Training overview: ${sessions.length} total workouts logged.`);
|
|
1302
1419
|
|
|
1420
|
+
// Recovery context — hours since last session
|
|
1421
|
+
const sortedRecent = sessions
|
|
1422
|
+
.slice()
|
|
1423
|
+
.sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))));
|
|
1424
|
+
const lastSessionDate = sortedRecent[0] ? completionDateForSession(sortedRecent[0]) : null;
|
|
1425
|
+
if (lastSessionDate) {
|
|
1426
|
+
const lastSessionTime = new Date(String(lastSessionDate)).getTime();
|
|
1427
|
+
const hoursSinceLast = Number.isNaN(lastSessionTime) ? -1 : Math.round((Date.now() - lastSessionTime) / (1000 * 60 * 60));
|
|
1428
|
+
if (hoursSinceLast >= 0 && hoursSinceLast < 72) {
|
|
1429
|
+
const recoveryLabel =
|
|
1430
|
+
hoursSinceLast < 12
|
|
1431
|
+
? 'less than 12 hours ago'
|
|
1432
|
+
: hoursSinceLast < 24
|
|
1433
|
+
? 'less than 24 hours ago'
|
|
1434
|
+
: `${Math.floor(hoursSinceLast / 24)} day${Math.floor(hoursSinceLast / 24) === 1 ? '' : 's'} ago`;
|
|
1435
|
+
lines.push(`Last session: ${recoveryLabel} (${lastSessionDate}).`);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1303
1439
|
// Training frequency (last 4 weeks)
|
|
1304
1440
|
const fourWeeksAgo = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
|
|
1305
1441
|
const recentCount = sessions.filter((s) => String(completionDateForSession(s)) >= fourWeeksAgo).length;
|
|
@@ -1308,10 +1444,68 @@ export function askContext(snapshot) {
|
|
|
1308
1444
|
lines.push(`Recent frequency: ${perWeek} sessions/week (last 4 weeks).`);
|
|
1309
1445
|
}
|
|
1310
1446
|
|
|
1311
|
-
// Current program
|
|
1447
|
+
// Current program + week phase (derived from most recent session's stamped context,
|
|
1448
|
+
// not from completedCyclesCount which advances immediately on cycle completion)
|
|
1312
1449
|
const program = activeProgram(snapshot);
|
|
1313
1450
|
if (program) {
|
|
1314
|
-
|
|
1451
|
+
const programSessions = sessions
|
|
1452
|
+
.filter((s) => s.programId === program.id && s.historicalContext?.programWeekNumber)
|
|
1453
|
+
.sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))));
|
|
1454
|
+
const latestSession = programSessions[0];
|
|
1455
|
+
const currentWeek = latestSession?.historicalContext?.programWeekNumber ?? (Number(program.completedCyclesCount ?? 0) + 1);
|
|
1456
|
+
const weekPhase = latestSession?.historicalContext?.programProgressionType ?? null;
|
|
1457
|
+
const phaseLabel = weekPhase ? ` (${weekPhase} week)` : '';
|
|
1458
|
+
lines.push(`Current program: ${program.name}, week ${currentWeek}${phaseLabel}, ${program.daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
|
|
1459
|
+
if (weekPhase === 'deload') {
|
|
1460
|
+
lines.push('Note: This is a planned deload week — reduced volume and intensity are intentional, not a regression.');
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Days until next session
|
|
1464
|
+
const currentDayIndex = program.currentDayIndex ?? 0;
|
|
1465
|
+
const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
|
|
1466
|
+
if (nextSessionWeekday != null) {
|
|
1467
|
+
const jsDay = new Date().getDay(); // 0=Sun
|
|
1468
|
+
const todayIso = jsDay === 0 ? 7 : jsDay; // 1=Mon … 7=Sun
|
|
1469
|
+
let daysUntil = nextSessionWeekday - todayIso;
|
|
1470
|
+
if (daysUntil < 0) daysUntil += 7;
|
|
1471
|
+
const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
1472
|
+
const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
|
|
1473
|
+
const nextDayTitle = (program.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
|
|
1474
|
+
lines.push(`Next session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Program days with planned sets
|
|
1478
|
+
const days = program.days ?? [];
|
|
1479
|
+
if (days.length > 0) {
|
|
1480
|
+
lines.push('');
|
|
1481
|
+
lines.push('Program schedule (planned sets):');
|
|
1482
|
+
for (let i = 0; i < days.length; i++) {
|
|
1483
|
+
const day = days[i];
|
|
1484
|
+
const upNext = i === currentDayIndex ? ' [UP NEXT]' : '';
|
|
1485
|
+
lines.push(` ${day.title ?? `Day ${i + 1}`}${upNext}:`);
|
|
1486
|
+
for (const exercise of day.exercises ?? []) {
|
|
1487
|
+
const sets = exercise.sets ?? [];
|
|
1488
|
+
if (sets.length === 0) continue;
|
|
1489
|
+
// Group identical sets for compact display: e.g. "4×10 @ 100kg"
|
|
1490
|
+
const groups = [];
|
|
1491
|
+
let run = 1;
|
|
1492
|
+
for (let j = 1; j <= sets.length; j++) {
|
|
1493
|
+
const prev = sets[j - 1];
|
|
1494
|
+
const curr = sets[j];
|
|
1495
|
+
if (curr && curr.weight === prev.weight && curr.reps === prev.reps) {
|
|
1496
|
+
run++;
|
|
1497
|
+
} else {
|
|
1498
|
+
groups.push(`${run}×${prev.reps}${prev.weight > 0 ? ` @ ${prev.weight}kg` : ''}`);
|
|
1499
|
+
run = 1;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const rec = (snapshot.exerciseRecommendations ?? {})[exercise.name];
|
|
1503
|
+
const recLabel = rec ? formatRecommendation(rec) : null;
|
|
1504
|
+
const recSuffix = recLabel ? ` → next: ${recLabel}` : '';
|
|
1505
|
+
lines.push(` ${exercise.name}: ${groups.join(', ')}${recSuffix}`);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1315
1509
|
}
|
|
1316
1510
|
|
|
1317
1511
|
// Best e1RM records (top 15)
|
|
@@ -1453,90 +1647,99 @@ export function askContext(snapshot) {
|
|
|
1453
1647
|
}
|
|
1454
1648
|
}
|
|
1455
1649
|
|
|
1456
|
-
appendHealthMetricsContext(lines, snapshot.healthMetrics, { recentDays: 14 });
|
|
1650
|
+
appendHealthMetricsContext(lines, snapshot.healthMetrics, { recentDays: 14, exclude });
|
|
1651
|
+
appendExcludeNote(lines, exclude);
|
|
1457
1652
|
|
|
1458
1653
|
return lines.join('\n');
|
|
1459
1654
|
}
|
|
1460
1655
|
|
|
1461
|
-
function appendHealthMetricsContext(lines, metrics, { recentDays = 14 } = {}) {
|
|
1656
|
+
function appendHealthMetricsContext(lines, metrics, { recentDays = 14, exclude = new Set() } = {}) {
|
|
1462
1657
|
if (!metrics) return;
|
|
1463
1658
|
|
|
1464
1659
|
const cutoff = new Date(Date.now() - recentDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
1465
1660
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1661
|
+
if (!exclude.has('otherWorkouts')) {
|
|
1662
|
+
const recentWorkouts = (metrics.otherWorkouts ?? []).filter((w) => w.date >= cutoff);
|
|
1663
|
+
if (recentWorkouts.length > 0) {
|
|
1664
|
+
lines.push('');
|
|
1665
|
+
lines.push(`Other workouts (last ${recentDays} days):`);
|
|
1666
|
+
for (const w of recentWorkouts) {
|
|
1667
|
+
const parts = [`${w.durationSecs ? Math.round(w.durationSecs / 60) : '?'} min`];
|
|
1668
|
+
if (w.distanceKm) parts.push(`${w.distanceKm.toFixed(1)} km`);
|
|
1669
|
+
if (w.avgHR) parts.push(`avg HR ${w.avgHR} bpm`);
|
|
1670
|
+
if (w.calories) parts.push(`${w.calories} kcal`);
|
|
1671
|
+
if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
|
|
1672
|
+
lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
|
|
1673
|
+
}
|
|
1477
1674
|
}
|
|
1478
|
-
}
|
|
1479
1675
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1676
|
+
// Weekly cardio volume summary (always last 7 days regardless of recentDays)
|
|
1677
|
+
const sevenDayCutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
1678
|
+
const weekCardio = (metrics.otherWorkouts ?? []).filter((w) => w.date >= sevenDayCutoff);
|
|
1679
|
+
if (weekCardio.length > 0) {
|
|
1680
|
+
const totalSecs = weekCardio.reduce((sum, w) => sum + (w.durationSecs ?? 0), 0);
|
|
1681
|
+
const totalMins = Math.round(totalSecs / 60);
|
|
1682
|
+
const totalKm = weekCardio.reduce((sum, w) => sum + (w.distanceKm ?? 0), 0);
|
|
1683
|
+
const distPart = totalKm > 0 ? `, ${totalKm.toFixed(1)} km total` : '';
|
|
1684
|
+
lines.push(`Cardio last 7 days: ${weekCardio.length} sessions, ${totalMins} min${distPart}`);
|
|
1685
|
+
}
|
|
1489
1686
|
}
|
|
1490
1687
|
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1688
|
+
if (!exclude.has('recovery')) {
|
|
1689
|
+
const recentRestingHR = (metrics.restingHR ?? []).filter((m) => m.date >= cutoff);
|
|
1690
|
+
if (recentRestingHR.length > 0) {
|
|
1691
|
+
const avg = Math.round(recentRestingHR.reduce((s, m) => s + m.value, 0) / recentRestingHR.length);
|
|
1692
|
+
const latest = recentRestingHR[recentRestingHR.length - 1];
|
|
1693
|
+
lines.push('');
|
|
1694
|
+
lines.push(`Resting HR (last ${recentDays} days): avg ${avg} bpm, latest ${Math.round(latest.value)} bpm (${latest.date})`);
|
|
1695
|
+
}
|
|
1498
1696
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1697
|
+
const recentHRV = (metrics.hrv ?? []).filter((m) => m.date >= cutoff);
|
|
1698
|
+
if (recentHRV.length > 0) {
|
|
1699
|
+
const avg = Math.round(recentHRV.reduce((s, m) => s + m.value, 0) / recentHRV.length);
|
|
1700
|
+
const latest = recentHRV[recentHRV.length - 1];
|
|
1701
|
+
lines.push(`HRV (last ${recentDays} days): avg ${avg} ms, latest ${Math.round(latest.value)} ms (${latest.date})`);
|
|
1702
|
+
}
|
|
1505
1703
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1704
|
+
const recentVO2Max = (metrics.vo2Max ?? []).filter((m) => m.date >= cutoff);
|
|
1705
|
+
if (recentVO2Max.length > 0) {
|
|
1706
|
+
const latest = recentVO2Max[recentVO2Max.length - 1];
|
|
1707
|
+
lines.push(`VO2 Max: ${Math.round(latest.value * 10) / 10} ml/kg/min (${latest.date})`);
|
|
1708
|
+
}
|
|
1511
1709
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1710
|
+
const recentSleep = (metrics.sleep ?? []).filter((m) => m.date >= cutoff);
|
|
1711
|
+
if (recentSleep.length > 0) {
|
|
1712
|
+
const avgMins = Math.round(recentSleep.reduce((s, m) => s + m.durationMins, 0) / recentSleep.length);
|
|
1713
|
+
const avgHours = (avgMins / 60).toFixed(1);
|
|
1714
|
+
const latest = recentSleep[recentSleep.length - 1];
|
|
1715
|
+
const latestHours = (latest.durationMins / 60).toFixed(1);
|
|
1716
|
+
lines.push(`Sleep (last ${recentDays} days): avg ${avgHours}h/night, last night ${latestHours}h (${latest.date})`);
|
|
1717
|
+
}
|
|
1518
1718
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
lines.push(`Body weight (last ${recentDays} days): latest ${latest.value.toFixed(1)} kg (${latest.date}), ${recentBodyWeight.length} readings, trend ${trend} kg`);
|
|
1526
|
-
}
|
|
1719
|
+
const recentRespiratoryRate = (metrics.respiratoryRate ?? []).filter((m) => m.date >= cutoff);
|
|
1720
|
+
if (recentRespiratoryRate.length > 0) {
|
|
1721
|
+
const avg = recentRespiratoryRate.reduce((s, m) => s + m.value, 0) / recentRespiratoryRate.length;
|
|
1722
|
+
const latest = recentRespiratoryRate[recentRespiratoryRate.length - 1];
|
|
1723
|
+
lines.push(`Respiratory rate (last ${recentDays} days): avg ${avg.toFixed(1)} rpm, latest ${latest.value.toFixed(1)} rpm (${latest.date})`);
|
|
1724
|
+
}
|
|
1527
1725
|
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1726
|
+
const recentBodyTemp = (metrics.bodyTemperature ?? []).filter((m) => m.date >= cutoff);
|
|
1727
|
+
if (recentBodyTemp.length > 0) {
|
|
1728
|
+
const avg = recentBodyTemp.reduce((s, m) => s + m.value, 0) / recentBodyTemp.length;
|
|
1729
|
+
const latest = recentBodyTemp[recentBodyTemp.length - 1];
|
|
1730
|
+
lines.push(`Body temperature (last ${recentDays} days): avg ${avg.toFixed(1)}°C, latest ${latest.value.toFixed(1)}°C (${latest.date})`);
|
|
1731
|
+
}
|
|
1533
1732
|
}
|
|
1534
1733
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1734
|
+
if (!exclude.has('bodyWeight')) {
|
|
1735
|
+
const recentBodyWeight = (metrics.bodyWeight ?? []).filter((m) => m.date >= cutoff);
|
|
1736
|
+
if (recentBodyWeight.length > 0) {
|
|
1737
|
+
const latest = recentBodyWeight[recentBodyWeight.length - 1];
|
|
1738
|
+
const earliest = recentBodyWeight[0];
|
|
1739
|
+
const delta = (latest.value - earliest.value).toFixed(1);
|
|
1740
|
+
const trend = delta > 0 ? `+${delta}` : delta;
|
|
1741
|
+
lines.push(`Body weight (last ${recentDays} days): latest ${latest.value.toFixed(1)} kg (${latest.date}), ${recentBodyWeight.length} readings, trend ${trend} kg`);
|
|
1742
|
+
}
|
|
1540
1743
|
}
|
|
1541
1744
|
}
|
|
1542
1745
|
|
|
@@ -1625,7 +1828,7 @@ function computeReadiness(dailyLoads, earliestDate, today, historyDays) {
|
|
|
1625
1828
|
};
|
|
1626
1829
|
}
|
|
1627
1830
|
|
|
1628
|
-
function trainingLoad(snapshot) {
|
|
1831
|
+
export function trainingLoad(snapshot) {
|
|
1629
1832
|
const metrics = snapshot.healthMetrics;
|
|
1630
1833
|
const sessions = snapshot.sessions ?? [];
|
|
1631
1834
|
|
|
@@ -1774,7 +1977,7 @@ function trainingLoad(snapshot) {
|
|
|
1774
1977
|
};
|
|
1775
1978
|
}
|
|
1776
1979
|
|
|
1777
|
-
function healthSummary(snapshot, days = 14) {
|
|
1980
|
+
export function healthSummary(snapshot, days = 14) {
|
|
1778
1981
|
const metrics = snapshot.healthMetrics;
|
|
1779
1982
|
const trainingLoadSummary = (() => {
|
|
1780
1983
|
const tl = trainingLoad(snapshot);
|
|
@@ -1826,6 +2029,7 @@ function healthSummary(snapshot, days = 14) {
|
|
|
1826
2029
|
},
|
|
1827
2030
|
sleep: {
|
|
1828
2031
|
avgHours: recentSleep.length > 0 ? Math.round(recentSleep.reduce((s, m) => s + m.durationMins, 0) / recentSleep.length / 60 * 10) / 10 : null,
|
|
2032
|
+
latestNight: recentSleep.length > 0 ? { hours: Math.round(recentSleep.at(-1).durationMins / 60 * 10) / 10, date: recentSleep.at(-1).date } : null,
|
|
1829
2033
|
nights: recentSleep.length
|
|
1830
2034
|
},
|
|
1831
2035
|
bodyWeight: (() => {
|
|
@@ -1859,7 +2063,7 @@ function healthSummary(snapshot, days = 14) {
|
|
|
1859
2063
|
};
|
|
1860
2064
|
}
|
|
1861
2065
|
|
|
1862
|
-
export function vitalsSummaryContext(snapshot) {
|
|
2066
|
+
export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
|
|
1863
2067
|
const lines = [];
|
|
1864
2068
|
lines.push(`Date: ${new Date().toISOString().slice(0, 10)}`);
|
|
1865
2069
|
|
|
@@ -1899,15 +2103,27 @@ export function vitalsSummaryContext(snapshot) {
|
|
|
1899
2103
|
lines.push('No training sessions in the last 7 days.');
|
|
1900
2104
|
}
|
|
1901
2105
|
|
|
1902
|
-
// Current program phase
|
|
2106
|
+
// Current program phase and schedule
|
|
1903
2107
|
const prog = snapshot.currentProgram ?? activeProgram(snapshot);
|
|
1904
2108
|
if (prog) {
|
|
1905
2109
|
lines.push(`Current program: ${prog.name}.`);
|
|
2110
|
+
const currentDayIndex = prog.currentDayIndex ?? 0;
|
|
2111
|
+
const nextSessionWeekday = (prog.trainingWeekdays ?? [])[currentDayIndex];
|
|
2112
|
+
if (nextSessionWeekday != null) {
|
|
2113
|
+
const jsDay = new Date().getDay();
|
|
2114
|
+
const todayIso = jsDay === 0 ? 7 : jsDay;
|
|
2115
|
+
let daysUntil = nextSessionWeekday - todayIso;
|
|
2116
|
+
if (daysUntil < 0) daysUntil += 7;
|
|
2117
|
+
const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
2118
|
+
const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
|
|
2119
|
+
const nextDayTitle = (prog.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
|
|
2120
|
+
lines.push(`Next scheduled session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
|
|
2121
|
+
}
|
|
1906
2122
|
}
|
|
1907
2123
|
|
|
1908
2124
|
const vitals = healthSummary(snapshot, 14);
|
|
1909
2125
|
const vitalsSignals = [];
|
|
1910
|
-
if (vitals.trainingLoad?.available) {
|
|
2126
|
+
if (!exclude.has('trainingLoad') && vitals.trainingLoad?.available) {
|
|
1911
2127
|
const load = vitals.trainingLoad;
|
|
1912
2128
|
vitalsSignals.push({
|
|
1913
2129
|
id: 'training-load',
|
|
@@ -1920,34 +2136,51 @@ export function vitalsSummaryContext(snapshot) {
|
|
|
1920
2136
|
actionability: 9
|
|
1921
2137
|
});
|
|
1922
2138
|
}
|
|
1923
|
-
if (
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2139
|
+
if (!exclude.has('recovery')) {
|
|
2140
|
+
if (vitals.restingHR?.avg != null && vitals.restingHR.latest?.value != null) {
|
|
2141
|
+
const drift = vitals.restingHR.latest.value - vitals.restingHR.avg;
|
|
2142
|
+
if (Math.abs(drift) >= 3) {
|
|
2143
|
+
vitalsSignals.push({
|
|
2144
|
+
id: 'resting-hr-drift',
|
|
2145
|
+
category: 'recovery',
|
|
2146
|
+
summary: `Resting HR drift ${drift > 0 ? '+' : ''}${drift} bpm vs 14d average`,
|
|
2147
|
+
detail: `Latest ${vitals.restingHR.latest.value} bpm on ${vitals.restingHR.latest.date}, avg ${vitals.restingHR.avg}`,
|
|
2148
|
+
impact: drift > 0 ? 8 : 6,
|
|
2149
|
+
confidence: 8,
|
|
2150
|
+
novelty: 7,
|
|
2151
|
+
actionability: 8
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
1936
2154
|
}
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
}
|
|
2155
|
+
if (vitals.hrv?.avg != null && vitals.hrv.latest?.value != null) {
|
|
2156
|
+
const drift = vitals.hrv.latest.value - vitals.hrv.avg;
|
|
2157
|
+
if (Math.abs(drift) >= 5) {
|
|
2158
|
+
vitalsSignals.push({
|
|
2159
|
+
id: 'hrv-drift',
|
|
2160
|
+
category: 'recovery',
|
|
2161
|
+
summary: `HRV drift ${drift > 0 ? '+' : ''}${drift} ms vs 14d average`,
|
|
2162
|
+
detail: `Latest ${vitals.hrv.latest.value} ms on ${vitals.hrv.latest.date}, avg ${vitals.hrv.avg}`,
|
|
2163
|
+
impact: drift < 0 ? 8 : 6,
|
|
2164
|
+
confidence: 8,
|
|
2165
|
+
novelty: 7,
|
|
2166
|
+
actionability: 8
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (vitals.sleep?.latestNight != null && vitals.sleep.avgHours != null) {
|
|
2171
|
+
const drift = vitals.sleep.latestNight.hours - vitals.sleep.avgHours;
|
|
2172
|
+
if (Math.abs(drift) >= 1) {
|
|
2173
|
+
vitalsSignals.push({
|
|
2174
|
+
id: 'sleep-drift',
|
|
2175
|
+
category: 'recovery',
|
|
2176
|
+
summary: `Last night's sleep ${drift > 0 ? '+' : ''}${drift.toFixed(1)}h vs 14d average`,
|
|
2177
|
+
detail: `Last night ${vitals.sleep.latestNight.hours}h (${vitals.sleep.latestNight.date}), avg ${vitals.sleep.avgHours}h`,
|
|
2178
|
+
impact: drift < 0 ? 8 : 6,
|
|
2179
|
+
confidence: 9,
|
|
2180
|
+
novelty: 7,
|
|
2181
|
+
actionability: 7
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
1951
2184
|
}
|
|
1952
2185
|
}
|
|
1953
2186
|
const rankedVitalsSignals = rankPrioritySignals(vitalsSignals);
|
|
@@ -1963,12 +2196,33 @@ export function vitalsSummaryContext(snapshot) {
|
|
|
1963
2196
|
// Health metrics
|
|
1964
2197
|
const metrics = snapshot.healthMetrics;
|
|
1965
2198
|
if (metrics) {
|
|
1966
|
-
appendHealthMetricsContext(lines, metrics, { recentDays: 14 });
|
|
2199
|
+
appendHealthMetricsContext(lines, metrics, { recentDays: 14, exclude });
|
|
1967
2200
|
}
|
|
1968
2201
|
|
|
2202
|
+
appendExcludeNote(lines, exclude);
|
|
2203
|
+
|
|
1969
2204
|
return lines.join('\n');
|
|
1970
2205
|
}
|
|
1971
2206
|
|
|
2207
|
+
function buildExcludeNote(exclude) {
|
|
2208
|
+
if (!exclude || exclude.size === 0) return null;
|
|
2209
|
+
const labels = [];
|
|
2210
|
+
if (exclude.has('recovery')) labels.push('recovery metrics (HR, HRV, sleep, VO2 max)');
|
|
2211
|
+
if (exclude.has('otherWorkouts')) labels.push('non-strength workouts');
|
|
2212
|
+
if (exclude.has('bodyWeight')) labels.push('body weight');
|
|
2213
|
+
if (exclude.has('trainingLoad')) labels.push('training load');
|
|
2214
|
+
if (labels.length === 0) return null;
|
|
2215
|
+
return `Note: The user has opted out of sharing ${labels.join(', ')} with the AI coach. Do not mention these data types or their absence. Instead, go deeper on the training data that is available — more detail on exercise progression, volume trends, and technique cues.`;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function appendExcludeNote(lines, exclude) {
|
|
2219
|
+
const note = buildExcludeNote(exclude);
|
|
2220
|
+
if (note) {
|
|
2221
|
+
lines.push('');
|
|
2222
|
+
lines.push(note);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
1972
2226
|
function requiredOption(options, primaryKey, legacyKey = null) {
|
|
1973
2227
|
return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
|
|
1974
2228
|
}
|