incremnt 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/queries.js CHANGED
@@ -1,12 +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
4
  import { enrichScoreSnapshots } from './score-context.js';
5
5
 
6
6
  function completionDateForSession(session) {
7
7
  return session.completedAt ?? session.summary?.date ?? session.date;
8
8
  }
9
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
+
10
146
  function normalizedNote(note) {
11
147
  if (typeof note !== 'string') return null;
12
148
  const trimmed = note.trim();
@@ -1101,38 +1237,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1101
1237
  return { original, replacement, count };
1102
1238
  });
1103
1239
 
1104
- let goalProgress = null;
1105
- const plans = snapshot.strengthPlans ?? [];
1106
- const activePlan = plans.find(
1107
- (p) => p.status === 'active' && p.programId === program.id
1108
- );
1109
- if (activePlan) {
1110
- const programExerciseNames = new Set(
1111
- (program.days ?? [])
1112
- .flatMap((day) => day.exercises ?? [])
1113
- .map((exercise) => canonicalExerciseName(exercise.name ?? exercise.exerciseName))
1114
- );
1115
- goalProgress = (activePlan.liftGoals ?? [])
1116
- .filter((g) => {
1117
- if (programExerciseNames.size === 0) return true;
1118
- return programExerciseNames.has(canonicalExerciseName(g.exerciseDisplayName));
1119
- })
1120
- .map((g) => {
1121
- const range = g.targetE1RM - g.startingE1RM;
1122
- const gained = g.currentBestE1RM - g.startingE1RM;
1123
- const progressPct =
1124
- range > 0 ? Math.max(0, Math.round((gained / range) * 100)) : null;
1125
- return {
1126
- exerciseName: g.exerciseDisplayName,
1127
- progressPercent: progressPct,
1128
- currentBestE1RM: g.currentBestE1RM,
1129
- targetE1RM: g.targetE1RM,
1130
- goalAdjustmentAction: g.goalAdjustmentAction ?? null,
1131
- goalAdjustedAt: g.goalAdjustedAt ?? null
1132
- };
1133
- });
1134
- }
1135
-
1136
1240
  const matchingSummary = programCycleSummaries[0] ?? null;
1137
1241
 
1138
1242
  const progressionDecisions = (matchingSummary?.progressionUpdates ?? []).map((u) => ({
@@ -1236,39 +1340,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1236
1340
  actionability: 9
1237
1341
  });
1238
1342
  }
1239
- if (goalProgress?.length > 0) {
1240
- const lagging = goalProgress.filter((g) => g.progressPercent != null && g.progressPercent < 40);
1241
- if (lagging.length > 0) {
1242
- cycleSignals.push({
1243
- id: 'goal-lagging',
1244
- category: 'goals',
1245
- summary: `${lagging.length} goal${lagging.length === 1 ? '' : 's'} under 40% progress`,
1246
- detail: lagging.map((g) => `${g.exerciseName} ${g.progressPercent}%`).join(', '),
1247
- impact: 9,
1248
- confidence: 8,
1249
- novelty: 6,
1250
- actionability: 9
1251
- });
1252
- }
1253
- }
1254
-
1255
- // Phase-window context (Step 9b of the deload-week unification plan): explicit
1256
- // structured phase facts so prompt builders / models never have to infer
1257
- // "is this a deload week?" from session prose.
1258
- const phaseRangeStart = cycleSessions[0]?.completedAt ?? cycleSessions[0]?.date ?? null;
1259
- const phaseRangeEnd = cycleSessions[cycleSessions.length - 1]?.completedAt
1260
- ?? cycleSessions[cycleSessions.length - 1]?.date
1261
- ?? null;
1262
- const summaryRange = phaseRangeStart && phaseRangeEnd
1263
- ? { start: phaseRangeStart, end: phaseRangeEnd }
1264
- : null;
1265
- const programPhase = programPhaseWindowContext(
1266
- program,
1267
- activeStrengthPlanForProgram(snapshot, program.id),
1268
- summaryRange,
1269
- new Date()
1270
- );
1271
-
1272
1343
  return {
1273
1344
  programName: program.name,
1274
1345
  cycleNumber: cycleWeekNumber,
@@ -1276,7 +1347,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1276
1347
  sessions,
1277
1348
  prsThisCycle,
1278
1349
  bwPrsThisCycle,
1279
- goalProgress,
1280
1350
  progressionDecisions,
1281
1351
  setCompletionRate,
1282
1352
  swapPatterns,
@@ -1291,7 +1361,6 @@ export function cycleSummaryContext(snapshot, programId, { exclude = new Set() }
1291
1361
  avgSleepMins,
1292
1362
  latestBodyWeightKg,
1293
1363
  prioritySignals: rankPrioritySignals(cycleSignals),
1294
- programPhase,
1295
1364
  excludeNote: buildExcludeNote(exclude)
1296
1365
  };
1297
1366
  }
