incremnt 0.8.1 → 0.8.3
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/package.json +6 -1
- package/src/ask-answer-verifier.js +249 -14
- package/src/ask-coach.js +495 -33
- package/src/openrouter.js +57 -30
- package/src/promptfoo-evals.js +20 -3
- package/src/queries.js +500 -21
- package/src/score-prelude.js +16 -13
- package/src/summary-evals.js +106 -474
- package/src/sync-service.js +73 -13
package/src/queries.js
CHANGED
|
@@ -2763,6 +2763,128 @@ export function getWeeklyVolume(snapshot, { today = new Date() } = {}) {
|
|
|
2763
2763
|
});
|
|
2764
2764
|
}
|
|
2765
2765
|
|
|
2766
|
+
// Light muscle-label normalizer: keys synonymous groups together without pulling
|
|
2767
|
+
// in the sync-service movement-family map (CLI stays self-contained). Display
|
|
2768
|
+
// label is the canonical group name.
|
|
2769
|
+
function normalizeMuscleLabel(raw) {
|
|
2770
|
+
const text = String(raw ?? '').trim().toLowerCase().replace(/[-_]/g, ' ').replace(/\s+/g, ' ');
|
|
2771
|
+
if (text === '') return { key: 'unattributed', label: 'Unattributed' };
|
|
2772
|
+
const synonyms = {
|
|
2773
|
+
delts: 'shoulders', delt: 'shoulders', shoulder: 'shoulders',
|
|
2774
|
+
quad: 'quads', quadriceps: 'quads',
|
|
2775
|
+
ham: 'hamstrings', hams: 'hamstrings', hamstring: 'hamstrings',
|
|
2776
|
+
glute: 'glutes', calf: 'calves', tricep: 'triceps', bicep: 'biceps',
|
|
2777
|
+
lat: 'lats', ab: 'abs', abdominals: 'abs', core: 'abs'
|
|
2778
|
+
};
|
|
2779
|
+
const key = synonyms[text] ?? text;
|
|
2780
|
+
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
|
2781
|
+
return { key, label };
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
function isoWeekStartOffset(weekStart, weeksBack) {
|
|
2785
|
+
const ms = new Date(`${weekStart}T00:00:00.000Z`).getTime() - weeksBack * 7 * 24 * 60 * 60 * 1000;
|
|
2786
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function isoDateOffset(isoDate, days) {
|
|
2790
|
+
const ms = new Date(`${isoDate}T00:00:00.000Z`).getTime() + days * 24 * 60 * 60 * 1000;
|
|
2791
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// Per-muscle strength volume (weight×reps over completed working sets) for the
|
|
2795
|
+
// last N ISO weeks, plus each muscle's share of that week's total. Answers
|
|
2796
|
+
// "volume per muscle relative to previous weeks' overall volume". Computed from
|
|
2797
|
+
// raw sessions so it reflects actual logged load, not sets-vs-target.
|
|
2798
|
+
export function getMuscleVolumeTrend(snapshot, { today = new Date(), weeks = 4 } = {}) {
|
|
2799
|
+
const todayIso = dateOnlyString(today);
|
|
2800
|
+
const currentWeekStart = startOfCurrentIsoWeek(today);
|
|
2801
|
+
const boundedWeeks = Math.max(1, Math.min(12, Math.round(Number(weeks) || 4)));
|
|
2802
|
+
|
|
2803
|
+
// Oldest -> newest so downstream arrays read chronologically.
|
|
2804
|
+
const weekStarts = [];
|
|
2805
|
+
for (let i = boundedWeeks - 1; i >= 0; i -= 1) {
|
|
2806
|
+
weekStarts.push(isoWeekStartOffset(currentWeekStart, i));
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
const sourceIds = [];
|
|
2810
|
+
const sourceDates = [];
|
|
2811
|
+
const muscleAccum = new Map(); // key -> { label, weeklyVolume: number[] }
|
|
2812
|
+
const weeklyTotals = weekStarts.map(() => 0);
|
|
2813
|
+
|
|
2814
|
+
weekStarts.forEach((weekStart, weekIndex) => {
|
|
2815
|
+
const isCurrent = weekStart === currentWeekStart;
|
|
2816
|
+
const weekEnd = isCurrent ? todayIso : isoDateOffset(weekStart, 6);
|
|
2817
|
+
const sessions = sessionsInDateRange(snapshot, weekStart, weekEnd);
|
|
2818
|
+
for (const session of sessions) {
|
|
2819
|
+
let contributed = false;
|
|
2820
|
+
for (const exercise of session.exercises ?? []) {
|
|
2821
|
+
const { key, label } = normalizeMuscleLabel(exercise.muscleGroup);
|
|
2822
|
+
const volume = completedWorkingSets(exercise.sets).reduce((sum, set) => sum + set.volume, 0);
|
|
2823
|
+
if (volume <= 0) continue;
|
|
2824
|
+
if (!muscleAccum.has(key)) {
|
|
2825
|
+
muscleAccum.set(key, { label, weeklyVolume: weekStarts.map(() => 0) });
|
|
2826
|
+
}
|
|
2827
|
+
muscleAccum.get(key).weeklyVolume[weekIndex] += volume;
|
|
2828
|
+
weeklyTotals[weekIndex] += volume;
|
|
2829
|
+
contributed = true;
|
|
2830
|
+
}
|
|
2831
|
+
if (contributed) {
|
|
2832
|
+
if (session.id) sourceIds.push(session.id);
|
|
2833
|
+
sourceDates.push(completionDateForSession(session));
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
});
|
|
2837
|
+
|
|
2838
|
+
const latestIndex = boundedWeeks - 1;
|
|
2839
|
+
const priorIndices = weekStarts.map((_, i) => i).filter((i) => i !== latestIndex);
|
|
2840
|
+
const muscles = [...muscleAccum.values()].map(({ label, weeklyVolume }) => {
|
|
2841
|
+
const rounded = weeklyVolume.map((value) => Math.round(value));
|
|
2842
|
+
const latestVolume = rounded[latestIndex];
|
|
2843
|
+
const latestTotal = weeklyTotals[latestIndex];
|
|
2844
|
+
const priorVolumes = priorIndices.map((i) => weeklyVolume[i]);
|
|
2845
|
+
const priorAvg = priorVolumes.length > 0
|
|
2846
|
+
? priorVolumes.reduce((sum, value) => sum + value, 0) / priorVolumes.length
|
|
2847
|
+
: 0;
|
|
2848
|
+
const sharePct = (volume, total) => (total > 0 ? Math.round((volume / total) * 100) : 0);
|
|
2849
|
+
return {
|
|
2850
|
+
muscle: label,
|
|
2851
|
+
weeklyVolume: rounded,
|
|
2852
|
+
latestVolume,
|
|
2853
|
+
latestSharePct: sharePct(weeklyVolume[latestIndex], latestTotal),
|
|
2854
|
+
priorAvgVolume: Math.round(priorAvg),
|
|
2855
|
+
deltaVsPriorAvgPct: priorAvg > 0 ? Math.round(((weeklyVolume[latestIndex] - priorAvg) / priorAvg) * 100) : null
|
|
2856
|
+
};
|
|
2857
|
+
}).sort((a, b) => b.latestVolume - a.latestVolume);
|
|
2858
|
+
|
|
2859
|
+
const rows = muscles.flatMap((row) => weekStarts.map((weekStart, i) => ({
|
|
2860
|
+
week: weekStart,
|
|
2861
|
+
muscle: row.muscle,
|
|
2862
|
+
volume: row.weeklyVolume[i],
|
|
2863
|
+
sharePct: weeklyTotals[i] > 0 ? Math.round((row.weeklyVolume[i] / weeklyTotals[i]) * 100) : 0
|
|
2864
|
+
})));
|
|
2865
|
+
|
|
2866
|
+
const missingDataFlags = [];
|
|
2867
|
+
if (muscles.length === 0) missingDataFlags.push('no_muscle_volume_in_window');
|
|
2868
|
+
if (muscleAccum.has('unattributed')) missingDataFlags.push('some_volume_unattributed_to_muscle');
|
|
2869
|
+
|
|
2870
|
+
return coachToolResult('get_muscle_volume_trend', {
|
|
2871
|
+
today: todayIso,
|
|
2872
|
+
weeks: boundedWeeks,
|
|
2873
|
+
weekStarts
|
|
2874
|
+
}, {
|
|
2875
|
+
rows,
|
|
2876
|
+
facts: {
|
|
2877
|
+
weekStarts,
|
|
2878
|
+
weeklyTotals: weeklyTotals.map((value) => Math.round(value)),
|
|
2879
|
+
muscleCount: muscles.length,
|
|
2880
|
+
muscles
|
|
2881
|
+
},
|
|
2882
|
+
sourceIds,
|
|
2883
|
+
sourceTimestamp: latestSourceTimestampFromDates(sourceDates),
|
|
2884
|
+
missingDataFlags
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2766
2888
|
export function getRecentSessions(snapshot, { limit = 3, today = new Date(), recencyCutoffDays = 14, includeStale = true } = {}) {
|
|
2767
2889
|
const sortedSessions = sortedSessionsNewestFirst(snapshot);
|
|
2768
2890
|
const rows = sortedSessions.map((session) => {
|
|
@@ -3051,9 +3173,11 @@ export function getBodyWeightSnapshot(snapshot, { recentDays = 30, exclude = new
|
|
|
3051
3173
|
|
|
3052
3174
|
const profileWeightKg = Number(snapshot.user?.weightKg);
|
|
3053
3175
|
const resolvedProfileWeightKg = Number.isFinite(profileWeightKg) && profileWeightKg > 0
|
|
3054
|
-
|
|
3176
|
+
? Math.round(profileWeightKg * 10) / 10
|
|
3055
3177
|
: null;
|
|
3056
3178
|
const cutoff = relativeDateString(today, -recentDays);
|
|
3179
|
+
const sevenDayCutoff = relativeDateString(today, -7);
|
|
3180
|
+
const thirtyDayCutoff = relativeDateString(today, -30);
|
|
3057
3181
|
const bodyWeightRows = (snapshot.healthMetrics?.bodyWeight ?? [])
|
|
3058
3182
|
.filter((entry) => entry?.date && Number.isFinite(Number(entry.value ?? entry.weight)))
|
|
3059
3183
|
.map((entry) => ({
|
|
@@ -3067,13 +3191,34 @@ export function getBodyWeightSnapshot(snapshot, { recentDays = 30, exclude = new
|
|
|
3067
3191
|
const trendKg = latest && earliestRecent && recentRows.length >= 2
|
|
3068
3192
|
? Math.round((latest.weightKg - earliestRecent.weightKg) * 10) / 10
|
|
3069
3193
|
: null;
|
|
3194
|
+
const averageWeightKg = (rows) => {
|
|
3195
|
+
if (!rows.length) return null;
|
|
3196
|
+
return Math.round((rows.reduce((sum, row) => sum + row.weightKg, 0) / rows.length) * 10) / 10;
|
|
3197
|
+
};
|
|
3198
|
+
const trendDirection = trendKg == null
|
|
3199
|
+
? null
|
|
3200
|
+
: trendKg > 0.1
|
|
3201
|
+
? 'rising'
|
|
3202
|
+
: trendKg < -0.1
|
|
3203
|
+
? 'falling'
|
|
3204
|
+
: 'flat';
|
|
3205
|
+
const sevenDayRows = bodyWeightRows.filter((entry) => entry.date >= sevenDayCutoff);
|
|
3206
|
+
const thirtyDayRows = bodyWeightRows.filter((entry) => entry.date >= thirtyDayCutoff);
|
|
3070
3207
|
const facts = {
|
|
3071
3208
|
recentDays,
|
|
3072
3209
|
profileWeightKg: resolvedProfileWeightKg,
|
|
3073
3210
|
latestBodyWeightKg: latest?.weightKg ?? resolvedProfileWeightKg,
|
|
3074
3211
|
latestBodyWeightDate: latest?.date ?? null,
|
|
3075
3212
|
readingCount: recentRows.length,
|
|
3076
|
-
trendKg
|
|
3213
|
+
trendKg,
|
|
3214
|
+
trendDirection,
|
|
3215
|
+
average7DayBodyWeightKg: averageWeightKg(sevenDayRows),
|
|
3216
|
+
average30DayBodyWeightKg: averageWeightKg(thirtyDayRows),
|
|
3217
|
+
earliestRecentBodyWeightKg: earliestRecent?.weightKg ?? null,
|
|
3218
|
+
earliestRecentBodyWeightDate: earliestRecent?.date ?? null,
|
|
3219
|
+
latestRecentBodyWeightKg: recentRows.at(-1)?.weightKg ?? null,
|
|
3220
|
+
latestRecentBodyWeightDate: recentRows.at(-1)?.date ?? null,
|
|
3221
|
+
sampleWindowDays: recentDays
|
|
3077
3222
|
};
|
|
3078
3223
|
const missingDataFlags = [];
|
|
3079
3224
|
if (facts.latestBodyWeightKg == null) missingDataFlags.push('no_body_weight');
|
|
@@ -3111,9 +3256,30 @@ export function getGoalStatus(snapshot, { limit = 5 } = {}) {
|
|
|
3111
3256
|
});
|
|
3112
3257
|
}
|
|
3113
3258
|
|
|
3259
|
+
function round1(value) {
|
|
3260
|
+
return Math.round(value * 10) / 10;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function priorBestSetBefore(sets, recordDateIso) {
|
|
3264
|
+
let prior = null;
|
|
3265
|
+
for (const set of sets) {
|
|
3266
|
+
const setDate = normalizeDateOnly(set.date);
|
|
3267
|
+
if (setDate == null || recordDateIso == null || setDate >= recordDateIso) continue;
|
|
3268
|
+
if (!prior || set.e1rm > prior.e1rm) prior = set;
|
|
3269
|
+
}
|
|
3270
|
+
return prior;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
function classifyRecordKind(record, priorBest) {
|
|
3274
|
+
if (!priorBest) return 'first';
|
|
3275
|
+
// A PR achieved by adding reps at the same (or even lower) load reads as a
|
|
3276
|
+
// stall to anything reasoning on bar weight, so distinguish it explicitly.
|
|
3277
|
+
return record.weight > priorBest.weight ? 'load_pr' : 'rep_pr';
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3114
3280
|
export function getRecords(snapshot, { exercises = [], limit = 15, recentSince = null, today = new Date() } = {}) {
|
|
3115
3281
|
const filter = exercises.length > 0 ? new Set(exercises.map((exercise) => exercise.canonical ?? canonicalExerciseName(exercise))) : null;
|
|
3116
|
-
const
|
|
3282
|
+
const setsByExercise = new Map();
|
|
3117
3283
|
for (const session of snapshot.sessions ?? []) {
|
|
3118
3284
|
for (const exercise of session.exercises ?? []) {
|
|
3119
3285
|
const key = canonicalExerciseName(exercise.name);
|
|
@@ -3121,27 +3287,85 @@ export function getRecords(snapshot, { exercises = [], limit = 15, recentSince =
|
|
|
3121
3287
|
for (const set of exercise.sets ?? []) {
|
|
3122
3288
|
if (!set.isComplete) continue;
|
|
3123
3289
|
const e1rm = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
date: completionDateForSession(session),
|
|
3130
|
-
sessionId: session.id ?? null
|
|
3131
|
-
});
|
|
3290
|
+
if (!(e1rm > 0)) continue;
|
|
3291
|
+
let entry = setsByExercise.get(key);
|
|
3292
|
+
if (!entry) {
|
|
3293
|
+
entry = { sets: [] };
|
|
3294
|
+
setsByExercise.set(key, entry);
|
|
3132
3295
|
}
|
|
3296
|
+
entry.sets.push({
|
|
3297
|
+
name: exercise.name,
|
|
3298
|
+
e1rm,
|
|
3299
|
+
weight: Number(set.weight),
|
|
3300
|
+
reps: Number(set.reps),
|
|
3301
|
+
date: completionDateForSession(session),
|
|
3302
|
+
sessionId: session.id ?? null
|
|
3303
|
+
});
|
|
3133
3304
|
}
|
|
3134
3305
|
}
|
|
3135
3306
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3307
|
+
|
|
3308
|
+
const records = [];
|
|
3309
|
+
for (const entry of setsByExercise.values()) {
|
|
3310
|
+
let best = null;
|
|
3311
|
+
for (const set of entry.sets) {
|
|
3312
|
+
// Strictly greater keeps the earliest set that reached the best e1RM.
|
|
3313
|
+
if (!best || set.e1rm > best.e1rm) best = set;
|
|
3314
|
+
}
|
|
3315
|
+
if (!best) continue;
|
|
3316
|
+
records.push({
|
|
3317
|
+
name: best.name,
|
|
3318
|
+
e1rm: best.e1rm,
|
|
3319
|
+
weight: best.weight,
|
|
3320
|
+
reps: best.reps,
|
|
3321
|
+
date: best.date,
|
|
3322
|
+
sessionId: best.sessionId,
|
|
3323
|
+
sets: entry.sets
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
const allRows = records
|
|
3328
|
+
.map((record) => ({
|
|
3329
|
+
name: record.name,
|
|
3330
|
+
e1rm: record.e1rm,
|
|
3331
|
+
weight: record.weight,
|
|
3332
|
+
reps: record.reps,
|
|
3333
|
+
date: record.date,
|
|
3334
|
+
sessionId: record.sessionId
|
|
3335
|
+
}))
|
|
3138
3336
|
.sort((a, b) => b.e1rm - a.e1rm);
|
|
3337
|
+
|
|
3139
3338
|
const todayIso = dateOnlyString(today);
|
|
3140
|
-
const
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3339
|
+
const recentSinceIso = recentSince ? normalizeDateOnly(recentSince) : null;
|
|
3340
|
+
const recentRecords = recentSinceIso
|
|
3341
|
+
? records
|
|
3342
|
+
.filter((record) => {
|
|
3343
|
+
const recordDate = normalizeDateOnly(record.date);
|
|
3344
|
+
return recordDate != null && recordDate >= recentSinceIso && recordDate <= todayIso;
|
|
3345
|
+
})
|
|
3346
|
+
.sort((a, b) => b.e1rm - a.e1rm)
|
|
3347
|
+
.map((record) => {
|
|
3348
|
+
const recordDateIso = normalizeDateOnly(record.date);
|
|
3349
|
+
const priorBest = priorBestSetBefore(record.sets, recordDateIso);
|
|
3350
|
+
const delta = priorBest ? round1(record.e1rm - priorBest.e1rm) : null;
|
|
3351
|
+
const deltaPct = priorBest && priorBest.e1rm > 0
|
|
3352
|
+
? round1(((record.e1rm - priorBest.e1rm) / priorBest.e1rm) * 100)
|
|
3353
|
+
: null;
|
|
3354
|
+
return {
|
|
3355
|
+
name: record.name,
|
|
3356
|
+
e1rm: record.e1rm,
|
|
3357
|
+
weight: record.weight,
|
|
3358
|
+
reps: record.reps,
|
|
3359
|
+
date: record.date,
|
|
3360
|
+
sessionId: record.sessionId,
|
|
3361
|
+
priorBest: priorBest
|
|
3362
|
+
? { e1rm: priorBest.e1rm, weight: priorBest.weight, reps: priorBest.reps, date: priorBest.date }
|
|
3363
|
+
: null,
|
|
3364
|
+
delta,
|
|
3365
|
+
deltaPct,
|
|
3366
|
+
kind: classifyRecordKind(record, priorBest)
|
|
3367
|
+
};
|
|
3368
|
+
})
|
|
3145
3369
|
: [];
|
|
3146
3370
|
const rows = allRows.slice(0, limit);
|
|
3147
3371
|
|
|
@@ -3156,7 +3380,8 @@ export function getRecords(snapshot, { exercises = [], limit = 15, recentSince =
|
|
|
3156
3380
|
recordCount: rows.length,
|
|
3157
3381
|
totalRecordCount: allRows.length,
|
|
3158
3382
|
recentRecordCount: recentRecords.length,
|
|
3159
|
-
recentRecordNames: recentRecords.map((record) => record.name)
|
|
3383
|
+
recentRecordNames: recentRecords.map((record) => record.name),
|
|
3384
|
+
recentRecords
|
|
3160
3385
|
},
|
|
3161
3386
|
sourceIds: rows.map((row) => row.sessionId),
|
|
3162
3387
|
sourceTimestamp: latestSourceTimestampFromDates(rows.map((row) => row.date)),
|
|
@@ -3484,6 +3709,201 @@ export function getTrainingProfile(snapshot, { since = null, today = new Date()
|
|
|
3484
3709
|
});
|
|
3485
3710
|
}
|
|
3486
3711
|
|
|
3712
|
+
function normalizeExcludeSet(exclude) {
|
|
3713
|
+
if (exclude instanceof Set) return new Set([...exclude].map((item) => String(item)));
|
|
3714
|
+
return new Set(Array.isArray(exclude) ? exclude.map((item) => String(item)) : []);
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
function compactRecordRow(row) {
|
|
3718
|
+
if (!row) return null;
|
|
3719
|
+
return {
|
|
3720
|
+
exercise: row.name ?? null,
|
|
3721
|
+
e1rm: round1(Number(row.e1rm ?? 0)),
|
|
3722
|
+
weight: Number(row.weight ?? 0),
|
|
3723
|
+
reps: Number(row.reps ?? 0),
|
|
3724
|
+
date: dateOnlyString(row.date),
|
|
3725
|
+
sessionId: row.sessionId ?? null
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
function compactRecentRecord(record) {
|
|
3730
|
+
const base = compactRecordRow(record);
|
|
3731
|
+
if (!base) return null;
|
|
3732
|
+
return {
|
|
3733
|
+
...base,
|
|
3734
|
+
delta: record.delta ?? null,
|
|
3735
|
+
deltaPct: record.deltaPct ?? null,
|
|
3736
|
+
kind: record.kind ?? null
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
function compactSessionHighlights(row, limit = 2) {
|
|
3741
|
+
return (row.exercises ?? [])
|
|
3742
|
+
.map((exercise) => {
|
|
3743
|
+
const topSet = exercise.topSet ?? null;
|
|
3744
|
+
if (!topSet) return null;
|
|
3745
|
+
const weight = Number(topSet.weight ?? 0);
|
|
3746
|
+
const reps = Number(topSet.reps ?? 0);
|
|
3747
|
+
if (!(reps > 0)) return null;
|
|
3748
|
+
return {
|
|
3749
|
+
exercise: exercise.name ?? null,
|
|
3750
|
+
weight,
|
|
3751
|
+
reps,
|
|
3752
|
+
e1rm: weight > 0 ? round1(estimateE1RM(weight, reps)) : null
|
|
3753
|
+
};
|
|
3754
|
+
})
|
|
3755
|
+
.filter(Boolean)
|
|
3756
|
+
.slice(0, limit);
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
function compactSessionRow(row) {
|
|
3760
|
+
return {
|
|
3761
|
+
date: row.date ?? null,
|
|
3762
|
+
label: row.label ?? 'Workout',
|
|
3763
|
+
volume: Math.round(Number(row.volume ?? 0)),
|
|
3764
|
+
highlights: compactSessionHighlights(row)
|
|
3765
|
+
};
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
function compactReadinessFacts(readinessFacts, load) {
|
|
3769
|
+
return {
|
|
3770
|
+
status: load?.status ?? null,
|
|
3771
|
+
loadReadiness: load?.readiness?.band ?? load?.readiness?.status ?? null,
|
|
3772
|
+
loadRatio: load?.readiness?.loadRatio ?? null,
|
|
3773
|
+
last7DayLoad: load?.last7Days?.totalLoad ?? null,
|
|
3774
|
+
last28DayLoad: load?.last28Days?.totalLoad ?? null,
|
|
3775
|
+
hrv: readinessFacts.latestHRV?.value ?? null,
|
|
3776
|
+
hrvDelta: readinessFacts.hrvDelta ?? null,
|
|
3777
|
+
restingHR: readinessFacts.latestRestingHR?.value ?? null,
|
|
3778
|
+
restingHRDelta: readinessFacts.restingHRDelta ?? null,
|
|
3779
|
+
sleepHrs: readinessFacts.latestSleep?.value ?? null,
|
|
3780
|
+
sleepDelta: readinessFacts.sleepDelta ?? null
|
|
3781
|
+
};
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
export function getAthleteSnapshot(snapshot, { today = new Date(), windowDays = 35, exclude = [] } = {}) {
|
|
3785
|
+
const asOf = dateOnlyString(today);
|
|
3786
|
+
const boundedWindowDays = boundedInteger(windowDays, { defaultValue: 35, min: 1, max: 365 });
|
|
3787
|
+
const since = relativeDateString(asOf, -boundedWindowDays);
|
|
3788
|
+
const excluded = normalizeExcludeSet(exclude);
|
|
3789
|
+
const profile = getTrainingProfile(snapshot, { since, today: asOf });
|
|
3790
|
+
const sourceIds = [...profile.sourceIds];
|
|
3791
|
+
const sourceTimestamps = [profile.sourceTimestamp];
|
|
3792
|
+
const missingDataFlags = [...profile.missingDataFlags];
|
|
3793
|
+
const facts = {
|
|
3794
|
+
asOf,
|
|
3795
|
+
windowDays: boundedWindowDays,
|
|
3796
|
+
profile: {
|
|
3797
|
+
program: profile.facts.currentProgram?.name ?? null,
|
|
3798
|
+
programId: profile.facts.currentProgram?.id ?? null,
|
|
3799
|
+
daysPerWeek: profile.facts.currentProgram?.daysPerWeek ?? null,
|
|
3800
|
+
trainingWeekdays: profile.facts.trainingWeekdays ?? [],
|
|
3801
|
+
loggedSessionCount: profile.facts.loggedSessionCount ?? 0,
|
|
3802
|
+
trainedExerciseCount: profile.facts.trainedExerciseCount ?? 0,
|
|
3803
|
+
completedCycles: profile.facts.currentProgram?.completedCyclesCount ?? 0
|
|
3804
|
+
}
|
|
3805
|
+
};
|
|
3806
|
+
|
|
3807
|
+
if (!excluded.has('score')) {
|
|
3808
|
+
const score = getIncrementScore(snapshot, { historyDays: Math.min(boundedWindowDays, 60) });
|
|
3809
|
+
facts.score = Object.keys(score.facts ?? {}).length > 0 ? {
|
|
3810
|
+
value: score.facts.score ?? null,
|
|
3811
|
+
band: score.facts.scoreBand ?? null,
|
|
3812
|
+
dayOverDayDelta: score.facts.dayOverDayDelta ?? null,
|
|
3813
|
+
positiveDrivers: score.facts.topPositiveDrivers ?? [],
|
|
3814
|
+
negativeDrivers: score.facts.topNegativeDrivers ?? []
|
|
3815
|
+
} : null;
|
|
3816
|
+
sourceIds.push(...score.sourceIds);
|
|
3817
|
+
sourceTimestamps.push(score.sourceTimestamp);
|
|
3818
|
+
missingDataFlags.push(...score.missingDataFlags);
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
if (!excluded.has('volume')) {
|
|
3822
|
+
const volume = getWeeklyVolume(snapshot, { today: asOf });
|
|
3823
|
+
facts.volume = {
|
|
3824
|
+
currentWeek: volume.facts.currentWeekVolume ?? 0,
|
|
3825
|
+
currentWeekSessions: volume.facts.currentWeekSessionCount ?? 0,
|
|
3826
|
+
previousWeek: volume.facts.previousWeekVolume ?? 0,
|
|
3827
|
+
previousWeekSessions: volume.facts.previousWeekSessionCount ?? 0,
|
|
3828
|
+
deltaPct: volume.facts.deltaPct ?? null
|
|
3829
|
+
};
|
|
3830
|
+
sourceIds.push(...volume.sourceIds);
|
|
3831
|
+
sourceTimestamps.push(volume.sourceTimestamp);
|
|
3832
|
+
missingDataFlags.push(...volume.missingDataFlags);
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
if (!excluded.has('muscleVolume')) {
|
|
3836
|
+
const trendWeeks = Math.max(2, Math.min(5, Math.round(boundedWindowDays / 7)));
|
|
3837
|
+
const muscleTrend = getMuscleVolumeTrend(snapshot, { today: asOf, weeks: trendWeeks });
|
|
3838
|
+
facts.muscleVolume = {
|
|
3839
|
+
weekStarts: muscleTrend.facts.weekStarts,
|
|
3840
|
+
weeklyTotals: muscleTrend.facts.weeklyTotals,
|
|
3841
|
+
muscles: (muscleTrend.facts.muscles ?? []).slice(0, 6).map((row) => ({
|
|
3842
|
+
muscle: row.muscle,
|
|
3843
|
+
weeklyVolume: row.weeklyVolume,
|
|
3844
|
+
latestSharePct: row.latestSharePct,
|
|
3845
|
+
deltaVsPriorAvgPct: row.deltaVsPriorAvgPct
|
|
3846
|
+
}))
|
|
3847
|
+
};
|
|
3848
|
+
sourceIds.push(...muscleTrend.sourceIds);
|
|
3849
|
+
sourceTimestamps.push(muscleTrend.sourceTimestamp);
|
|
3850
|
+
missingDataFlags.push(...muscleTrend.missingDataFlags);
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
if (!excluded.has('recovery')) {
|
|
3854
|
+
const readiness = getReadinessSnapshot(snapshot, {
|
|
3855
|
+
recentDays: Math.min(boundedWindowDays, 60),
|
|
3856
|
+
today: asOf
|
|
3857
|
+
});
|
|
3858
|
+
const load = trainingLoad(snapshot);
|
|
3859
|
+
facts.readiness = compactReadinessFacts(readiness.facts, load);
|
|
3860
|
+
sourceIds.push(...readiness.sourceIds);
|
|
3861
|
+
sourceTimestamps.push(readiness.sourceTimestamp, load?.asOf);
|
|
3862
|
+
missingDataFlags.push(...readiness.missingDataFlags);
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
if (!excluded.has('records')) {
|
|
3866
|
+
const records = getRecords(snapshot, { limit: 6, recentSince: since, today: asOf });
|
|
3867
|
+
facts.records = {
|
|
3868
|
+
topPRs: records.rows.map(compactRecordRow).filter(Boolean).slice(0, 6),
|
|
3869
|
+
recentRecords: (records.facts.recentRecords ?? []).map(compactRecentRecord).filter(Boolean).slice(0, 5)
|
|
3870
|
+
};
|
|
3871
|
+
sourceIds.push(...records.sourceIds);
|
|
3872
|
+
sourceTimestamps.push(records.sourceTimestamp);
|
|
3873
|
+
missingDataFlags.push(...records.missingDataFlags);
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
const recentSessions = getRecentSessions(snapshot, {
|
|
3877
|
+
limit: 4,
|
|
3878
|
+
today: asOf,
|
|
3879
|
+
recencyCutoffDays: boundedWindowDays,
|
|
3880
|
+
includeStale: false
|
|
3881
|
+
});
|
|
3882
|
+
facts.recentSessions = recentSessions.rows.map(compactSessionRow).slice(0, 4);
|
|
3883
|
+
sourceIds.push(...recentSessions.sourceIds);
|
|
3884
|
+
sourceTimestamps.push(recentSessions.sourceTimestamp);
|
|
3885
|
+
missingDataFlags.push(...recentSessions.missingDataFlags);
|
|
3886
|
+
|
|
3887
|
+
if (!excluded.has('notes')) {
|
|
3888
|
+
facts.notes = (profile.facts.recentNotes ?? []).map((note) => ({
|
|
3889
|
+
date: note.date ?? null,
|
|
3890
|
+
text: note.note ?? ''
|
|
3891
|
+
})).slice(0, 5);
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
return coachToolResult('get_athlete_snapshot', {
|
|
3895
|
+
today: asOf,
|
|
3896
|
+
windowDays: boundedWindowDays,
|
|
3897
|
+
exclude: [...excluded]
|
|
3898
|
+
}, {
|
|
3899
|
+
rows: [],
|
|
3900
|
+
facts,
|
|
3901
|
+
sourceIds,
|
|
3902
|
+
sourceTimestamp: latestSourceTimestamp(sourceTimestamps),
|
|
3903
|
+
missingDataFlags
|
|
3904
|
+
});
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3487
3907
|
function scoreComponentNumber(value) {
|
|
3488
3908
|
const num = typeof value === 'number' ? value : value?.score;
|
|
3489
3909
|
return typeof num === 'number' && Number.isFinite(num) ? num : null;
|
|
@@ -3543,7 +3963,15 @@ export function incrementScoreSummary(snapshot, { historyDays = 14 } = {}) {
|
|
|
3543
3963
|
|
|
3544
3964
|
const trimmedHistory = history.slice(0, boundedHistoryDays);
|
|
3545
3965
|
const prior = trimmedHistory[1];
|
|
3546
|
-
|
|
3966
|
+
// Scores are only comparable within the same formula version. The Increment
|
|
3967
|
+
// Score formula changed mid-2026 (it started counting recovery data it did not
|
|
3968
|
+
// have before), so subtracting an older-formula score from a newer one is the
|
|
3969
|
+
// "+36 / 77% up" cross-ruler artifact. Null the delta across a formula change
|
|
3970
|
+
// so downstream voice cannot frame a non-comparable jump as real progress.
|
|
3971
|
+
const latestFormulaVersion = latest.formulaVersion ?? null;
|
|
3972
|
+
const dayOverDayComparable = (typeof prior?.score === 'number')
|
|
3973
|
+
&& (prior.formulaVersion ?? null) === latestFormulaVersion;
|
|
3974
|
+
const dayOverDayDelta = dayOverDayComparable
|
|
3547
3975
|
? latest.score - prior.score
|
|
3548
3976
|
: null;
|
|
3549
3977
|
|
|
@@ -3570,6 +3998,11 @@ export function incrementScoreSummary(snapshot, { historyDays = 14 } = {}) {
|
|
|
3570
3998
|
dataTier: entry.dataTier ?? null,
|
|
3571
3999
|
formulaVersion: entry.formulaVersion ?? null
|
|
3572
4000
|
}));
|
|
4001
|
+
// A multi-day trend is only meaningful if every point shares the latest
|
|
4002
|
+
// formula version; otherwise the "rising/falling" steer mixes rulers.
|
|
4003
|
+
const trendComparable = recentTrend.every(
|
|
4004
|
+
(entry) => (entry.formulaVersion ?? null) === latestFormulaVersion
|
|
4005
|
+
);
|
|
3573
4006
|
|
|
3574
4007
|
return {
|
|
3575
4008
|
available: true,
|
|
@@ -3581,6 +4014,8 @@ export function incrementScoreSummary(snapshot, { historyDays = 14 } = {}) {
|
|
|
3581
4014
|
topPositiveDrivers: scoreDriverLabels(latest.topPositiveDrivers),
|
|
3582
4015
|
topNegativeDrivers: scoreDriverLabels(latest.topNegativeDrivers),
|
|
3583
4016
|
dayOverDayDelta,
|
|
4017
|
+
dayOverDayComparable,
|
|
4018
|
+
trendComparable,
|
|
3584
4019
|
recentTrend,
|
|
3585
4020
|
dataQualityNotes,
|
|
3586
4021
|
missingDataFlags,
|
|
@@ -3961,7 +4396,7 @@ export const COACH_READ_TOOL_SCHEMAS = Object.freeze({
|
|
|
3961
4396
|
outputSchema: COACH_TOOL_RESULT_SCHEMA
|
|
3962
4397
|
}),
|
|
3963
4398
|
get_body_weight_snapshot: Object.freeze({
|
|
3964
|
-
description: 'Read
|
|
4399
|
+
description: 'Read profile body weight plus recent HealthKit body-mass readings, compact trend facts, and chartable rows when body weight sharing is enabled.',
|
|
3965
4400
|
inputSchema: {
|
|
3966
4401
|
type: 'object',
|
|
3967
4402
|
properties: {
|
|
@@ -4089,6 +4524,35 @@ export const COACH_READ_TOOL_SCHEMAS = Object.freeze({
|
|
|
4089
4524
|
},
|
|
4090
4525
|
outputSchema: COACH_TOOL_RESULT_SCHEMA
|
|
4091
4526
|
}),
|
|
4527
|
+
get_athlete_snapshot: Object.freeze({
|
|
4528
|
+
description: 'Read a compact athlete-state snapshot for Coach curation: profile, score, volume, readiness, records, recent sessions, and notes.',
|
|
4529
|
+
inputSchema: {
|
|
4530
|
+
type: 'object',
|
|
4531
|
+
properties: {
|
|
4532
|
+
today: { type: 'string', format: 'date', description: 'Optional anchor date; defaults to today.' },
|
|
4533
|
+
windowDays: { type: 'integer', minimum: 1, maximum: 365, default: 35 },
|
|
4534
|
+
exclude: {
|
|
4535
|
+
type: 'array',
|
|
4536
|
+
items: { type: 'string', enum: ['recovery', 'records', 'notes', 'score', 'volume', 'muscleVolume'] },
|
|
4537
|
+
default: []
|
|
4538
|
+
}
|
|
4539
|
+
},
|
|
4540
|
+
additionalProperties: false
|
|
4541
|
+
},
|
|
4542
|
+
outputSchema: COACH_TOOL_RESULT_SCHEMA
|
|
4543
|
+
}),
|
|
4544
|
+
get_muscle_volume_trend: Object.freeze({
|
|
4545
|
+
description: 'Per-muscle strength volume (weight×reps) per ISO week for the last N weeks, with each muscle\'s share of weekly total.',
|
|
4546
|
+
inputSchema: {
|
|
4547
|
+
type: 'object',
|
|
4548
|
+
properties: {
|
|
4549
|
+
today: { type: 'string', format: 'date', description: 'Optional anchor date; defaults to today.' },
|
|
4550
|
+
weeks: { type: 'integer', minimum: 1, maximum: 12, default: 4 }
|
|
4551
|
+
},
|
|
4552
|
+
additionalProperties: false
|
|
4553
|
+
},
|
|
4554
|
+
outputSchema: COACH_TOOL_RESULT_SCHEMA
|
|
4555
|
+
}),
|
|
4092
4556
|
get_cycle_progression_summary: Object.freeze({
|
|
4093
4557
|
description: 'Summarize completed cycle progression counts and adherence.',
|
|
4094
4558
|
inputSchema: {
|
|
@@ -4244,6 +4708,19 @@ function normalizeCoachToolInput(toolName, input = {}) {
|
|
|
4244
4708
|
today: normalizedToolDateOnly(source.today)
|
|
4245
4709
|
};
|
|
4246
4710
|
}
|
|
4711
|
+
if (toolName === 'get_athlete_snapshot') {
|
|
4712
|
+
return {
|
|
4713
|
+
today: normalizedToolDateOnly(source.today),
|
|
4714
|
+
windowDays: boundedInteger(source.windowDays, { defaultValue: 35, min: 1, max: 365 }),
|
|
4715
|
+
exclude: Array.isArray(source.exclude) ? source.exclude.map((item) => String(item)) : []
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
if (toolName === 'get_muscle_volume_trend') {
|
|
4719
|
+
return {
|
|
4720
|
+
today: normalizedToolDateOnly(source.today),
|
|
4721
|
+
weeks: boundedInteger(source.weeks, { defaultValue: 4, min: 1, max: 12 })
|
|
4722
|
+
};
|
|
4723
|
+
}
|
|
4247
4724
|
if (toolName === 'get_cycle_progression_summary') {
|
|
4248
4725
|
return {
|
|
4249
4726
|
programId: source.programId ? String(source.programId) : null,
|
|
@@ -4289,6 +4766,8 @@ export function executeCoachReadTool(snapshot, toolName, input = {}) {
|
|
|
4289
4766
|
if (toolName === 'get_exercise_progress_summary') return getExerciseProgressSummary(snapshot, params);
|
|
4290
4767
|
if (toolName === 'get_program_progress') return getProgramProgress(snapshot, params);
|
|
4291
4768
|
if (toolName === 'get_training_profile') return getTrainingProfile(snapshot, params);
|
|
4769
|
+
if (toolName === 'get_athlete_snapshot') return getAthleteSnapshot(snapshot, params);
|
|
4770
|
+
if (toolName === 'get_muscle_volume_trend') return getMuscleVolumeTrend(snapshot, params);
|
|
4292
4771
|
if (toolName === 'get_cycle_progression_summary') return getCycleProgressionSummary(snapshot, params);
|
|
4293
4772
|
if (toolName === 'get_current_coach_observations') return getCurrentCoachObservations(snapshot, params);
|
|
4294
4773
|
if (toolName === 'compare_session_to_observations') return compareSessionToObservations(snapshot, params);
|