incremnt 0.5.0 → 0.6.1

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
@@ -1,11 +1,148 @@
1
1
  import { coachFactPolicyViolation } from './coach-facts.js';
2
2
  import { exerciseAliasMapping } from './exercise-aliases.js';
3
- import { programPhaseWindowContext, resolveProgramPhase } from './program-phase-resolver.js';
3
+ import { resolveProgramPhase } from './program-phase-resolver.js';
4
+ import { enrichScoreSnapshots } from './score-context.js';
4
5
 
5
6
  function completionDateForSession(session) {
6
7
  return session.completedAt ?? session.summary?.date ?? session.date;
7
8
  }
8
9
 
10
+ const WEEKDAY_NAMES = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
11
+
12
+ function isoWeekdayOf(date) {
13
+ const jsDay = date.getDay();
14
+ return jsDay === 0 ? 7 : jsDay;
15
+ }
16
+
17
+ function dateOnly(date) {
18
+ return localDateString(date);
19
+ }
20
+
21
+ function shiftDate(date, days) {
22
+ const d = new Date(date);
23
+ d.setDate(d.getDate() + days);
24
+ return d;
25
+ }
26
+
27
+ /**
28
+ * Mirror of iOS DashboardSchedulingLogic for AI prompt builders.
29
+ * Resolves the program's schedule position into one of:
30
+ * - 'catchUp' — currentDayIndex's scheduled weekday already passed this week
31
+ * and that index isn't in completedDayIndices.
32
+ * - 'today' — currentDayIndex's scheduled weekday is today.
33
+ * - 'upcoming' — scheduled later this week or next.
34
+ *
35
+ * Without this, the naive `(trainingWeekdays[currentDayIndex] - todayIso + 7) % 7`
36
+ * surfaces a missed Friday session as "Friday (in 6 days)" the morning after.
37
+ */
38
+ export function resolveProgramSchedule(program, now = new Date()) {
39
+ if (!program) return null;
40
+ const trainingWeekdays = program.trainingWeekdays ?? [];
41
+ const days = program.days ?? [];
42
+ const dayCount = days.length;
43
+ if (dayCount === 0) return null;
44
+
45
+ const currentDayIndex = program.currentDayIndex ?? 0;
46
+ if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= dayCount) {
47
+ return null;
48
+ }
49
+ const scheduledWeekday = trainingWeekdays[currentDayIndex];
50
+ if (scheduledWeekday == null) return null;
51
+
52
+ const todayIso = isoWeekdayOf(now);
53
+ const completed = new Set(program.completedDayIndices ?? []);
54
+ const dayTitle = days[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
55
+
56
+ // Mirror DashboardSchedulingLogic.shouldShowCatchUpWorkout: cycle has started
57
+ // (some completions exist) and currentDayIndex itself isn't completed.
58
+ const cycleStarted = completed.size > 0;
59
+ const currentIncomplete = !completed.has(currentDayIndex);
60
+ const nextScheduledSession = trainingWeekdays
61
+ .map((weekday, dayIndex) => ({ weekday, dayIndex }))
62
+ .filter(({ weekday, dayIndex }) => (
63
+ dayIndex < dayCount
64
+ && weekday > todayIso
65
+ && !completed.has(dayIndex)
66
+ ))
67
+ .sort((a, b) => a.weekday - b.weekday)[0] ?? null;
68
+
69
+ if (
70
+ scheduledWeekday < todayIso
71
+ && cycleStarted
72
+ && currentIncomplete
73
+ ) {
74
+ const daysAgo = todayIso - scheduledWeekday;
75
+ const resolved = {
76
+ state: 'catchUp',
77
+ dayIndex: currentDayIndex,
78
+ dayTitle,
79
+ scheduledWeekday,
80
+ scheduledWeekdayName: WEEKDAY_NAMES[scheduledWeekday],
81
+ scheduledOn: dateOnly(shiftDate(now, -daysAgo)),
82
+ daysAgo
83
+ };
84
+ if (nextScheduledSession) {
85
+ const daysUntil = nextScheduledSession.weekday - todayIso;
86
+ resolved.nextScheduledSession = {
87
+ state: 'upcoming',
88
+ dayIndex: nextScheduledSession.dayIndex,
89
+ dayTitle: days[nextScheduledSession.dayIndex]?.title ?? `Day ${nextScheduledSession.dayIndex + 1}`,
90
+ scheduledWeekday: nextScheduledSession.weekday,
91
+ scheduledWeekdayName: WEEKDAY_NAMES[nextScheduledSession.weekday],
92
+ scheduledOn: dateOnly(shiftDate(now, daysUntil)),
93
+ daysUntil
94
+ };
95
+ }
96
+ return resolved;
97
+ }
98
+
99
+ let daysUntil = scheduledWeekday - todayIso;
100
+ if (daysUntil < 0) daysUntil += 7;
101
+ const state = daysUntil === 0 ? 'today' : 'upcoming';
102
+ return {
103
+ state,
104
+ dayIndex: currentDayIndex,
105
+ dayTitle,
106
+ scheduledWeekday,
107
+ scheduledWeekdayName: WEEKDAY_NAMES[scheduledWeekday],
108
+ scheduledOn: dateOnly(shiftDate(now, daysUntil)),
109
+ daysUntil
110
+ };
111
+ }
112
+
113
+ function formatScheduleLine(resolved) {
114
+ if (!resolved) return null;
115
+ if (resolved.state === 'catchUp') {
116
+ const next = resolved.nextScheduledSession;
117
+ const nextLine = next
118
+ ? ` Next scheduled session: ${next.dayTitle} on ${next.scheduledWeekdayName} (${next.daysUntil === 1 ? 'tomorrow' : `in ${next.daysUntil} days`}).`
119
+ : '';
120
+ return `Missed scheduled session: ${resolved.dayTitle} (was ${resolved.scheduledWeekdayName} ${resolved.scheduledOn}, not completed). Available to catch up.${nextLine}`;
121
+ }
122
+ if (resolved.state === 'today') {
123
+ return `Today's scheduled session: ${resolved.dayTitle} (${resolved.scheduledWeekdayName}).`;
124
+ }
125
+ const whenLabel = resolved.daysUntil === 1 ? 'tomorrow' : `in ${resolved.daysUntil} days`;
126
+ return `Next scheduled session: ${resolved.dayTitle} on ${resolved.scheduledWeekdayName} (${whenLabel}).`;
127
+ }
128
+
129
+ function completedThisCycleLines(program) {
130
+ if (!program) return [];
131
+ const completed = Array.from(new Set(program.completedDayIndices ?? []))
132
+ .filter((idx) => Number.isInteger(idx))
133
+ .sort((a, b) => a - b);
134
+ if (completed.length === 0) return [];
135
+ const days = program.days ?? [];
136
+ const trainingWeekdays = program.trainingWeekdays ?? [];
137
+ const parts = completed.map((idx) => {
138
+ const title = days[idx]?.title ?? `Day ${idx + 1}`;
139
+ const wd = trainingWeekdays[idx];
140
+ const wdName = wd != null ? WEEKDAY_NAMES[wd]?.slice(0, 3) : null;
141
+ return wdName ? `${title} (${wdName})` : title;
142
+ });
143
+ return [`Completed this cycle: ${parts.join(', ')}.`];
144
+ }
145
+
9
146
  function normalizedNote(note) {
10
147
  if (typeof note !== 'string') return null;
11
148
  const trimmed = note.trim();
@@ -1100,38 +1237,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1100
1237
  return { original, replacement, count };
1101
1238
  });