@@ -1373,26 +1442,12 @@ export function checkpointContext(snapshot, programId, checkpointWeek, { exclude
1373
1442
  .slice(0, 3);
1374
1443
  const previousCycleNotes = programCycleSummaries.map((cs) => cs.aiSummary);
1375
1444
 
1376
- // Phase-window context (Step 9b). Scoped to a 14-day window ending today
1377
- // so phasesInRange covers "current week" + "previous week" — enough for
1378
- // the model to spot post-deload-return / pre-deload patterns without
1379
- // bloating the prompt with the entire plan timeline.
1380
- const checkpointToday = new Date();
1381
- const checkpointStart = new Date(checkpointToday.getTime() - 14 * 24 * 60 * 60 * 1000);
1382
- const programPhase = programPhaseWindowContext(
1383
- program,
1384
- activeStrengthPlanForProgram(snapshot, program.id),
1385
- { start: checkpointStart, end: checkpointToday },
1386
- checkpointToday
1387
- );
1388
-
1389
1445
  return {
1390
1446
  programName: program.name,
1391
1447
  checkpointWeek,
1392
1448
  totalWeeks,
1393
1449
  exercises,
1394
1450
  previousCycleNotes,
1395
- programPhase,
1396
1451
  excludeNote: buildExcludeNote(exclude)
1397
1452
  };
1398
1453
  }
@@ -1402,6 +1457,7 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
1402
1457
  const session = sessions.find((s) => s.id === sessionId);
1403
1458
  if (!session) return null;
1404
1459
 
1460
+ const today = new Date();
1405
1461
  const sessionDate = completionDateForSession(session);
1406
1462
  const dayName = session.dayName ?? 'Session';
1407
1463
  const earlierSessions = sessions
@@ -1715,14 +1771,16 @@ export function workoutSummaryContext(snapshot, sessionId, { exclude = new Set()
1715
1771
  const nextExercises = ((program.days ?? [])[currentDayIndex]?.exercises ?? [])
1716
1772
  .map(ex => ex.exerciseName ?? ex.name)
1717
1773
  .filter(Boolean);
1718
- const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
1719
- let weekdayName = null;
1720
- if (nextSessionWeekday != null) {
1721
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
1722
- weekdayName = dayNames[nextSessionWeekday] ?? null;
1723
- }
1774
+ const schedule = resolveProgramSchedule(program, today);
1724
1775
  if (nextDayTitle) {
1725
- 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
+ };
1726
1784
  }
1727
1785
  }
1728
1786
  }
@@ -1997,30 +2055,13 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
1997
2055
  lines.push(`Recent frequency: ${perWeek} sessions/week (last 4 weeks).`);
1998
2056
  }
1999
2057
 
2000
- // Current program + week phase. Guided programs use ProgramPhaseResolver so
2001
- // 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.
2002
2060
  const program = activeProgram(snapshot);
