incremnt 0.3.0 → 0.5.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.
@@ -0,0 +1,822 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const INCREMENT_SCORE_REPLAY_VERSION = 'increment-score-replay-v0.1';
4
+ export const PROVISIONAL_FORMULA_VERSION = 'increment-score-provisional-v0.1';
5
+
6
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
7
+ const SCORE_WEIGHTS = Object.freeze({
8
+ stimulus: 0.4,
9
+ progression: 0.2,
10
+ recovery: 0.2,
11
+ coverage: 0.15,
12
+ execution: 0.05
13
+ });
14
+
15
+ const TARGET_SETS = Object.freeze({
16
+ chest: { label: 'Chest', target: 8 },
17
+ back: { label: 'Back', target: 8 },
18
+ quads: { label: 'Quads', target: 6 },
19
+ posterior: { label: 'Posterior', target: 6 },
20
+ shoulders: { label: 'Shoulders', target: 6 },
21
+ arms: { label: 'Arms', target: 6 }
22
+ });
23
+
24
+ const EXPORTED_REPLAY_INPUT_KIND = 'incrementScoreReplayInput';
25
+
26
+ const MUSCLE_ALIASES = new Map([
27
+ ['chest', 'chest'],
28
+ ['back', 'back'],
29
+ ['lats', 'back'],
30
+ ['upper back', 'back'],
31
+ ['quads', 'quads'],
32
+ ['quadriceps', 'quads'],
33
+ ['legs', 'quads'],
34
+ ['hamstrings', 'posterior'],
35
+ ['glutes', 'posterior'],
36
+ ['posterior chain', 'posterior'],
37
+ ['shoulders', 'shoulders'],
38
+ ['delts', 'shoulders'],
39
+ ['biceps', 'arms'],
40
+ ['triceps', 'arms'],
41
+ ['arms', 'arms']
42
+ ]);
43
+
44
+ function clamp(value, min = 0, max = 100) {
45
+ if (!Number.isFinite(value)) return min;
46
+ return Math.max(min, Math.min(max, value));
47
+ }
48
+
49
+ function round(value, places = 0) {
50
+ const scale = 10 ** places;
51
+ return Math.round(value * scale) / scale;
52
+ }
53
+
54
+ function parseDateKey(value) {
55
+ if (!value) return null;
56
+ const raw = String(value);
57
+ if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10);
58
+ const parsed = new Date(raw);
59
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
60
+ }
61
+
62
+ function dateFromKey(dateKey) {
63
+ return new Date(`${dateKey}T00:00:00.000Z`);
64
+ }
65
+
66
+ function addDays(dateKey, days) {
67
+ const date = dateFromKey(dateKey);
68
+ date.setUTCDate(date.getUTCDate() + days);
69
+ return date.toISOString().slice(0, 10);
70
+ }
71
+
72
+ function daysBetween(startDateKey, endDateKey) {
73
+ return Math.round((dateFromKey(endDateKey) - dateFromKey(startDateKey)) / MS_PER_DAY);
74
+ }
75
+
76
+ function dateRange(startDateKey, endDateKey) {
77
+ const dates = [];
78
+ for (let cursor = startDateKey; cursor <= endDateKey; cursor = addDays(cursor, 1)) {
79
+ dates.push(cursor);
80
+ }
81
+ return dates;
82
+ }
83
+
84
+ function weekStart(dateKey) {
85
+ const date = dateFromKey(dateKey);
86
+ const day = date.getUTCDay() || 7;
87
+ date.setUTCDate(date.getUTCDate() - day + 1);
88
+ return date.toISOString().slice(0, 10);
89
+ }
90
+
91
+ function weekEnd(dateKey) {
92
+ return addDays(weekStart(dateKey), 6);
93
+ }
94
+
95
+ function completedAtForSession(session) {
96
+ return session.completedAt ?? session.summary?.date ?? session.date ?? null;
97
+ }
98
+
99
+ function normalizeExerciseName(name) {
100
+ return String(name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
101
+ }
102
+
103
+ function normalizeMuscleGroup(value) {
104
+ const normalized = String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
105
+ return MUSCLE_ALIASES.get(normalized) ?? normalized;
106
+ }
107
+
108
+ function e1rmForSet(set) {
109
+ const weight = Number(set?.weight);
110
+ const reps = Number(set?.reps);
111
+ if (!Number.isFinite(weight) || !Number.isFinite(reps) || reps <= 0) return null;
112
+ if (weight <= 0) return reps;
113
+ return weight * (1 + reps / 30);
114
+ }
115
+
116
+ function completedSetsForExercise(exercise) {
117
+ return (exercise.sets ?? [])
118
+ .filter((set) => set?.isComplete !== false)
119
+ .map((set) => ({
120
+ weight: Number(set.weight ?? 0),
121
+ reps: Number(set.reps ?? 0),
122
+ rir: set.rir ?? exercise.rir ?? null,
123
+ estimatedOneRM: e1rmForSet(set)
124
+ }))
125
+ .filter((set) => set.reps > 0 && set.estimatedOneRM != null);
126
+ }
127
+
128
+ function completedSetsForExportedExercise(exercise) {
129
+ return (exercise.sets ?? [])
130
+ .filter((set) => set?.isComplete !== false)
131
+ .map((set) => {
132
+ const normalized = {
133
+ weight: Number(set.weightKg ?? set.weight ?? 0),
134
+ reps: Number(set.reps ?? 0),
135
+ rir: set.rir ?? null
136
+ };
137
+ return {
138
+ ...normalized,
139
+ estimatedOneRM: e1rmForSet(normalized)
140
+ };
141
+ })
142
+ .filter((set) => set.reps > 0 && set.estimatedOneRM != null);
143
+ }
144
+
145
+ function exportedExerciseName(exercise) {
146
+ if (exercise.customExercise) {
147
+ return exercise.customExerciseHash
148
+ ? `Custom exercise ${String(exercise.customExerciseHash).slice(0, 8)}`
149
+ : 'Custom exercise';
150
+ }
151
+ return exercise.canonicalName ?? exercise.name ?? exercise.exerciseName ?? 'Unknown Exercise';
152
+ }
153
+
154
+ function normalizeExportedReplayInput(payload, options = {}) {
155
+ const sessions = (payload?.sessions ?? [])
156
+ .map((session) => {
157
+ const date = parseDateKey(session.date ?? session.completedAt);
158
+ if (!date) return null;
159
+
160
+ const plannedSetCounts = new Map((session.prescription?.plannedExercises ?? [])
161
+ .map((exercise) => [
162
+ exercise.canonicalName ?? exercise.exerciseName ?? exercise.name,
163
+ Number(exercise.plannedSets ?? 0)
164
+ ])
165
+ .filter(([key, count]) => key && Number.isFinite(count) && count > 0));
166
+
167
+ const exercises = (session.exercises ?? []).map((exercise) => {
168
+ const primaryMuscle = normalizeMuscleGroup(exercise.muscleGroup);
169
+ const name = exportedExerciseName(exercise);
170
+ return {
171
+ name,
172
+ exerciseKey: String(exercise.canonicalName ?? exercise.customExerciseHash ?? normalizeExerciseName(name)),
173
+ primaryMuscle,
174
+ muscleLabel: TARGET_SETS[primaryMuscle]?.label ?? exercise.muscleGroup ?? primaryMuscle ?? 'Unknown',
175
+ completedSets: completedSetsForExportedExercise(exercise),
176
+ plannedSetCount: plannedSetCounts.get(exercise.canonicalName) ?? (Array.isArray(exercise.sets) ? exercise.sets.length : null)
177
+ };
178
+ });
179
+
180
+ const completedSetCount = exercises.reduce((sum, exercise) => sum + exercise.completedSets.length, 0);
181
+ const plannedSetCount = exercises.reduce((sum, exercise) => {
182
+ if (exercise.plannedSetCount == null) return sum;
183
+ return sum + exercise.plannedSetCount;
184
+ }, 0);
185
+
186
+ return {
187
+ sessionId: session.id ?? null,
188
+ date,
189
+ completedAt: session.completedAt ?? session.date ?? null,
190
+ dayName: null,
191
+ completedSetCount,
192
+ plannedSetCount,
193
+ emptyStrengthSession: completedSetCount === 0,
194
+ exercises
195
+ };
196
+ })
197
+ .filter(Boolean)
198
+ .sort((left, right) => String(left.completedAt ?? left.date).localeCompare(String(right.completedAt ?? right.date)));
199
+
200
+ const dates = sessions.map((session) => session.date);
201
+ const fallbackDate = parseDateKey(options.asOf) ?? new Date().toISOString().slice(0, 10);
202
+ const from = options.from ?? payload?.replayWindow?.startDate ?? dates[0] ?? fallbackDate;
203
+ const to = options.to ?? payload?.replayWindow?.endDate ?? dates.at(-1) ?? from;
204
+ const accountAlias = payload?.source?.accountAlias;
205
+
206
+ return {
207
+ replayVersion: INCREMENT_SCORE_REPLAY_VERSION,
208
+ formulaVersion: options.formulaVersion ?? payload?.formulaVersion ?? PROVISIONAL_FORMULA_VERSION,
209
+ source: options.source ?? payload?.source?.type ?? 'replay-input',
210
+ extractedAt: payload?.extractedAt ?? new Date().toISOString(),
211
+ user: {
212
+ label: options.userLabel ?? accountAlias ?? 'User',
213
+ ref: payload?.source?.accountHash ?? null
214
+ },
215
+ sessions,
216
+ healthMetrics: payload?.healthMetrics ?? {},
217
+ from,
218
+ to
219
+ };
220
+ }
221
+
222
+ export function normalizedIncrementScoreReplayInput(snapshot, options = {}) {
223
+ if (snapshot?.replayVersion === INCREMENT_SCORE_REPLAY_VERSION) return snapshot;
224
+ if (snapshot?.kind === EXPORTED_REPLAY_INPUT_KIND) return normalizeExportedReplayInput(snapshot, options);
225
+
226
+ const sessions = (snapshot?.sessions ?? [])
227
+ .map((session) => {
228
+ const date = parseDateKey(completedAtForSession(session));
229
+ if (!date) return null;
230
+
231
+ const exercises = (session.exercises ?? []).map((exercise) => {
232
+ const primaryMuscle = normalizeMuscleGroup(
233
+ exercise.muscleGroup ?? exercise.primaryMuscleGroup ?? exercise.primaryMuscle
234
+ );
235
+ return {
236
+ name: exercise.name ?? exercise.exerciseName ?? 'Unknown Exercise',
237
+ exerciseKey: normalizeExerciseName(exercise.name ?? exercise.exerciseName),
238
+ primaryMuscle,
239
+ muscleLabel: TARGET_SETS[primaryMuscle]?.label ?? exercise.muscleGroup ?? primaryMuscle ?? 'Unknown',
240
+ completedSets: completedSetsForExercise(exercise),
241
+ plannedSetCount: Array.isArray(exercise.sets) ? exercise.sets.length : null
242
+ };
243
+ });
244
+
245
+ const completedSetCount = exercises.reduce((sum, exercise) => sum + exercise.completedSets.length, 0);
246
+ const plannedSetCount = exercises.reduce((sum, exercise) => {
247
+ if (exercise.plannedSetCount == null) return sum;
248
+ return sum + exercise.plannedSetCount;
249
+ }, 0);
250
+
251
+ return {
252
+ sessionId: session.id ?? null,
253
+ date,
254
+ completedAt: completedAtForSession(session),
255
+ dayName: session.dayName ?? session.summary?.dayName ?? null,
256
+ completedSetCount,
257
+ plannedSetCount,
258
+ emptyStrengthSession: completedSetCount === 0,
259
+ exercises
260
+ };
261
+ })
262
+ .filter(Boolean)
263
+ .sort((left, right) => String(left.completedAt ?? left.date).localeCompare(String(right.completedAt ?? right.date)));
264
+
265
+ const dates = sessions.map((session) => session.date);
266
+ const fallbackDate = parseDateKey(options.asOf) ?? new Date().toISOString().slice(0, 10);
267
+ const from = options.from ?? dates[0] ?? fallbackDate;
268
+ const to = options.to ?? dates.at(-1) ?? from;
269
+
270
+ return {
271
+ replayVersion: INCREMENT_SCORE_REPLAY_VERSION,
272
+ formulaVersion: options.formulaVersion ?? PROVISIONAL_FORMULA_VERSION,
273
+ source: options.source ?? 'snapshot',
274
+ extractedAt: options.extractedAt ?? new Date().toISOString(),
275
+ user: redactUser(snapshot, options),
276
+ sessions,
277
+ healthMetrics: snapshot?.healthMetrics ?? {},
278
+ from,
279
+ to
280
+ };
281
+ }
282
+
283
+ function redactUser(snapshot, options) {
284
+ const explicitLabel = options.userLabel ?? snapshot?.replayUserLabel ?? snapshot?.userLabel ?? 'User';
285
+ const rawId = snapshot?.account?.id ?? snapshot?.accountId ?? snapshot?.userId ?? null;
286
+ return {
287
+ label: explicitLabel,
288
+ ref: rawId ? createHash('sha256').update(String(rawId)).digest('hex').slice(0, 10) : null
289
+ };
290
+ }
291
+
292
+ function sessionsInWindow(sessions, startDate, endDate) {
293
+ return sessions.filter((session) => session.date >= startDate && session.date <= endDate);
294
+ }
295
+
296
+ function healthReadingsInWindow(metrics, key, startDate, endDate) {
297
+ return (metrics?.[key] ?? []).filter((reading) => {
298
+ const date = parseDateKey(reading.date);
299
+ return date && date >= startDate && date <= endDate;
300
+ });
301
+ }
302
+
303
+ function buildProgressionEvents(sessions) {
304
+ const bestByExercise = new Map();
305
+ const groupedByDate = new Map();
306
+
307
+ for (const session of sessions) {
308
+ if (!groupedByDate.has(session.date)) groupedByDate.set(session.date, []);
309
+ groupedByDate.get(session.date).push(session);
310
+ }
311
+
312
+ const eventsByDate = new Map();
313
+ for (const date of [...groupedByDate.keys()].sort()) {
314
+ const events = [];
315
+ const sessionsForDate = groupedByDate.get(date);
316
+
317
+ for (const session of sessionsForDate) {
318
+ const eventKeys = new Set();
319
+ for (const exercise of session.exercises) {
320
+ for (const set of exercise.completedSets) {
321
+ const previousBest = bestByExercise.get(exercise.exerciseKey);
322
+ if (previousBest != null && set.estimatedOneRM > previousBest * 1.01 && !eventKeys.has(exercise.exerciseKey)) {
323
+ eventKeys.add(exercise.exerciseKey);
324
+ events.push({
325
+ date,
326
+ sessionId: session.sessionId,
327
+ exercise: exercise.name,
328
+ previousBest: round(previousBest, 1),
329
+ estimatedOneRM: round(set.estimatedOneRM, 1)
330
+ });
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ for (const session of sessionsForDate) {
337
+ for (const exercise of session.exercises) {
338
+ for (const set of exercise.completedSets) {
339
+ const current = bestByExercise.get(exercise.exerciseKey);
340
+ if (current == null || set.estimatedOneRM > current) {
341
+ bestByExercise.set(exercise.exerciseKey, set.estimatedOneRM);
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ if (events.length > 0) eventsByDate.set(date, events);
348
+ }
349
+
350
+ return eventsByDate;
351
+ }
352
+
353
+ function componentScores({ windowSessions, windowProgressionEvents, healthMetrics, windowStart, date, earliestDate }) {
354
+ const completedSessions = windowSessions.filter((session) => session.completedSetCount > 0);
355
+ const emptySessions = windowSessions.filter((session) => session.emptyStrengthSession).length;
356
+ const completedSetCount = completedSessions.reduce((sum, session) => sum + session.completedSetCount, 0);
357
+ const plannedSetCount = windowSessions.reduce((sum, session) => sum + (session.plannedSetCount ?? 0), 0);
358
+ const completionRate = plannedSetCount > 0 ? completedSetCount / plannedSetCount : 1;
359
+ const setCountsByMuscle = {};
360
+
361
+ for (const session of completedSessions) {
362
+ for (const exercise of session.exercises) {
363
+ if (!TARGET_SETS[exercise.primaryMuscle]) continue;
364
+ setCountsByMuscle[exercise.primaryMuscle] = (setCountsByMuscle[exercise.primaryMuscle] ?? 0) + exercise.completedSets.length;
365
+ }
366
+ }
367
+
368
+ const stimulus = Object.entries(TARGET_SETS).reduce((sum, [muscle, target]) => {
369
+ return sum + clamp((setCountsByMuscle[muscle] ?? 0) / target.target, 0, 1);
370
+ }, 0) / Object.keys(TARGET_SETS).length * 100;
371
+
372
+ const coveredMuscles = Object.keys(setCountsByMuscle).filter((muscle) => setCountsByMuscle[muscle] > 0);
373
+ const coverage = coveredMuscles.length / Object.keys(TARGET_SETS).length * 100;
374
+
375
+ const progressionEventCount = windowProgressionEvents.reduce((sum, events) => sum + events.length, 0);
376
+ const historyDays = earliestDate ? daysBetween(earliestDate, date) + 1 : 0;
377
+ const progression = progressionEventCount > 0
378
+ ? clamp(45 + progressionEventCount * 18)
379
+ : (historyDays < 14 ? 45 : 35);
380
+
381
+ const recovery = recoveryScore(completedSessions, healthMetrics, windowStart, date);
382
+ const execution = clamp((completedSessions.length / 3) * 85 + completionRate * 15 - emptySessions * 20);
383
+
384
+ return {
385
+ stimulus: round(stimulus),
386
+ progression: round(progression),
387
+ recovery: round(recovery.score),
388
+ coverage: round(coverage),
389
+ execution: round(execution),
390
+ meta: {
391
+ completedSessions: completedSessions.length,
392
+ completedSetCount,
393
+ plannedSetCount,
394
+ completionRate: round(completionRate, 2),
395
+ emptySessions,
396
+ setCountsByMuscle,
397
+ coveredMuscles,
398
+ progressionEventCount,
399
+ recovery
400
+ }
401
+ };
402
+ }
403
+
404
+ function recoveryScore(completedSessions, healthMetrics, windowStart, date) {
405
+ let score = 88;
406
+ const sameMuscleWarnings = [];
407
+ const lastSeenByMuscle = new Map();
408
+
409
+ for (const session of [...completedSessions].sort((left, right) => left.date.localeCompare(right.date))) {
410
+ const muscles = new Set(session.exercises
411
+ .filter((exercise) => exercise.completedSets.length > 0 && TARGET_SETS[exercise.primaryMuscle])
412
+ .map((exercise) => exercise.primaryMuscle));
413
+
414
+ for (const muscle of muscles) {
415
+ const previous = lastSeenByMuscle.get(muscle);
416
+ if (previous) {
417
+ const gapDays = daysBetween(previous, session.date);
418
+ if (gapDays < 1) {
419
+ score -= 25;
420
+ sameMuscleWarnings.push({ muscle, gapDays, severity: 'high' });
421
+ } else if (gapDays < 2) {
422
+ score -= 12;
423
+ sameMuscleWarnings.push({ muscle, gapDays, severity: 'medium' });
424
+ } else if (gapDays < 3) {
425
+ score -= 4;
426
+ sameMuscleWarnings.push({ muscle, gapDays, severity: 'low' });
427
+ }
428
+ }
429
+ lastSeenByMuscle.set(muscle, session.date);
430
+ }
431
+ }
432
+
433
+ if (completedSessions.length >= 5) score -= 5;
434
+
435
+ const hrv = healthReadingsInWindow(healthMetrics, 'hrv', windowStart, date);
436
+ const restingHR = healthReadingsInWindow(healthMetrics, 'restingHR', windowStart, date);
437
+ const sleep = healthReadingsInWindow(healthMetrics, 'sleep', windowStart, date);
438
+ const availableSignals = [hrv.length > 0, restingHR.length > 0, sleep.length > 0].filter(Boolean).length;
439
+ if (availableSignals >= 2) score += 4;
440
+
441
+ return {
442
+ score: clamp(score),
443
+ sameMuscleWarnings,
444
+ availableSignals
445
+ };
446
+ }
447
+
448
+ function totalScore(components) {
449
+ return round(
450
+ components.stimulus * SCORE_WEIGHTS.stimulus
451
+ + components.progression * SCORE_WEIGHTS.progression
452
+ + components.recovery * SCORE_WEIGHTS.recovery
453
+ + components.coverage * SCORE_WEIGHTS.coverage
454
+ + components.execution * SCORE_WEIGHTS.execution
455
+ );
456
+ }
457
+
458
+ function dataQualityNotes({ healthMetrics, windowStart, date, earliestDate, completedSessions, noSignal }) {
459
+ const historyDays = earliestDate ? daysBetween(earliestDate, date) + 1 : 0;
460
+ const hrv = healthReadingsInWindow(healthMetrics, 'hrv', windowStart, date).length;
461
+ const restingHR = healthReadingsInWindow(healthMetrics, 'restingHR', windowStart, date).length;
462
+ const sleep = healthReadingsInWindow(healthMetrics, 'sleep', windowStart, date).length;
463
+ const availableRecoverySignals = [
464
+ hrv > 0 ? 'HRV' : null,
465
+ restingHR > 0 ? 'resting HR' : null,
466
+ sleep > 0 ? 'sleep' : null
467
+ ].filter(Boolean);
468
+ const missingRecoverySignals = [
469
+ hrv === 0 ? 'HRV' : null,
470
+ restingHR === 0 ? 'resting HR' : null,
471
+ sleep === 0 ? 'sleep' : null
472
+ ].filter(Boolean);
473
+
474
+ const confidenceScore = clamp(
475
+ (historyDays >= 28 ? 40 : historyDays >= 14 ? 28 : 15)
476
+ + (completedSessions.length >= 3 ? 30 : completedSessions.length >= 1 ? 18 : 0)
477
+ + availableRecoverySignals.length * 10
478
+ - (noSignal ? 20 : 0)
479
+ );
480
+
481
+ const confidence = noSignal || confidenceScore < 45
482
+ ? 'low'
483
+ : confidenceScore < 70
484
+ ? 'medium'
485
+ : 'high';
486
+
487
+ const notes = [];
488
+ notes.push(`History baseline: ${historyDays} day${historyDays === 1 ? '' : 's'} (${historyDays >= 28 ? 'high' : historyDays >= 14 ? 'medium' : 'low'}).`);
489
+ if (availableRecoverySignals.length > 0) {
490
+ notes.push(`Health recovery signals present: ${availableRecoverySignals.join(', ')}.`);
491
+ }
492
+ if (missingRecoverySignals.length > 0) {
493
+ notes.push(`Health recovery signals missing: ${missingRecoverySignals.join(', ')}.`);
494
+ }
495
+ notes.push('Intra-workout rest quality is not scored until set timing/rest metadata exists.');
496
+ if (noSignal) notes.push('No completed strength sets in the rolling 7-day window.');
497
+
498
+ return {
499
+ confidence,
500
+ confidenceScore,
501
+ healthSignals: {
502
+ hrvReadings: hrv,
503
+ restingHRReadings: restingHR,
504
+ sleepNights: sleep
505
+ },
506
+ notes
507
+ };
508
+ }
509
+
510
+ function driverCandidates(row, previousScoredRow) {
511
+ if (row.state === 'no_signal') {
512
+ return {
513
+ positive: [],
514
+ negative: [{ component: 'signal', text: 'No logged strength signal in the rolling 7-day window.', impact: 100 }]
515
+ };
516
+ }
517
+
518
+ const positives = [];
519
+ const negatives = [];
520
+ const meta = row.componentMeta;
521
+
522
+ if (row.components.stimulus >= 70) positives.push({ component: 'stimulus', text: 'Productive weekly strength stimulus is near target.', impact: row.components.stimulus });
523
+ if (row.components.coverage >= 70) positives.push({ component: 'coverage', text: 'Major muscle coverage is broad this week.', impact: row.components.coverage });
524
+ if (row.components.progression >= 63) positives.push({ component: 'progression', text: `${meta.progressionEventCount} progression signal${meta.progressionEventCount === 1 ? '' : 's'} in the window.`, impact: row.components.progression });
525
+ if (row.components.execution >= 80) positives.push({ component: 'execution', text: 'Session execution is strong for the week.', impact: row.components.execution });
526
+ if (row.components.recovery >= 80) positives.push({ component: 'recovery', text: 'Between-session recovery spacing is mostly clear.', impact: row.components.recovery });
527
+
528
+ const missingMuscles = Object.entries(TARGET_SETS)
529
+ .filter(([muscle]) => (meta.setCountsByMuscle[muscle] ?? 0) === 0)
530
+ .map(([, target]) => target.label);
531
+ if (missingMuscles.length > 0) negatives.push({ component: 'coverage', text: `${missingMuscles.slice(0, 3).join(', ')} missing from the rolling window.`, impact: 100 - row.components.coverage });
532
+ if (row.components.stimulus < 55) negatives.push({ component: 'stimulus', text: 'Completed working sets are below weekly target.', impact: 100 - row.components.stimulus });
533
+ if (meta.recovery.sameMuscleWarnings.length > 0) negatives.push({ component: 'recovery', text: 'Same-muscle sessions are clustered inside the recovery window.', impact: 100 - row.components.recovery });
534
+ if (row.components.progression < 45) negatives.push({ component: 'progression', text: 'Progression evidence is fading versus prior logged history.', impact: 100 - row.components.progression });
535
+ if (meta.emptySessions > 0) negatives.push({ component: 'execution', text: `${meta.emptySessions} empty strength session${meta.emptySessions === 1 ? '' : 's'} in the window.`, impact: 60 });
536
+
537
+ if (previousScoredRow?.components) {
538
+ for (const component of Object.keys(SCORE_WEIGHTS)) {
539
+ const delta = row.components[component] - previousScoredRow.components[component];
540
+ if (delta >= 12) positives.push({ component, text: `${component} improved ${round(delta)} points day over day.`, impact: delta });
541
+ if (delta <= -12) negatives.push({ component, text: `${component} dropped ${Math.abs(round(delta))} points day over day.`, impact: Math.abs(delta) });
542
+ }
543
+ }
544
+
545
+ return {
546
+ positive: positives.sort((left, right) => right.impact - left.impact).slice(0, 3),
547
+ negative: negatives.sort((left, right) => right.impact - left.impact).slice(0, 3)
548
+ };
549
+ }
550
+
551
+ export function buildIncrementScoreReplay(inputOrSnapshot, options = {}) {
552
+ const input = normalizedIncrementScoreReplayInput(inputOrSnapshot, options);
553
+
554
+ const progressionEventsByDate = buildProgressionEvents(input.sessions);
555
+ const earliestDate = input.sessions[0]?.date ?? null;
556
+ const rows = [];
557
+ let previousRow = null;
558
+ let previousScoredRow = null;
559
+
560
+ for (const date of dateRange(input.from, input.to)) {
561
+ const windowStart = addDays(date, -6);
562
+ const windowSessions = sessionsInWindow(input.sessions, windowStart, date);
563
+ const completedSessions = windowSessions.filter((session) => session.completedSetCount > 0);
564
+ const noSignal = completedSessions.length === 0;
565
+ const windowProgressionEvents = dateRange(windowStart, date).map((key) => progressionEventsByDate.get(key) ?? []);
566
+
567
+ const quality = dataQualityNotes({
568
+ healthMetrics: input.healthMetrics,
569
+ windowStart,
570
+ date,
571
+ earliestDate,
572
+ completedSessions,
573
+ noSignal
574
+ });
575
+
576
+ let row;
577
+ if (noSignal) {
578
+ row = {
579
+ date,
580
+ windowStart,
581
+ windowEnd: date,
582
+ state: 'no_signal',
583
+ score: null,
584
+ scoreDelta: previousRow?.score != null ? null : null,
585
+ deltaState: previousRow?.score != null ? 'lost_signal' : 'no_signal',
586
+ trend: 'no_signal',
587
+ components: {
588
+ stimulus: 0,
589
+ progression: 0,
590
+ recovery: 0,
591
+ coverage: 0,
592
+ execution: 0
593
+ },
594
+ componentMeta: {
595
+ completedSessions: 0,
596
+ completedSetCount: 0,
597
+ plannedSetCount: windowSessions.reduce((sum, session) => sum + (session.plannedSetCount ?? 0), 0),
598
+ completionRate: 0,
599
+ emptySessions: windowSessions.filter((session) => session.emptyStrengthSession).length,
600
+ setCountsByMuscle: {},
601
+ coveredMuscles: [],
602
+ progressionEventCount: 0,
603
+ recovery: { score: 0, sameMuscleWarnings: [], availableSignals: 0 }
604
+ },
605
+ topDrivers: { positive: [], negative: [] },
606
+ dataQuality: quality
607
+ };
608
+ } else {
609
+ const components = componentScores({
610
+ windowSessions,
611
+ windowProgressionEvents,
612
+ healthMetrics: input.healthMetrics,
613
+ windowStart,
614
+ date,
615
+ earliestDate
616
+ });
617
+ const score = totalScore(components);
618
+ const scoreDelta = previousRow?.score != null ? score - previousRow.score : null;
619
+ row = {
620
+ date,
621
+ windowStart,
622
+ windowEnd: date,
623
+ state: 'scored',
624
+ score,
625
+ scoreDelta,
626
+ deltaState: previousRow?.state === 'no_signal' ? 'rebuilt' : (scoreDelta == null ? 'baseline' : 'scored'),
627
+ trend: trendFor(scoreDelta, previousRow),
628
+ components: {
629
+ stimulus: components.stimulus,
630
+ progression: components.progression,
631
+ recovery: components.recovery,
632
+ coverage: components.coverage,
633
+ execution: components.execution
634
+ },
635
+ componentMeta: components.meta,
636
+ topDrivers: { positive: [], negative: [] },
637
+ dataQuality: quality
638
+ };
639
+ }
640
+
641
+ row.topDrivers = driverCandidates(row, previousScoredRow);
642
+ rows.push(row);
643
+ previousRow = row;
644
+ if (row.score != null) previousScoredRow = row;
645
+ }
646
+
647
+ const weeklySummaries = buildWeeklySummaries(rows);
648
+
649
+ return {
650
+ replayVersion: input.replayVersion,
651
+ formulaVersion: input.formulaVersion,
652
+ source: input.source,
653
+ extractedAt: input.extractedAt,
654
+ user: input.user,
655
+ dateRange: { from: input.from, to: input.to },
656
+ dailyRows: rows,
657
+ weeklySummaries
658
+ };
659
+ }
660
+
661
+ function trendFor(scoreDelta, previousRow) {
662
+ if (previousRow?.state === 'no_signal') return 'rebuilt';
663
+ if (scoreDelta == null) return 'baseline';
664
+ if (scoreDelta >= 3) return 'improving';
665
+ if (scoreDelta <= -3) return 'slipping';
666
+ return 'stable';
667
+ }
668
+
669
+ function buildWeeklySummaries(rows) {
670
+ const grouped = new Map();
671
+ for (const row of rows) {
672
+ const key = weekStart(row.date);
673
+ if (!grouped.has(key)) grouped.set(key, []);
674
+ grouped.get(key).push(row);
675
+ }
676
+
677
+ const summaries = [];
678
+ let previousScoredSummary = null;
679
+ for (const [startDate, weekRows] of [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right))) {
680
+ const scoredRows = weekRows.filter((row) => row.score != null);
681
+ const lastRow = [...weekRows].reverse().find((row) => row.score != null) ?? weekRows.at(-1);
682
+ const averageScore = scoredRows.length > 0
683
+ ? round(scoredRows.reduce((sum, row) => sum + row.score, 0) / scoredRows.length)
684
+ : null;
685
+ const components = averageComponents(scoredRows);
686
+ const scoreDelta = lastRow?.score != null && previousScoredSummary?.score != null
687
+ ? lastRow.score - previousScoredSummary.score
688
+ : null;
689
+
690
+ const summary = {
691
+ weekStart: startDate,
692
+ weekEnd: weekEnd(startDate),
693
+ state: scoredRows.length > 0 ? 'scored' : 'no_signal',
694
+ score: lastRow?.score ?? null,
695
+ averageScore,
696
+ scoreDelta,
697
+ trend: scoredRows.length === 0 ? 'no_signal' : trendFor(scoreDelta, previousScoredSummary),
698
+ scoredDays: scoredRows.length,
699
+ noSignalDays: weekRows.length - scoredRows.length,
700
+ components,
701
+ topDrivers: aggregateDrivers(weekRows),
702
+ confidenceNotes: [...new Set(weekRows.flatMap((row) => row.dataQuality.notes))].slice(0, 5)
703
+ };
704
+
705
+ summaries.push(summary);
706
+ if (summary.score != null) previousScoredSummary = summary;
707
+ }
708
+
709
+ return summaries;
710
+ }
711
+
712
+ function averageComponents(rows) {
713
+ if (rows.length === 0) {
714
+ return { stimulus: 0, progression: 0, recovery: 0, coverage: 0, execution: 0 };
715
+ }
716
+
717
+ return Object.fromEntries(Object.keys(SCORE_WEIGHTS).map((component) => [
718
+ component,
719
+ round(rows.reduce((sum, row) => sum + row.components[component], 0) / rows.length)
720
+ ]));
721
+ }
722
+
723
+ function aggregateDrivers(rows) {
724
+ const rank = (drivers) => {
725
+ const byText = new Map();
726
+ for (const driver of drivers) {
727
+ const current = byText.get(driver.text) ?? { ...driver, impact: 0, count: 0 };
728
+ current.impact += driver.impact;
729
+ current.count += 1;
730
+ byText.set(driver.text, current);
731
+ }
732
+ return [...byText.values()]
733
+ .sort((left, right) => right.impact - left.impact)
734
+ .slice(0, 3)
735
+ .map((driver) => ({
736
+ component: driver.component,
737
+ text: driver.text,
738
+ count: driver.count
739
+ }));
740
+ };
741
+
742
+ return {
743
+ positive: rank(rows.flatMap((row) => row.topDrivers.positive)),
744
+ negative: rank(rows.flatMap((row) => row.topDrivers.negative))
745
+ };
746
+ }
747
+
748
+ function formatDelta(delta, state = 'scored') {
749
+ if (state === 'rebuilt') return 'rebuilt';
750
+ if (state === 'lost_signal') return 'lost signal';
751
+ if (state === 'no_signal') return 'no signal';
752
+ if (delta == null) return 'baseline';
753
+ return `${delta >= 0 ? '+' : ''}${delta}`;
754
+ }
755
+
756
+ function formatScore(score) {
757
+ return score == null ? 'no signal' : String(score);
758
+ }
759
+
760
+ function driverLines(prefix, drivers) {
761
+ if (drivers.length === 0) return [`${prefix} none`];
762
+ return drivers.map((driver) => `${prefix} ${driver.text}`);
763
+ }
764
+
765
+ export function formatIncrementScoreReplayMarkdown(replay) {
766
+ const lines = [];
767
+ lines.push(`# Increment Score Replay - ${replay.user.label}`);
768
+ lines.push('');
769
+ if (replay.user.ref) lines.push(`User ref: ${replay.user.ref}`);
770
+ lines.push(`Range: ${replay.dateRange.from} to ${replay.dateRange.to}`);
771
+ lines.push(`Formula: ${replay.formulaVersion}`);
772
+ lines.push(`Replay harness: ${replay.replayVersion}`);
773
+ lines.push(`Extracted at: ${replay.extractedAt}`);
774
+ lines.push('');
775
+
776
+ lines.push('## Daily Score Rows');
777
+ lines.push('');
778
+ lines.push('| Date | Score | Delta | State | Stimulus | Progression | Recovery | Coverage | Execution | Confidence |');
779
+ lines.push('| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | --- |');
780
+ for (const row of replay.dailyRows) {
781
+ lines.push([
782
+ row.date,
783
+ formatScore(row.score),
784
+ formatDelta(row.scoreDelta, row.deltaState),
785
+ row.state,
786
+ row.components.stimulus,
787
+ row.components.progression,
788
+ row.components.recovery,
789
+ row.components.coverage,
790
+ row.components.execution,
791
+ row.dataQuality.confidence
792
+ ].join(' | ').replace(/^/, '| ').replace(/$/, ' |'));
793
+ }
794
+ lines.push('');
795
+
796
+ lines.push('## Weekly Summaries');
797
+ lines.push('');
798
+ for (const week of replay.weeklySummaries) {
799
+ lines.push(`### Week Ending ${week.weekEnd}`);
800
+ lines.push('');
801
+ lines.push(`Score: ${formatScore(week.score)} (${formatDelta(week.scoreDelta, week.trend)})`);
802
+ lines.push(`Trend: ${week.trend}`);
803
+ lines.push(`Scored days: ${week.scoredDays}; no-signal days: ${week.noSignalDays}`);
804
+ lines.push(`Components: stimulus ${week.components.stimulus}, progression ${week.components.progression}, recovery ${week.components.recovery}, coverage ${week.components.coverage}, execution ${week.components.execution}`);
805
+ lines.push('');
806
+ lines.push('Drivers:');
807
+ for (const line of driverLines('+', week.topDrivers.positive)) lines.push(line);
808
+ for (const line of driverLines('-', week.topDrivers.negative)) lines.push(line);
809
+ lines.push('');
810
+ lines.push('Data quality:');
811
+ for (const note of week.confidenceNotes) lines.push(`- ${note}`);
812
+ lines.push('');
813
+ }
814
+
815
+ lines.push('## Calibration Notes');
816
+ lines.push('');
817
+ lines.push('- Provisional formula only; replace the adapter when `onemore-9ar` lands.');
818
+ lines.push('- Snapshot adapter is read-only and emits redacted user refs only.');
819
+ lines.push('- No raw user exports should be committed with generated reports.');
820
+
821
+ return `${lines.join('\n')}\n`;
822
+ }