1102
1239
 
1103
- let goalProgress = null;
1104
- const plans = snapshot.strengthPlans ?? [];
1105
- const activePlan = plans.find(
1106
- (p) => p.status === 'active' && p.programId === program.id
1107
- );
1108
- if (activePlan) {
1109
- const programExerciseNames = new Set(
1110
- (program.days ?? [])
1111
- .flatMap((day) => day.exercises ?? [])
1112
- .map((exercise) => canonicalExerciseName(exercise.name ?? exercise.exerciseName))
1113
- );
1114
- goalProgress = (activePlan.liftGoals ?? [])
1115
- .filter((g) => {
1116
- if (programExerciseNames.size === 0) return true;
1117
- return programExerciseNames.has(canonicalExerciseName(g.exerciseDisplayName));
1118
- })
1119
- .map((g) => {
1120
- const range = g.targetE1RM - g.startingE1RM;
1121
- const gained = g.currentBestE1RM - g.startingE1RM;
1122
- const progressPct =
1123
- range > 0 ? Math.max(0, Math.round((gained / range) * 100)) : null;
1124
- return {
1125
- exerciseName: g.exerciseDisplayName,
1126
- progressPercent: progressPct,
1127
- currentBestE1RM: g.currentBestE1RM,
1128
- targetE1RM: g.targetE1RM,
1129
- goalAdjustmentAction: g.goalAdjustmentAction ?? null,
1130
- goalAdjustedAt: g.goalAdjustedAt ?? null
1131
- };
1132
- });
1133
- }
1134
-
1135
1240
  const matchingSummary = programCycleSummaries[0] ?? null;
1136
1241
 
1137
1242
  const progressionDecisions = (matchingSummary?.progressionUpdates ?? []).map((u) => ({
@@ -1235,39 +1340,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1235
1340
  actionability: 9
1236
1341
  });
1237
1342
  }
