Skip to content

Commit 0bfc406

Browse files
authored
Add Phase 2 analytics: PR tracking, volume analysis, and progression (#2)
1 parent becf83f commit 0bfc406

File tree

4 files changed

+698
-2
lines changed

4 files changed

+698
-2
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "workout-cli",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Workout CLI",
55
"type": "module",
66
"bin": {

src/commands/analytics.ts

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import { Command } from 'commander';
2+
import { getStorage } from '../data/storage.js';
3+
4+
interface PR {
5+
exercise: string;
6+
exerciseName: string;
7+
weight: number;
8+
reps: number;
9+
e1rm: number;
10+
date: string;
11+
workoutId: string;
12+
}
13+
14+
function calculateE1RM(weight: number, reps: number): number {
15+
if (reps === 1) return weight;
16+
return Math.round(weight * (1 + reps / 30));
17+
}
18+
19+
function findPRs(storage: ReturnType<typeof getStorage>): Map<string, PR> {
20+
const workouts = storage.getAllWorkouts();
21+
const prs = new Map<string, PR>();
22+
23+
for (const workout of workouts) {
24+
for (const log of workout.exercises) {
25+
const exercise = storage.getExercise(log.exercise);
26+
if (!exercise) continue;
27+
28+
for (const set of log.sets) {
29+
const e1rm = calculateE1RM(set.weight, set.reps);
30+
const existing = prs.get(exercise.id);
31+
32+
if (!existing || e1rm > existing.e1rm) {
33+
prs.set(exercise.id, {
34+
exercise: exercise.id,
35+
exerciseName: exercise.name,
36+
weight: set.weight,
37+
reps: set.reps,
38+
e1rm,
39+
date: workout.date,
40+
workoutId: workout.id,
41+
});
42+
}
43+
}
44+
}
45+
}
46+
47+
return prs;
48+
}
49+
50+
export function createPRCommand(): Command {
51+
return new Command('pr')
52+
.description('Show personal records')
53+
.argument('[exercise]', 'Exercise ID (optional, shows all if omitted)')
54+
.option('-m, --muscle <muscle>', 'Filter by muscle group')
55+
.option('--json', 'Output as JSON')
56+
.action((exerciseId: string | undefined, options: { muscle?: string; json?: boolean }) => {
57+
const storage = getStorage();
58+
const config = storage.getConfig();
59+
const unit = config.units;
60+
const prs = findPRs(storage);
61+
62+
let prList = Array.from(prs.values());
63+
64+
if (exerciseId) {
65+
const exercise = storage.getExercise(exerciseId);
66+
if (!exercise) {
67+
console.error(`Exercise "${exerciseId}" not found.`);
68+
process.exit(1);
69+
}
70+
prList = prList.filter((pr) => pr.exercise === exercise.id);
71+
}
72+
73+
if (options.muscle) {
74+
const exercises = storage.getExercises();
75+
const muscleExerciseIds = new Set(
76+
exercises
77+
.filter((e) =>
78+
e.muscles.some((m) => m.toLowerCase().includes(options.muscle!.toLowerCase()))
79+
)
80+
.map((e) => e.id)
81+
);
82+
prList = prList.filter((pr) => muscleExerciseIds.has(pr.exercise));
83+
}
84+
85+
prList.sort((a, b) => b.e1rm - a.e1rm);
86+
87+
if (options.json) {
88+
console.log(JSON.stringify(prList, null, 2));
89+
return;
90+
}
91+
92+
if (prList.length === 0) {
93+
console.log('No personal records found.');
94+
return;
95+
}
96+
97+
console.log('Personal Records:');
98+
console.log('');
99+
for (const pr of prList) {
100+
console.log(
101+
`${pr.exerciseName}: ${pr.weight}${unit} x ${pr.reps} (est. 1RM: ${pr.e1rm}${unit}) - ${pr.date}`
102+
);
103+
}
104+
});
105+
}
106+
107+
function getWeekStart(date: Date): Date {
108+
const d = new Date(date);
109+
const day = d.getDay();
110+
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
111+
d.setDate(diff);
112+
d.setHours(0, 0, 0, 0);
113+
return d;
114+
}
115+
116+
function getMonthStart(date: Date): Date {
117+
return new Date(date.getFullYear(), date.getMonth(), 1);
118+
}
119+
120+
function formatDateRange(start: Date, end: Date): string {
121+
const startStr = start.toISOString().split('T')[0];
122+
const endStr = end.toISOString().split('T')[0];
123+
return `${startStr} to ${endStr}`;
124+
}
125+
126+
export function createVolumeCommand(): Command {
127+
return new Command('volume')
128+
.description('Analyze training volume')
129+
.option('-w, --week', 'Show current week')
130+
.option('-m, --month', 'Show current month')
131+
.option('--last-weeks <n>', 'Show last N weeks', '4')
132+
.option('--by <grouping>', 'Group by: muscle, exercise, day')
133+
.option('--json', 'Output as JSON')
134+
.action(
135+
(options: {
136+
week?: boolean;
137+
month?: boolean;
138+
lastWeeks?: string;
139+
by?: string;
140+
json?: boolean;
141+
}) => {
142+
const storage = getStorage();
143+
const config = storage.getConfig();
144+
const unit = config.units;
145+
const workouts = storage.getAllWorkouts();
146+
147+
if (workouts.length === 0) {
148+
console.log('No workouts found.');
149+
return;
150+
}
151+
152+
const now = new Date();
153+
let startDate: Date;
154+
let endDate: Date = now;
155+
let periodLabel: string;
156+
157+
if (options.week) {
158+
startDate = getWeekStart(now);
159+
periodLabel = 'This week';
160+
} else if (options.month) {
161+
startDate = getMonthStart(now);
162+
periodLabel = 'This month';
163+
} else {
164+
const weeks = parseInt(options.lastWeeks ?? '4', 10);
165+
startDate = new Date(now);
166+
startDate.setDate(startDate.getDate() - weeks * 7);
167+
periodLabel = `Last ${weeks} weeks`;
168+
}
169+
170+
const filteredWorkouts = workouts.filter((w) => {
171+
const d = new Date(w.date);
172+
return d >= startDate && d <= endDate;
173+
});
174+
175+
if (filteredWorkouts.length === 0) {
176+
console.log(`No workouts in period: ${periodLabel}`);
177+
return;
178+
}
179+
180+
let totalSets = 0;
181+
let totalVolume = 0;
182+
const muscleVolume = new Map<string, number>();
183+
const exerciseVolume = new Map<string, { sets: number; volume: number; name: string }>();
184+
185+
for (const workout of filteredWorkouts) {
186+
for (const log of workout.exercises) {
187+
const exercise = storage.getExercise(log.exercise);
188+
if (!exercise) continue;
189+
190+
let exerciseSets = 0;
191+
let exerciseVol = 0;
192+
193+
for (const set of log.sets) {
194+
const vol = set.weight * set.reps;
195+
totalSets++;
196+
totalVolume += vol;
197+
exerciseSets++;
198+
exerciseVol += vol;
199+
200+
for (const muscle of exercise.muscles) {
201+
muscleVolume.set(muscle, (muscleVolume.get(muscle) ?? 0) + vol);
202+
}
203+
}
204+
205+
const existing = exerciseVolume.get(exercise.id);
206+
if (existing) {
207+
existing.sets += exerciseSets;
208+
existing.volume += exerciseVol;
209+
} else {
210+
exerciseVolume.set(exercise.id, {
211+
sets: exerciseSets,
212+
volume: exerciseVol,
213+
name: exercise.name,
214+
});
215+
}
216+
}
217+
}
218+
219+
if (options.json) {
220+
const result = {
221+
period: periodLabel,
222+
startDate: startDate.toISOString().split('T')[0],
223+
endDate: endDate.toISOString().split('T')[0],
224+
workouts: filteredWorkouts.length,
225+
totalSets,
226+
totalVolume,
227+
byMuscle: Object.fromEntries(muscleVolume),
228+
byExercise: Object.fromEntries(exerciseVolume),
229+
};
230+
console.log(JSON.stringify(result, null, 2));
231+
return;
232+
}
233+
234+
console.log(`Volume Analysis: ${periodLabel}`);
235+
console.log(`(${formatDateRange(startDate, endDate)})`);
236+
console.log('');
237+
console.log(`Workouts: ${filteredWorkouts.length}`);
238+
console.log(`Total sets: ${totalSets}`);
239+
console.log(`Total volume: ${totalVolume.toLocaleString()}${unit}`);
240+
console.log('');
241+
242+
if (options.by === 'muscle') {
243+
console.log('By Muscle Group:');
244+
const sorted = Array.from(muscleVolume.entries()).sort((a, b) => b[1] - a[1]);
245+
for (const [muscle, vol] of sorted) {
246+
console.log(` ${muscle}: ${vol.toLocaleString()}${unit}`);
247+
}
248+
} else if (options.by === 'exercise') {
249+
console.log('By Exercise:');
250+
const sorted = Array.from(exerciseVolume.entries()).sort(
251+
(a, b) => b[1].volume - a[1].volume
252+
);
253+
for (const [, data] of sorted) {
254+
console.log(
255+
` ${data.name}: ${data.sets} sets, ${data.volume.toLocaleString()}${unit}`
256+
);
257+
}
258+
} else {
259+
console.log('Top Muscles:');
260+
const sortedMuscles = Array.from(muscleVolume.entries())
261+
.sort((a, b) => b[1] - a[1])
262+
.slice(0, 5);
263+
for (const [muscle, vol] of sortedMuscles) {
264+
console.log(` ${muscle}: ${vol.toLocaleString()}${unit}`);
265+
}
266+
}
267+
}
268+
);
269+
}
270+
271+
export function createProgressionCommand(): Command {
272+
return new Command('progression')
273+
.description('Show progression over time for an exercise')
274+
.argument('<exercise>', 'Exercise ID')
275+
.option('-n, --last <count>', 'Show last N sessions', '10')
276+
.option('--json', 'Output as JSON')
277+
.action((exerciseId: string, options: { last: string; json?: boolean }) => {
278+
const storage = getStorage();
279+
const config = storage.getConfig();
280+
const unit = config.units;
281+
const exercise = storage.getExercise(exerciseId);
282+
283+
if (!exercise) {
284+
console.error(`Exercise "${exerciseId}" not found.`);
285+
process.exit(1);
286+
}
287+
288+
const history = storage.getExerciseHistory(exercise.id);
289+
const limit = parseInt(options.last, 10);
290+
const limited = history.slice(0, limit).reverse();
291+
292+
if (limited.length === 0) {
293+
console.log(`No history for ${exercise.name}.`);
294+
return;
295+
}
296+
297+
const progressionData = limited.map(({ workout, log }) => {
298+
const bestSet = log.sets.reduce((best, set) => {
299+
const e1rm = calculateE1RM(set.weight, set.reps);
300+
const bestE1rm = calculateE1RM(best.weight, best.reps);
301+
return e1rm > bestE1rm ? set : best;
302+
}, log.sets[0]!);
303+
304+
const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps, 0);
305+
const e1rm = calculateE1RM(bestSet.weight, bestSet.reps);
306+
307+
return {
308+
date: workout.date,
309+
sets: log.sets.length,
310+
bestWeight: bestSet.weight,
311+
bestReps: bestSet.reps,
312+
e1rm,
313+
totalVolume,
314+
};
315+
});
316+
317+
if (options.json) {
318+
console.log(
319+
JSON.stringify({ exercise: exercise.name, progression: progressionData }, null, 2)
320+
);
321+
return;
322+
}
323+
324+
console.log(`Progression for ${exercise.name}:`);
325+
console.log('');
326+
327+
const first = progressionData[0];
328+
const last = progressionData[progressionData.length - 1];
329+
330+
if (first && last && progressionData.length > 1) {
331+
const e1rmChange = last.e1rm - first.e1rm;
332+
const sign = e1rmChange >= 0 ? '+' : '';
333+
console.log(`Est. 1RM change: ${sign}${e1rmChange}${unit} (${first.e1rm}${last.e1rm})`);
334+
console.log('');
335+
}
336+
337+
console.log('Date | Best Set | Est 1RM | Volume');
338+
console.log('-----------|------------------|---------|--------');
339+
for (const entry of progressionData) {
340+
const bestStr = `${entry.bestWeight}${unit} x ${entry.bestReps}`.padEnd(16);
341+
const e1rmStr = `${entry.e1rm}${unit}`.padEnd(7);
342+
console.log(
343+
`${entry.date} | ${bestStr} | ${e1rmStr} | ${entry.totalVolume.toLocaleString()}${unit}`
344+
);
345+
}
346+
});
347+
}

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ import {
1010
createCancelCommand,
1111
} from './commands/session.js';
1212
import { createLastCommand, createHistoryCommand } from './commands/history.js';
13+
import {
14+
createPRCommand,
15+
createVolumeCommand,
16+
createProgressionCommand,
17+
} from './commands/analytics.js';
1318

1419
const program = new Command();
1520

1621
program
1722
.name('workout')
1823
.description('CLI for tracking workouts, managing exercises, and querying training history')
19-
.version('0.1.0');
24+
.version('0.2.0');
2025

2126
program.addCommand(createExercisesCommand());
2227
program.addCommand(createTemplatesCommand());
@@ -27,5 +32,8 @@ program.addCommand(createDoneCommand());
2732
program.addCommand(createCancelCommand());
2833
program.addCommand(createLastCommand());
2934
program.addCommand(createHistoryCommand());
35+
program.addCommand(createPRCommand());
36+
program.addCommand(createVolumeCommand());
37+
program.addCommand(createProgressionCommand());
3038

3139
program.parse();

0 commit comments

Comments
 (0)