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/README.md +0 -3
- package/SKILL.md +57 -0
- package/package.json +4 -2
- package/src/browse.js +7 -1
- package/src/contract.js +57 -21
- package/src/fields.js +41 -0
- package/src/format.js +8 -2
- package/src/lib.js +116 -5
- package/src/mcp.js +60 -4
- package/src/openrouter.js +2 -70
- package/src/queries.js +344 -190
- package/src/remote.js +103 -12
- package/src/summary-evals.js +40 -1
- package/src/sync-service.js +48 -41
- package/src/validate.js +152 -0
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 {
|
|
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
|
|
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 = {
|
|
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
|
|
2001
|
-
//
|
|
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
|
|
2006
|
-
|
|
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
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
if (
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
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
|
|
4043
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
|
4148
|
-
const
|
|
4149
|
-
|
|
4150
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
4462
|
-
|
|
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) =>
|
|
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
|
+
}
|