2003
2061
  if (program) {
2004
2062
  const recoveryOutcome = exclude.has('recovery') ? null : deriveRecoveryOutcome(snapshot, program);
2005
- const programSessions = sessions
2006
- .filter((s) => s.programId === program.id && s.historicalContext?.programWeekNumber)
2007
- .sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))));
2008
- const latestSession = programSessions[0];
2009
- const phase = resolveCurrentProgramPhase(snapshot, program, today);
2010
- const currentWeek = phase?.displayWeek
2011
- ?? Math.max(
2012
- Number(program.completedCyclesCount ?? 0) + 1,
2013
- Number(latestSession?.historicalContext?.programWeekNumber ?? 0),
2014
- 1
2015
- );
2016
- const weekPhase = phase?.phase
2017
- ?? latestSession?.historicalContext?.programProgressionType
2018
- ?? null;
2019
- const phaseLabel = weekPhase ? ` (${weekPhase} week)` : '';
2020
- lines.push(`Current program: ${program.name}, week ${currentWeek}${phaseLabel}, ${program.daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
2021
- if (weekPhase === 'deload') {
2022
- lines.push('Note: This is a planned deload week — reduced volume and intensity are intentional, not a regression.');
2023
- }
2063
+ const daysPerWeek = program.daysPerWeek ?? program.days?.length ?? 'unknown';
2064
+ lines.push(`Current program: ${program.name}, ${daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
2024
2065
 
2025
2066
  const weekStart = startOfCurrentIsoWeek(today);
2026
2067
  const strengthSessionsThisWeek = sessions.filter((session) => {
@@ -2038,30 +2079,21 @@ export function askContext(snapshot, { exclude = new Set() } = {}) {
2038
2079
  lines.push(`Last strength session: ${lastStrengthSessionDate}.`);
2039
2080
  }
2040
2081
 
2041
- const latestAdaptation = Array.isArray(program.adaptationEvents) && program.adaptationEvents.length > 0
2042
- ? program.adaptationEvents[0]
2043
- : null;
2044
- if (latestAdaptation?.actionRawValue === 'skipToNextWeek') {
2045
- const adaptedAt = String(latestAdaptation.occurredAt ?? '').slice(0, 10);
2046
- const suffix = adaptedAt ? ` on ${adaptedAt}` : '';
2047
- const adaptationDetails = normalizedNote(latestAdaptation.details);
2048
- const detailsSuffix = adaptationDetails ? ` ${adaptationDetails}` : '';
2049
- 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
+ }
2050
2095
  }
2051
-
2052
- // Days until next session
2053
2096
  const currentDayIndex = program.currentDayIndex ?? 0;
2054
- const nextSessionWeekday = (program.trainingWeekdays ?? [])[currentDayIndex];
2055
- if (nextSessionWeekday != null) {
2056
- const jsDay = new Date().getDay(); // 0=Sun
2057
- const todayWeekday = jsDay === 0 ? 7 : jsDay; // 1=Mon … 7=Sun
2058
- let daysUntil = nextSessionWeekday - todayWeekday;
2059
- if (daysUntil < 0) daysUntil += 7;
2060
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
2061
- const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
2062
- const nextDayTitle = (program.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
2063
- lines.push(`Next session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
2064
- }
2065
2097
 
2066
2098
  if (recoveryOutcome) {
2067
2099
  lines.push(`Recovery update: ${recoveryOutcome.scheduleLine} ${recoveryOutcome.targetLine} ${recoveryOutcome.nextStepLine}`);
@@ -4039,12 +4071,51 @@ export function healthSummary(snapshot, days = 14) {
4039
4071
  nights: recentSleep.length
4040
4072
  },
4041
4073
  bodyWeight: (() => {
4042
- const recent = (metrics.bodyWeight ?? []).filter((m) => m.date >= cutoff);
4043
- if (recent.length === 0) return { latest: null, readings: 0 };
4074
+ const profileWeightKg = Number(snapshot.user?.weightKg);
4075
+ const resolvedProfileWeightKg = Number.isFinite(profileWeightKg) && profileWeightKg > 0
4076
+ ? Math.round(profileWeightKg * 10) / 10
4077
+ : null;
4078
+ const rows = (metrics.bodyWeight ?? [])
4079
+ .filter((m) => m?.date && Number.isFinite(Number(m.value ?? m.weight)))
4080
+ .map((m) => ({
4081
+ date: String(m.date).slice(0, 10),
4082
+ value: Number(m.value ?? m.weight)
4083
+ }))
4084
+ .sort((a, b) => a.date.localeCompare(b.date));
4085
+ const recent = rows.filter((m) => m.date >= cutoff);
4086
+ if (recent.length === 0 && resolvedProfileWeightKg != null) {
4087
+ return {
4088
+ latest: { value: resolvedProfileWeightKg, date: null, source: 'profile', stale: false },
4089
+ trend: null,
4090
+ readings: 0,
4091
+ totalReadings: rows.length
4092
+ };
4093
+ }
4094
+ if (recent.length === 0) {
4095
+ const latestKnown = rows.at(-1);
4096
+ if (!latestKnown) return { latest: null, readings: 0, totalReadings: 0 };
4097
+ return {
4098
+ latest: {
4099
+ value: Math.round(latestKnown.value * 10) / 10,
4100
+ date: latestKnown.date,
4101
+ source: 'healthkit',
4102
+ stale: true
4103
+ },
4104
+ trend: null,
4105
+ readings: 0,
4106
+ totalReadings: rows.length
4107
+ };
4108
+ }
4044
4109
  return {
4045
- latest: { value: Math.round(recent.at(-1).value * 10) / 10, date: recent.at(-1).date },
4110
+ latest: {
4111
+ value: Math.round(recent.at(-1).value * 10) / 10,
4112
+ date: recent.at(-1).date,
4113
+ source: 'healthkit',
4114
+ stale: false
4115
+ },
4046
4116
  trend: Math.round((recent.at(-1).value - recent[0].value) * 10) / 10,
4047
- readings: recent.length
4117
+ readings: recent.length,
4118
+ totalReadings: rows.length
4048
4119
  };
4049
4120
  })(),
4050
4121
  respiratoryRate: (() => {
@@ -4102,7 +4173,8 @@ function weeklyActivitySummary(metrics) {
4102
4173
 
4103
4174
  export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
4104
4175
  const lines = [];
4105
- lines.push(`Date: ${new Date().toISOString().slice(0, 10)}`);
4176
+ const today = new Date();
4177
+ lines.push(`Date: ${dateOnly(today)} (${WEEKDAY_NAMES[isoWeekdayOf(today)]})`);
4106
4178
 
4107
4179
  // Training context
4108
4180
  const cutoff = new Date();
@@ -4144,18 +4216,10 @@ export function vitalsSummaryContext(snapshot, { exclude = new Set() } = {}) {
4144
4216
  const prog = snapshot.currentProgram ?? activeProgram(snapshot);
4145
4217
  if (prog) {
4146
4218
  lines.push(`Current program: ${prog.name}.`);
4147
- const currentDayIndex = prog.currentDayIndex ?? 0;
4148
- const nextSessionWeekday = (prog.trainingWeekdays ?? [])[currentDayIndex];
4149
- if (nextSessionWeekday != null) {
4150
- const jsDay = new Date().getDay();
4151
- const todayIso = jsDay === 0 ? 7 : jsDay;
4152
- let daysUntil = nextSessionWeekday - todayIso;
4153
- if (daysUntil < 0) daysUntil += 7;
4154
- const dayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
4155
- const whenLabel = daysUntil === 0 ? 'today' : daysUntil === 1 ? 'tomorrow' : `in ${daysUntil} days`;
4156
- const nextDayTitle = (prog.days ?? [])[currentDayIndex]?.title ?? `Day ${currentDayIndex + 1}`;
4157
- lines.push(`Next scheduled session: ${nextDayTitle} on ${dayNames[nextSessionWeekday]} (${whenLabel}).`);
4158
- }
4219
+ for (const line of completedThisCycleLines(prog)) lines.push(line);
4220
+ const schedule = resolveProgramSchedule(prog, today);
4221
+ const scheduleLine = formatScheduleLine(schedule);
4222
+ if (scheduleLine) lines.push(scheduleLine);
4159
4223
  }
4160
4224
 
4161
4225
  const vitals = healthSummary(snapshot, 14);
@@ -4453,19 +4517,25 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
4453
4517
  // ---------- Weekly Coach Check-in ----------
4454
4518
  // Builds a rolling 7-day context for the Sunday Coach Check-in.
4455
4519
  // See docs/plans/2026-04-23-001-feat-sunday-coach-checkin-plan-deepened.md.
4456
- export function weeklyCheckinContext(snapshot, accountId) {
4520
+ // `now` (optional, in options) anchors the 7-day window to a caller-provided
4521
+ // reference time instead of real time. The cron uses this to pin the window
4522
+ // to `row.week_start_date` so a late catch-up run still reports the canonical
4523
+ // Sun→Sun week rather than a Tue→Tue rolling slice. Defaults to new Date().
4524
+ export function weeklyCheckinContext(snapshot, accountId, { now: providedNow } = {}) {
4457
4525
  if (!snapshot) return null;
4458
4526
  const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
4459
- const now = new Date();
4527
+ const now = providedNow instanceof Date && !Number.isNaN(providedNow.getTime())
4528
+ ? providedNow
4529
+ : new Date();
4460
4530
  const todayIso = now.toISOString().slice(0, 10);
4461
- const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
4462
- const program = activeProgram(snapshot);
4463
-
4531
+ const cutoff = new Date(now);
4532
+ cutoff.setUTCHours(0, 0, 0, 0);
4533
+ cutoff.setUTCDate(cutoff.getUTCDate() - 7);
4464
4534
  const weekSessions = sessions.filter((s) => {
4465
4535
  const d = completionDateForSession(s);
4466
4536
  if (!d) return false;
4467
4537
  const dt = new Date(d);
4468
- return !Number.isNaN(dt.getTime()) && dt >= cutoff;
4538
+ return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
4469
4539
  });
4470
4540
 
4471
4541
  // Sessions prior to this week for stall/PR comparison.
@@ -4532,6 +4602,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
4532
4602
  }
4533
4603
 
4534
4604
  // Stalled exercises: e1RM hasn't increased in 3+ consecutive weeks.
4605
+ // Bounded by `now` so anchored windows ignore sessions after the reference.
4535
4606
  const stalled = [];
4536
4607
  const byExercise = new Map();
4537
4608
  for (const s of sessions) {
@@ -4539,6 +4610,7 @@ export function weeklyCheckinContext(snapshot, accountId) {
4539
4610
  if (!d) continue;
4540
4611
  const dt = new Date(d);
4541
4612
  if (Number.isNaN(dt.getTime())) continue;
4613
+ if (dt >= now) continue;
4542
4614
  for (const ex of s.exercises ?? []) {
4543
4615
  if (!ex.name) continue;
4544
4616
  let top = 0;
@@ -4576,7 +4648,10 @@ export function weeklyCheckinContext(snapshot, accountId) {
4576
4648
  .slice()
4577
4649
  .filter((e) => e && e.date && Number.isFinite(Number(e.value ?? e.weight)))
4578
4650
  .sort((a, b) => String(a.date).localeCompare(String(b.date)));
4579
- const recent = sorted.filter((e) => new Date(e.date) >= cutoff);
4651
+ const recent = sorted.filter((e) => {
4652
+ const dt = new Date(e.date);
4653
+ return !Number.isNaN(dt.getTime()) && dt >= cutoff && dt < now;
4654
+ });
4580
4655
  if (recent.length >= 2) {
4581
4656
  const first = Number(recent[0].value ?? recent[0].weight);
4582
4657
  const last = Number(recent[recent.length - 1].value ?? recent[recent.length - 1].weight);
@@ -4584,38 +4659,6 @@ export function weeklyCheckinContext(snapshot, accountId) {
4584
4659
  }
4585
4660
  }
4586
4661
 
4587
- // Goal trajectory: read from active StrengthPlan liftGoals.
4588
- let goalProgress = [];
4589
- const plans = Array.isArray(snapshot.strengthPlans) ? snapshot.strengthPlans : [];
4590
- const activePlan = program
4591
- ? activeStrengthPlanForProgram(snapshot, program.id)
4592
- : plans.find((p) => p?.status === 'active');
4593
- if (activePlan && Array.isArray(activePlan.liftGoals)) {
4594
- for (const goal of activePlan.liftGoals) {
4595
- const start = Number(goal.startingE1RM ?? 0);
4596
- const target = Number(goal.targetE1RM ?? 0);
4597
- const current = Number(goal.currentBestE1RM ?? 0);
4598
- if (target <= 0 || target === start) continue;
4599
- const pct = Math.max(0, Math.min(100, Math.round(((current - start) / (target - start)) * 100)));
4600
- goalProgress.push({
4601
- exerciseName: goal.exerciseDisplayName ?? goal.exerciseName ?? 'goal',
4602
- progressPercent: pct,
4603
- currentE1RM: Math.round(current * 10) / 10,
4604
- targetE1RM: target,
4605
- finishDate: activePlan.finishDate ?? null,
4606
- });
4607
- }
4608
- }
4609
-
4610
- const programPhase = program
4611
- ? programPhaseWindowContext(
4612
- program,
4613
- activeStrengthPlanForProgram(snapshot, program.id),
4614
- { start: cutoff, end: now },
4615
- now
4616
- )
4617
- : null;
4618
-
4619
4662
  // Prior commitment from typed coach_commitments storage (caller may inject).
4620
4663
  const context = {
4621
4664
  accountId,
@@ -4629,10 +4672,121 @@ export function weeklyCheckinContext(snapshot, accountId) {
4629
4672
  prsThisWeek: prs,
4630
4673
  stalledExercises: stalled.slice(0, 5),
4631
4674
  bodyweightDeltaKg: bodyweightDelta,
4632
- goalProgress,
4633
- programPhase,
4634
4675
  // Placeholder for injection by the handler; not a secret, just coherent.
4635
4676
  priorCommitment: null,
4636
4677
  };
4637
4678
  return context;
4638
4679
  }
4680
+
4681
+ // ---------- Weekly score digest (onemore-3s7j) ----------
4682
+ // Pure derivation: given the existing weekly check-in context (sessions,
4683
+ // volume, adherence, PRs, stalled lifts, bodyweight delta) plus the last week
4684
+ // of score_snapshots rows from the DB, build the row that backs the iOS
4685
+ // digest card. The card is glanceable and rule-based — no LLM call here.
4686
+ //
4687
+ // scoreSnapshots argument: array of { snapshotAt, score, components } rows,
4688
+ // ordered DESC by snapshotAt (i.e. listScoreSnapshots default ordering).
4689
+ export function buildWeeklyScoreDigest(weeklyContext, scoreSnapshots) {
4690
+ if (!weeklyContext) return null;
4691
+ const rows = Array.isArray(scoreSnapshots) ? scoreSnapshots : [];
4692
+ if (rows.length === 0) return null;
4693
+
4694
+ const weekStart = weeklyContext.weekRangeIso?.start;
4695
+ const weekEnd = weeklyContext.weekRangeIso?.end;
4696
+ if (!weekStart || !weekEnd) return null;
4697
+
4698
+ const inWeek = (iso) => {
4699
+ const d = String(iso ?? '').slice(0, 10);
4700
+ return d >= weekStart && d <= weekEnd;
4701
+ };
4702
+
4703
+ const latestInWeek = rows.find((r) => inWeek(r.snapshotAt));
4704
+ if (!latestInWeek) return null;
4705
+
4706
+ // Earliest prior row to compute a delta against. "Prior" means strictly
4707
+ // before the start of the digest week, capped at the most recent such row.
4708
+ const priorRow = rows.find((r) => String(r.snapshotAt ?? '').slice(0, 10) < weekStart);
4709
+
4710
+ const components = latestInWeek.components ?? {};
4711
+ const priorComponents = priorRow?.components ?? {};
4712
+ const componentsDelta = {};
4713
+ const keys = new Set([...Object.keys(components), ...Object.keys(priorComponents)]);
4714
+ for (const key of keys) {
4715
+ const now = scoreComponentNumber(components[key]);
4716
+ const before = scoreComponentNumber(priorComponents[key]);
4717
+ if (Number.isFinite(now) && Number.isFinite(before)) {
4718
+ componentsDelta[key] = Math.round((now - before) * 10) / 10;
4719
+ }
4720
+ }
4721
+
4722
+ const scoreDelta = priorRow && Number.isFinite(Number(priorRow.score))
4723
+ ? Math.round(Number(latestInWeek.score) - Number(priorRow.score))
4724
+ : null;
4725
+
4726
+ const signals = {
4727
+ sessionCount: weeklyContext.sessionCount ?? 0,
4728
+ totalVolume: weeklyContext.totalVolume ?? 0,
4729
+ adherencePct: weeklyContext.adherencePct ?? null,
4730
+ prCount: Array.isArray(weeklyContext.prsThisWeek) ? weeklyContext.prsThisWeek.length : 0,
4731
+ topPr: Array.isArray(weeklyContext.prsThisWeek) && weeklyContext.prsThisWeek.length > 0
4732
+ ? weeklyContext.prsThisWeek[0]
4733
+ : null,
4734
+ topStalledExercise: Array.isArray(weeklyContext.stalledExercises) && weeklyContext.stalledExercises.length > 0
4735
+ ? weeklyContext.stalledExercises[0].exerciseName
4736
+ : null,
4737
+ bodyweightDeltaKg: weeklyContext.bodyweightDeltaKg ?? null,
4738
+ };
4739
+
4740
+ return {
4741
+ weekStart,
4742
+ weekEnd,
4743
+ score: Math.round(Number(latestInWeek.score)),
4744
+ scoreDelta,
4745
+ components,
4746
+ componentsDelta,
4747
+ signals,
4748
+ observation: weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }),
4749
+ sourceSnapshotId: latestInWeek.id != null ? String(latestInWeek.id) : null,
4750
+ };
4751
+ }
4752
+
4753
+ // Rule-based observation. Picks the single most useful sentence for the card.
4754
+ // Order: no sessions logged > biggest negative component drop > top PR >
4755
+ // stalled lift > positive consistency. Templated, never references plan rituals.
4756
+ export function weeklyScoreDigestObservation({ signals, componentsDelta, scoreDelta }) {
4757
+ const sessionCount = signals?.sessionCount ?? 0;
4758
+ if (sessionCount === 0) {
4759
+ return 'No sessions logged this week.';
4760
+ }
4761
+
4762
+ let worstKey = null;
4763
+ let worstValue = 0;
4764
+ for (const [key, value] of Object.entries(componentsDelta ?? {})) {
4765
+ if (Number.isFinite(value) && value < worstValue) {
4766
+ worstKey = key;
4767
+ worstValue = value;
4768
+ }
4769
+ }
4770
+ if (worstKey && worstValue <= -2) {
4771
+ return `Your ${worstKey} score dropped ${Math.abs(Math.round(worstValue))} this week — biggest drag on your overall score.`;
4772
+ }
4773
+
4774
+ if (signals.topPr?.exerciseName) {
4775
+ const weight = Number(signals.topPr.weight);
4776
+ const reps = Number(signals.topPr.reps);
4777
+ const repStr = Number.isFinite(weight) && Number.isFinite(reps) && weight > 0 && reps > 0
4778
+ ? ` (${weight}×${reps})`
4779
+ : '';
4780
+ return `${signals.topPr.exerciseName} PR this week${repStr}.`;
4781
+ }
4782
+
4783
+ if (signals.topStalledExercise) {
4784
+ return `${signals.topStalledExercise} hasn't moved in a few weeks — the lift dragging your trajectory most.`;
4785
+ }
4786
+
4787
+ if (Number.isFinite(scoreDelta) && scoreDelta >= 3) {
4788
+ return `Score up ${scoreDelta} from last week — keep the rhythm.`;
4789
+ }
4790
+
4791
+ return `${sessionCount} session${sessionCount === 1 ? '' : 's'} logged this week.`;
4792
+ }