1238
- if (goalProgress?.length > 0) {
1239
- const lagging = goalProgress.filter((g) => g.progressPercent != null && g.progressPercent < 40);
1240
- if (lagging.length > 0) {
1241
- cycleSignals.push({
1242
- id: 'goal-lagging',
1243
- category: 'goals',
1244
- summary: `${lagging.length} goal${lagging.length === 1 ? '' : 's'} under 40% progress`,
1245
- detail: lagging.map((g) => `${g.exerciseName} ${g.progressPercent}%`).join(', '),
1246
- impact: 9,
1247
- confidence: 8,
1248
- novelty: 6,
1249
- actionability: 9
1250
- });
1251
- }
1252
- }
1253
-
1254
- // Phase-window context (Step 9b of the deload-week unification plan): explicit
1255
- // structured phase facts so prompt builders / models never have to infer
1256
- // "is this a deload week?" from session prose.
1257
- const phaseRangeStart = cycleSessions[0]?.completedAt ?? cycleSessions[0]?.date ?? null;
1258
- const phaseRangeEnd = cycleSessions[cycleSessions.length - 1]?.completedAt
1259
- ?? cycleSessions[cycleSessions.length - 1]?.date
1260
- ?? null;
1261
- const summaryRange = phaseRangeStart && phaseRangeEnd
1262
- ? { start: phaseRangeStart, end: phaseRangeEnd }
1263
- : null;
1264
- const programPhase = programPhaseWindowContext(
1265
- program,
1266
- activeStrengthPlanForProgram(snapshot, program.id),
1267
- summaryRange,
1268
- new Date()
1269
- );
1270
-
1271
1343
  return {
1272
1344
  programName: program.name,
1273
1345
  cycleNumber: cycleWeekNumber,
@@ -1275,7 +1347,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1275
1347
  sessions,
1276
1348
  prsThisCycle,
1277
1349
  bwPrsThisCycle,
1278
- goalProgress,
1279
1350
  progressionDecisions,
1280
1351
  setCompletionRate,
1281
1352
  swapPatterns,
@@ -1290,7 +1361,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1290
1361
  avgSleepMins,
1291
1362
  latestBodyWeightKg,
1292
1363
  prioritySignals: rankPrioritySignals(cycleSignals),
1293
- programPhase,
1294
1364
  excludeNote: buildExcludeNote(exclude)
1295
1365
  };
1296
1366
  }
@@ -1372,26 +1442,12 @@ export function checkpointContext(snapshot, programId, checkpointWeek, { exclude
1372
1442
  .slice(0, 3);
1373
1443
  const previousCycleNotes = programCycleSummaries.map((cs) => cs.aiSummary);
1374
1444
 
1375
- // Phase-window context (Step 9b). Scoped to a 14-day window ending today
1376
- // so phasesInRange covers "current week" + "previous week" — enough for
1377
- // the model to spot post-deload-return / pre-deload patterns without
1378
- // bloating the prompt with the entire plan timeline.
1379
- const checkpointToday = new Date();
1380
- const checkpointStart = new Date(checkpointToday.getTime() - 14 * 24 * 60 * 60 * 1000);
1381
- const programPhase = programPhaseWindowContext(
1382
- program,
1383
- activeStrengthPlanForProgram(snapshot, program.id),
1384
- { start: checkpointStart, end: checkpointToday },
1385
- checkpointToday
1386
- );
1387
-
1388
1445
  return {
1389
1446
  programName: program.name,
1390
1447
  checkpointWeek,
1391
1448
  totalWeeks,
1392
1449
  exercises,
1393
1450
  previousCycleNotes,
1394
- programPhase,
1395
1451
  excludeNote: buildExcludeNote(exclude)
1396
1452
  };
1397
1453
  }
@@ -1401,6 +1457,7 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
1401
1457
  const session = sessions.find((s) => s.id === sessionId);
1402
1458
  if (!session) return null;
1403
1459
 
1460
+ const today = new Date();
1404
1461
  const sessionDate = completionDateForSession(session);
1405
1462
  const dayName = session.dayName ?? 'Session';
1406
1463
  const earlierSessions = sessions
@@ -1714,14 +1771,16 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
1714
1771
  const nextExercises = ((program.days ?? [])[currentDayIndex]?.exercises ?? [])
1715
1772
  .map(ex => ex.exerciseName ?? ex.name)
1716
1773
  .filter(Boolean);
1717
- const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
1718
- let weekdayName = null;
1719
- if (nextSessionWeekday != null) {
1720
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
1721
- weekdayName = dayNames[nextSessionWeekday] ?? null;
1722
- }
1774
+ const schedule = resolveProgramSchedule(program, today);
1723
1775
  if (nextDayTitle) {
1724
- nextSession = { dayTitle: nextDayTitle, weekday: weekdayName, exerciseNames: nextExercises };
1776
+ nextSession = {
1777
+ dayTitle: nextDayTitle,
1778
+ weekday: schedule?.scheduledWeekdayName ?? null,
1779
+ exerciseNames: nextExercises,
1780
+ state: schedule?.state ?? null,
1781
+ scheduledOn: schedule?.scheduledOn ?? null,
1782
+ nextScheduledSession: schedule?.nextScheduledSession ?? null
1783
+ };
1725
1784
  }
1726
1785
  }
1727
1786
  }
@@ -1996,30 +2055,13 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
1996
2055
  lines.push(`Recent frequency: ${perWeek} sessions/week (last 4 weeks).`);
1997
2056
  }
1998
2057
 
1999
- // Current program + week phase. Guided programs use ProgramPhaseResolver so
2000
- // coach text cannot contradict the structured programPhase prelude.
2058
+ // Current program context without plan-week or phase framing. Score is the
2059
+ // release progress model; program context is only for what the user trains.
2001
2060
  const program = activeProgram(snapshot);
