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/README.md +8 -5
- package/package.json +1 -1
- package/src/auth.js +5 -1
- package/src/contract.js +17 -5
- package/src/format.js +46 -1
- package/src/lib.js +9 -1
- package/src/mcp.js +67 -0
- package/src/openrouter.js +4 -72
- package/src/queries.js +431 -210
- package/src/remote.js +50 -3
- package/src/score-context.js +182 -0
- package/src/summary-evals.js +317 -5
- package/src/sync-service.js +127 -44
- package/src/transport.js +9 -1
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 {
|
|
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
|
|
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 = {
|
|
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
|
|
2000
|
-
//
|
|
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
|
|
2005
|
-
|
|
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
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
if (
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
|
|
2887
|
-
const
|
|
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
|
|
2893
|
-
|
|
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 =
|
|
2902
|
-
if (
|
|
2971
|
+
const num = scoreComponentNumber(value);
|
|
2972
|
+
if (num != null) components[name] = num;
|
|
2903
2973
|
}
|
|
2904
2974
|
}
|
|
2905
2975
|
|
|
2906
|
-
const trimmedHistory = history.slice(0,
|
|
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
|
|
2917
|
-
|
|
2918
|
-
|
|
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
|
-
|
|
2924
|
-
|
|
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:
|
|
2932
|
-
missingDataFlags:
|
|
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
|
-
|
|
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
|
|
4050
|
-
const
|
|
4051
|
-
|
|
4052
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
4356
|
-
|
|
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) =>
|
|
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
|
+
}
|