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/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
- ? Math.round(profileWeightKg * 10) / 10
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 bestByExercise = new Map();
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
- const current = bestByExercise.get(key);
3125
- if (!current || e1rm > current.e1rm) {
3126
- bestByExercise.set(key, {
3127
- name: exercise.name,
3128
- e1rm,
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
- const allRows = [...bestByExercise.values()]
3137
- .filter((record) => record.e1rm > 0)
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 recentRecords = recentSince
3141
- ? allRows.filter((record) => {
3142
- const recordDate = normalizeDateOnly(record.date);
3143
- return recordDate != null && recordDate >= recentSince && recordDate <= todayIso;
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
- const dayOverDayDelta = (typeof prior?.score === 'number')
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 the user profile body weight and recent HealthKit body-mass readings when body weight sharing is enabled.',
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);