2002
2061
  if (program) {
2003
2062
  const recoveryOutcome = exclude.has('recovery') ? null : deriveRecoveryOutcome(snapshot, program);
2004
- const programSessions = sessions
2005
- .filter((s) => s.programId === program.id && s.historicalContext?.programWeekNumber)
2006
- .sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))));
2007
- const latestSession = programSessions[0];
2008
- const phase = resolveCurrentProgramPhase(snapshot, program, today);
2009
- const currentWeek = phase?.displayWeek
2010
- ?? Math.max(
2011
- Number(program.completedCyclesCount ?? 0) + 1,
2012
- Number(latestSession?.historicalContext?.programWeekNumber ?? 0),
2013
- 1
2014
- );
2015
- const weekPhase = phase?.phase
2016
- ?? latestSession?.historicalContext?.programProgressionType
2017
- ?? null;
2018
- const phaseLabel = weekPhase ? ` (${weekPhase} week)` : '';
2019
- lines.push(`Current program: ${program.name}, week ${currentWeek}${phaseLabel}, ${program.daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
2020
- if (weekPhase === 'deload') {
2021
- lines.push('Note: This is a planned deload week — reduced volume and intensity are intentional, not a regression.');
2022
- }
2063
+ const daysPerWeek = program.daysPerWeek ?? program.days?.length ?? 'unknown';
2064
+ lines.push(`Current program: ${program.name}, ${daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
2023
2065
 
2024
2066
  const weekStart = startOfCurrentIsoWeek(today);
2025
2067
  const strengthSessionsThisWeek = sessions.filter((session) => {
@@ -2037,30 +2079,21 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
2037
2079
  lines.push(`Last strength session: ${lastStrengthSessionDate}.`);
2038
2080
  }
2039
2081
 
2040
- const latestAdaptation = Array.isArray(program.adaptationEvents) && program.adaptationEvents.length > 0
2041
- ? program.adaptationEvents[0]
2042
- : null;
2043
- if (latestAdaptation?.actionRawValue === 'skipToNextWeek') {
2044
- const adaptedAt = String(latestAdaptation.occurredAt ?? '').slice(0, 10);
2045
- const suffix = adaptedAt ? ` on ${adaptedAt}` : '';
2046
- const adaptationDetails = normalizedNote(latestAdaptation.details);
2047
- const detailsSuffix = adaptationDetails ? ` ${adaptationDetails}` : '';
2048
- lines.push(`Latest program adaptation: Week skipped${suffix}.${detailsSuffix}`);
2082
+ // Days until next session respects iOS catch-up semantics.
2083
+ const schedule = resolveProgramSchedule(program, today);
2084
+ const scheduleLine = formatScheduleLine(schedule);
2085
+ if (scheduleLine) {
2086
+ // workout-summary historically uses "Next session:" lead — preserve when not catching up.
2087
+ if (schedule.state === 'catchUp') {
2088
+ lines.push(scheduleLine);
2089
+ } else if (schedule.state === 'today') {
2090
+ lines.push(`Next session: ${schedule.dayTitle} (today).`);
2091
+ } else {
2092
+ const whenLabel = schedule.daysUntil === 1 ? 'tomorrow' : `in ${schedule.daysUntil} days`;
2093
+ lines.push(`Next session: ${schedule.dayTitle} on ${schedule.scheduledWeekdayName} (${whenLabel}).`);
2094
+ }
2049
2095
  }
2050
-
2051
- // Days until next session
2052
2096
  const currentDayIndex = program.currentDayIndex ?? 0;
2053
- const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
2054
- if (nextSessionWeekday != null) {
2055
- const jsDay = new Date().getDay(); // 0=Sun
2056
- const todayWeekday = jsDay === 0 ? 7 : jsDay; // 1=Mon … 7=Sun
2057
- let daysUntil = nextSessionWeekday - todayWeekday;
2058
- if (daysUntil < 0) daysUntil += 7;
2059
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
2060
- const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
2061
- const nextDayTitle = (program.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
2062
- lines.push(`Next session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
2063
- }
2064
2097
 
2065
2098
  if (recoveryOutcome) {
2066
2099
  lines.push(`Recovery update: ${recoveryOutcome.scheduleLine} ${recoveryOutcome.targetLine} ${recoveryOutcome.nextStepLine}`);
@@ -2883,53 +2916,150 @@ export function getRecords(snapshot, { exercises = [], limit = 15 } = {}) {
2883
2916
  });
2884
2917
  }
2885
2918
 
2886
- export function getIncrementScore(snapshot, { historyDays = 14 } = {}) {
2887
- const raw = snapshot?.incrementScore;
2919
+ function scoreComponentNumber(value) {
2920
+ const num = typeof value === 'number' ? value : value?.score;
2921
+ return typeof num === 'number' && Number.isFinite(num) ? num : null;
2922
+ }
2923
+
2924
+ function scoreDriverLabels(list, limit = 5) {
2925
+ if (!Array.isArray(list)) return [];
2926
+ return list.slice(0, limit).map((d) => d?.label ?? d?.message ?? d?.id ?? d?.driver).filter(Boolean);
2927
+ }
2928
+
2929
+ function normalizeScoreHistory(raw) {
2888
2930
  const history = Array.isArray(raw?.history) ? raw.history : Array.isArray(raw) ? raw : [];
2889
2931
  const latest = raw?.latest ?? history[0] ?? null;
2932
+ const first = history[0] ?? null;
2933
+ const sameFirst = latest && first && (
2934
+ (latest.snapshotAt && first.snapshotAt && latest.snapshotAt === first.snapshotAt) ||
2935
+ (latest === first)
2936
+ );
2937
+ const mergedHistory = latest && sameFirst
2938
+ ? [{ ...first, ...latest }, ...history.slice(1)]
2939
+ : latest
2940
+ ? [latest, ...history]
2941
+ : history;
2942
+ return enrichScoreSnapshots(mergedHistory);
2943
+ }
2944
+
2945
+ export function incrementScoreSummary(snapshot, { historyDays = 14 } = {}) {
2946
+ const raw = snapshot?.incrementScore;
2947
+ const history = normalizeScoreHistory(raw);
2948
+ const latest = history[0] ?? null;
2949
+ const boundedHistoryDays = boundedInteger(historyDays, { defaultValue: 14, min: 1, max: 60 });
2890
2950
 
2891
2951
  if (!latest || typeof latest.score !== 'number') {
2892
- return coachToolResult('get_increment_score', { historyDays }, {
2893
- facts: {},
2952
+ return {
2953
+ available: false,
2954
+ score: null,
2955
+ snapshotAt: null,
2956
+ formulaVersion: null,
2957
+ dataTier: null,
2958
+ components: {},
2959
+ topPositiveDrivers: [],
2960
+ topNegativeDrivers: [],
2961
+ dayOverDayDelta: null,
2962
+ recentTrend: [],
2963
+ dataQualityNotes: ['No Increment Score snapshots found.'],
2894
2964
  missingDataFlags: ['no_increment_score']
2895
- });
2965
+ };
2896
2966
  }
2897
2967
 
2898
2968
  const components = {};
2899
2969
  if (latest.components && typeof latest.components === 'object') {
2900
2970
  for (const [name, value] of Object.entries(latest.components)) {
2901
- const num = typeof value === 'number' ? value : value?.score;
2902
- if (typeof num === 'number') components[name] = num;
2971
+ const num = scoreComponentNumber(value);
2972
+ if (num != null) components[name] = num;
2903
2973
  }
2904
2974
  }
2905
2975
 
2906
- const trimmedHistory = history.slice(0, Math.min(Math.max(Number(historyDays) || 14, 1), 60));
2907
- const recentScores = trimmedHistory
2908
- .map((entry) => (typeof entry?.score === 'number' ? entry.score : null))
2909
- .filter((s) => s != null);
2910
-
2976
+ const trimmedHistory = history.slice(0, boundedHistoryDays);
2911
2977
  const prior = trimmedHistory[1];
2912
2978
  const dayOverDayDelta = (typeof prior?.score === 'number')
2913
2979
  ? latest.score - prior.score
2914
2980
  : null;
2915
2981
 
2916
- const driverLabels = (list) => {
2917
- if (!Array.isArray(list)) return [];
2918
- return list.slice(0, 5).map((d) => d?.label ?? d?.id ?? d?.driver).filter(Boolean);
2982
+ const missingDataFlags = [];
2983
+ const dataQualityNotes = [];
2984
+ if (Object.keys(components).length === 0) {
2985
+ missingDataFlags.push('no_components');
2986
+ dataQualityNotes.push('Component scores are missing for this snapshot.');
2987
+ }
2988
+ if (!latest.dataTier) {
2989
+ missingDataFlags.push('no_data_tier');
2990
+ dataQualityNotes.push('Data tier is missing for this snapshot.');
2991
+ }
2992
+ if (!latest.formulaVersion) {
2993
+ missingDataFlags.push('no_formula_version');
2994
+ dataQualityNotes.push('Formula version is missing for this snapshot.');
2995
+ }
2996
+
2997
+ const recentTrend = trimmedHistory
2998
+ .filter((entry) => typeof entry?.score === 'number')
2999
+ .map((entry) => ({
3000
+ snapshotAt: entry.snapshotAt ?? null,
3001
+ score: entry.score,
3002
+ dataTier: entry.dataTier ?? null,
3003
+ formulaVersion: entry.formulaVersion ?? null
3004
+ }));
3005
+
3006
+ return {
3007
+ available: true,
3008
+ score: latest.score,
3009
+ snapshotAt: latest.snapshotAt ?? null,
3010
+ formulaVersion: latest.formulaVersion ?? null,
3011
+ dataTier: latest.dataTier ?? null,
3012
+ components,
3013
+ topPositiveDrivers: scoreDriverLabels(latest.topPositiveDrivers),
3014
+ topNegativeDrivers: scoreDriverLabels(latest.topNegativeDrivers),
3015
+ dayOverDayDelta,
3016
+ recentTrend,
3017
+ dataQualityNotes,
3018
+ missingDataFlags,
3019
+ scoreBand: latest.scoreBand ?? null,
3020
+ summaryText: latest.summaryText ?? null
2919
3021
  };
3022
+ }
3023
+
3024
+ export function incrementScoreCurrent(snapshot, options = {}) {
3025
+ return incrementScoreSummary(snapshot, options);
3026
+ }
3027
+
3028
+ export function incrementScoreHistory(snapshot, options = {}) {
3029
+ const raw = snapshot?.incrementScore;
3030
+ const history = normalizeScoreHistory(raw);
3031
+ const limit = boundedInteger(options.limit, { defaultValue: 200, min: 1, max: 1000 });
3032
+ const from = options.from ? new Date(options.from) : null;
3033
+ const to = options.to ? new Date(options.to) : null;
3034
+ const filtered = history.filter((entry) => {
3035
+ if (!entry?.snapshotAt) return true;
3036
+ const date = new Date(entry.snapshotAt);
3037
+ if (Number.isNaN(date.getTime())) return true;
3038
+ if (from && !Number.isNaN(from.getTime()) && date < from) return false;
3039
+ if (to && !Number.isNaN(to.getTime()) && date > to) return false;
3040
+ return true;
3041
+ });
3042
+
3043
+ return { snapshots: filtered.slice(0, limit) };
3044
+ }
3045
+
3046
+ export function getIncrementScore(snapshot, { historyDays = 14 } = {}) {
3047
+ const summary = incrementScoreSummary(snapshot, { historyDays });
3048
+
3049
+ if (!summary.available) {
3050
+ return coachToolResult('get_increment_score', { historyDays }, {
3051
+ facts: {},
3052
+ missingDataFlags: summary.missingDataFlags
3053
+ });
3054
+ }
2920
3055
 
2921
3056
  return coachToolResult('get_increment_score', { historyDays }, {
2922
3057
  facts: {
2923
- score: latest.score,
2924
- dataTier: latest.dataTier ?? null,
2925
- components,
2926
- topPositiveDrivers: driverLabels(latest.topPositiveDrivers),
2927
- topNegativeDrivers: driverLabels(latest.topNegativeDrivers),
2928
- dayOverDayDelta,
2929
- recentScores
3058
+ ...summary,
3059
+ recentScores: summary.recentTrend.map((entry) => entry.score)
2930
3060
  },
2931
- sourceTimestamp: latest.snapshotAt ?? null,
2932
- missingDataFlags: Object.keys(components).length === 0 ? ['no_components'] : []
3061
+ sourceTimestamp: summary.snapshotAt,
3062
+ missingDataFlags: summary.missingDataFlags
2933
3063
  });
2934
3064
  }
2935
3065
 
@@ -4004,7 +4134,8 @@ function weeklyActivitySummary(metrics) {
4004
4134
 
4005
4135
  export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
4006
4136
  const lines = [];
4007
- lines.push(`Date: ${new Date().toISOString().slice(0, 10)}`);
4137
+ const today = new Date();
4138
+ lines.push(`Date: ${dateOnly(today)} (${WEEKDAY_NAMES[isoWeekdayOf(today)]})`);
4008
4139
 
4009
4140
  // Training context
4010
4141
  const cutoff = new Date();
@@ -4046,18 +4177,10 @@ export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
4046
4177
  const prog = snapshot.currentProgram ?? activeProgram(snapshot);
4047
4178
  if (prog) {
4048
4179
  lines.push(`Current program: ${prog.name}.`);
4049
- const currentDayIndex = prog.currentDayIndex ?? 0;
4050
- const nextSessionWeekday = (prog.trainingWeekdays ?? [])[currentDayIndex];
4051
- if (nextSessionWeekday != null) {
4052
- const jsDay = new Date().getDay();
4053
- const todayIso = jsDay === 0 ? 7 : jsDay;
4054
- let daysUntil = nextSessionWeekday - todayIso;
4055
- if (daysUntil < 0) daysUntil += 7;
4056
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
4057
- const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
4058
- const nextDayTitle = (prog.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
4059
- lines.push(`Next scheduled session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
4060
- }
4180
+ for (const line of completedThisCycleLines(prog)) lines.push(line);
4181
+ const schedule = resolveProgramSchedule(prog, today);
4182
+ const scheduleLine = formatScheduleLine(schedule);
4183
+ if (scheduleLine) lines.push(scheduleLine);
4061
4184
  }
4062
4185
 
4063
4186
  const vitals = healthSummary(snapshot, 14);
@@ -4308,6 +4431,14 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
4308
4431
  return { ok: true, payload: trainingLoad(snapshot) };
4309
4432
  }
4310
4433
 
4434
+ if (normalizedCommand === 'increment-score-current') {
4435
+ return { ok: true, payload: incrementScoreCurrent(snapshot, options) };
4436
+ }
4437
+
4438
+ if (normalizedCommand === 'increment-score-history') {
4439
+ return { ok: true, payload: incrementScoreHistory(snapshot, options) };
4440
+ }
4441
+
4311
4442
  if (normalizedCommand === 'ask-history') {
4312
4443
  const conversations = Array.isArray(snapshot.askConversations)
4313
4444
  ? snapshot.askConversations
@@ -4347,19 +4478,25 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
4347
4478
  // ---------- Weekly Coach Check-in ----------
4348
4479
  // Builds a rolling 7-day context for the Sunday Coach Check-in.
4349
4480
  // See docs/plans/2026-04-23-001-feat-sunday-coach-checkin-plan-deepened.md.
4350
- export function weeklyCheckinContext(snapshot, accountId) {
4481
+ // `now` (optional, in options) anchors the 7-day window to a caller-provided
4482
+ // reference time instead of real time. The cron uses this to pin the window
4483
+ // to `row.week_start_date` so a late catch-up run still reports the canonical
4484
+ // Sun→Sun week rather than a Tue→Tue rolling slice. Defaults to new Date().
4485
+ export function weeklyCheckinContext(snapshot, accountId, { now: providedNow } = {}) {
4351
4486
  if (!snapshot) return null;
4352
4487
  const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
4353
- const now = new Date();
4488
+ const now = providedNow instanceof Date && !Number.isNaN(providedNow.getTime())
4489
+ ? providedNow
4490
+ : new Date();
4354
4491
  const todayIso = now.toISOString().slice(0, 10);
4355
- const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
4356
- const program = activeProgram(snapshot);
4357
-
4492
+ const cutoff = new Date(now);
4493
+ cutoff.setUTCHours(0, 0, 0, 0);
4494
+ cutoff.setUTCDate(cutoff.getUTCDate() - 7);
4358
4495
  const weekSessions = sessions.filter((s) => {
4359
4496
  const d = completionDateForSession(s);
4360
4497
  if (!d) return false;
4361
4498
  const dt = new Date(d);
4362
- return !Number.isNaN(dt.getTime()) && dt >= cutoff;
4499
+ return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
4363
4500
  });
4364
4501
 
4365
4502
  // Sessions prior to this week for stall/PR comparison.
@@ -4426,6 +4563,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
4426
4563
  }
4427
4564
 
4428
4565
  // Stalled exercises: e1RM hasn't increased in 3+ consecutive weeks.
4566
+ // Bounded by `now` so anchored windows ignore sessions after the reference.
4429
4567
  const stalled = [];
4430
4568
  const byExercise = new Map();
4431
4569
  for (const s of sessions) {
@@ -4433,6 +4571,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
4433
4571
  if (!d) continue;
4434
4572
  const dt = new Date(d);
4435
4573
  if (Number.isNaN(dt.getTime())) continue;
4574
+ if (dt >= now) continue;
4436
4575
  for (const ex of s.exercises ?? []) {
4437
4576
  if (!ex.name) continue;
4438
4577
  let top = 0;
@@ -4470,7 +4609,10 @@ export function weeklyCheckinContext(snapshot, accountId) {
4470
4609
  .slice()
4471
4610
  .filter((e) => e && e.date && Number.isFinite(Number(e.value ?? e.weight)))
4472
4611
  .sort((a, b) => String(a.date).localeCompare(String(b.date)));
4473
- const recent = sorted.filter((e) => new Date(e.date) >= cutoff);
4612
+ const recent = sorted.filter((e) => {
4613
+ const dt = new Date(e.date);
4614
+ return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
4615
+ });
4474
4616
  if (recent.length >= 2) {
4475
4617
  const first = Number(recent[0].value ?? recent[0].weight);
4476
4618
  const last = Number(recent[recent.length - 1].value ?? recent[recent.length - 1].weight);
@@ -4478,38 +4620,6 @@ export function weeklyCheckinContext(snapshot, accountId) {
4478
4620
  }
4479
4621
  }
4480
4622
 
4481
- // Goal trajectory: read from active StrengthPlan liftGoals.
4482
- let goalProgress = [];
4483
- const plans = Array.isArray(snapshot.strengthPlans) ? snapshot.strengthPlans : [];
4484
- const activePlan = program
4485
- ? activeStrengthPlanForProgram(snapshot, program.id)
4486
- : plans.find((p) => p?.status === 'active');
4487
- if (activePlan && Array.isArray(activePlan.liftGoals)) {
4488
- for (const goal of activePlan.liftGoals) {
4489
- const start = Number(goal.startingE1RM ?? 0);
4490
- const target = Number(goal.targetE1RM ?? 0);
4491
- const current = Number(goal.currentBestE1RM ?? 0);
4492
- if (target <= 0 || target === start) continue;
4493
- const pct = Math.max(0, Math.min(100, Math.round(((current - start) / (target - start)) * 100)));
4494
- goalProgress.push({
4495
- exerciseName: goal.exerciseDisplayName ?? goal.exerciseName ?? 'goal',
4496
- progressPercent: pct,
4497
- currentE1RM: Math.round(current * 10) / 10,
4498
- targetE1RM: target,
4499
- finishDate: activePlan.finishDate ?? null,
4500
- });
4501
- }
4502
- }
4503
-
4504
- const programPhase = program
4505
- ? programPhaseWindowContext(
4506
- program,
4507
- activeStrengthPlanForProgram(snapshot, program.id),
4508
- { start: cutoff, end: now },
4509
- now
4510
- )
4511
- : null;
4512
-
4513
4623
  // Prior commitment from typed coach_commitments storage (caller may inject).
4514
4624
  const context = {
4515
4625
  accountId,
@@ -4523,10 +4633,121 @@ export function weeklyCheckinContext(snapshot, accountId) {
4523
4633
  prsThisWeek: prs,
4524
4634
  stalledExercises: stalled.slice(0, 5),
4525
4635
  bodyweightDeltaKg: bodyweightDelta,
4526
- goalProgress,
4527
- programPhase,
4528
4636
  // Placeholder for injection by the handler; not a secret, just coherent.
4529
4637
  priorCommitment: null,
4530
4638
  };
4531
4639
  return context;
4532
4640
  }
4641
+
4642
+ // ---------- Weekly score digest (onemore-3s7j) ----------
4643
+ // Pure derivation: given the existing weekly check-in context (sessions,
4644
+ // volume, adherence, PRs, stalled lifts, bodyweight delta) plus the last week
4645
+ // of score_snapshots rows from the DB, build the row that backs the iOS
4646
+ // digest card. The card is glanceable and rule-based — no LLM call here.
4647
+ //
4648
+ // scoreSnapshots argument: array of { snapshotAt, score, components } rows,
4649
+ // ordered DESC by snapshotAt (i.e. listScoreSnapshots default ordering).
4650
+ export function buildWeeklyScoreDigest(weeklyContext, scoreSnapshots) {
4651
+ if (!weeklyContext) return null;
4652
+ const rows = Array.isArray(scoreSnapshots) ? scoreSnapshots : [];
4653
+ if (rows.length === 0) return null;
4654
+
4655
+ const weekStart = weeklyContext.weekRangeIso?.start;
4656
+ const weekEnd = weeklyContext.weekRangeIso?.end;
4657
+ if (!weekStart || !weekEnd) return null;
4658
+
4659
+ const inWeek = (iso) => {
4660
+ const d = String(iso ?? '').slice(0, 10);
4661
+ return d >= weekStart && d <= weekEnd;
4662
+ };
4663
+
4664
+ const latestInWeek = rows.find((r) => inWeek(r.snapshotAt));
4665
+ if (!latestInWeek) return null;
4666
+
4667
+ // Earliest prior row to compute a delta against. "Prior" means strictly
4668
+ // before the start of the digest week, capped at the most recent such row.
4669
+ const priorRow = rows.find((r) => String(r.snapshotAt ?? '').slice(0, 10) < weekStart);
4670
+
4671
+ const components = latestInWeek.components ?? {};
4672
+ const priorComponents = priorRow?.components ?? {};
4673
+ const componentsDelta = {};
4674
+ const keys = new Set([...Object.keys(components), ...Object.keys(priorComponents)]);
4675
+ for (const key of keys) {
4676
+ const now = scoreComponentNumber(components[key]);
4677
+ const before = scoreComponentNumber(priorComponents[key]);
4678
+ if (Number.isFinite(now) && Number.isFinite(before)) {
4679
+ componentsDelta[key] = Math.round((now - before) * 10) / 10;
4680
+ }
4681
+ }
4682
+
4683
+ const scoreDelta = priorRow && Number.isFinite(Number(priorRow.score))
4684
+ ? Math.round(Number(latestInWeek.score) - Number(priorRow.score))
4685
+ : null;
4686
+
4687
+ const signals = {
4688
+ sessionCount: weeklyContext.sessionCount ?? 0,
4689
+ totalVolume: weeklyContext.totalVolume ?? 0,
4690
+ adherencePct: weeklyContext.adherencePct ?? null,
4691
+ prCount: Array.isArray(weeklyContext.prsThisWeek) ? weeklyContext.prsThisWeek.length : 0,
4692
+ topPr: Array.isArray(weeklyContext.prsThisWeek) && weeklyContext.prsThisWeek.length > 0
4693
+ ? weeklyContext.prsThisWeek[0]
4694
+ : null,
4695
+ topStalledExercise: Array.isArray(weeklyContext.stalledExercises) && weeklyContext.stalledExercises.length > 0
4696
+ ? weeklyContext.stalledExercises[0].exerciseName
4697
+ : null,
4698
+ bodyweightDeltaKg: weeklyContext.bodyweightDeltaKg ?? null,
4699
+ };
4700
+
4701
+ return {
4702
+ weekStart,
4703
+ weekEnd,
4704
+ score: Math.round(Number(latestInWeek.score)),
4705
+ scoreDelta,
4706
+ components,
4707
+ componentsDelta,
4708
+ signals,
4709
+ observation: weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }),
4710
+ sourceSnapshotId: latestInWeek.id != null ? String(latestInWeek.id) : null,
4711
+ };
4712
+ }
4713
+
4714
+ // Rule-based observation. Picks the single most useful sentence for the card.
4715
+ // Order: no sessions logged > biggest negative component drop > top PR >
4716
+ // stalled lift > positive consistency. Templated, never references plan rituals.
4717
+ export function weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }) {
4718
+ const sessionCount = signals?.sessionCount ?? 0;
4719
+ if (sessionCount === 0) {
4720
+ return 'No sessions logged this week.';
4721
+ }
4722
+
4723
+ let worstKey = null;
4724
+ let worstValue = 0;
4725
+ for (const [key, value] of Object.entries(componentsDelta ?? {})) {
4726
+ if (Number.isFinite(value) && value < worstValue) {
4727
+ worstKey = key;
4728
+ worstValue = value;
4729
+ }
4730
+ }
4731
+ if (worstKey && worstValue <= -2) {
4732
+ return `Your ${worstKey} score dropped ${Math.abs(Math.round(worstValue))} this week — biggest drag on your overall score.`;
4733
+ }
4734
+
4735
+ if (signals.topPr?.exerciseName) {
4736
+ const weight = Number(signals.topPr.weight);
4737
+ const reps = Number(signals.topPr.reps);
4738
+ const repStr = Number.isFinite(weight) && Number.isFinite(reps) && weight > 0 && reps > 0
4739
+ ? ` (${weight}×${reps})`
4740
+ : '';
4741
+ return `${signals.topPr.exerciseName} PR this week${repStr}.`;
4742
+ }
4743
+
4744
+ if (signals.topStalledExercise) {
4745
+ return `${signals.topStalledExercise} hasn't moved in a few weeks — the lift dragging your trajectory most.`;
4746
+ }
4747
+
4748
+ if (Number.isFinite(scoreDelta) && scoreDelta >= 3) {
4749
+ return `Score up ${scoreDelta} from last week — keep the rhythm.`;
4750
+ }
4751
+
4752
+ return `${sessionCount} session${sessionCount === 1 ? '' : 's'} logged this week.`;
4753
+ }