From 046fe30048774983129a535cf6818bb2bb11ad94 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:23:36 +0000 Subject: [PATCH] Add Phase 2 analytics: PR tracking, volume analysis, and progression --- package.json | 2 +- src/commands/analytics.ts | 347 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 10 +- test/analytics.test.ts | 341 +++++++++++++++++++++++++++++++++++++ 4 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 src/commands/analytics.ts create mode 100644 test/analytics.test.ts diff --git a/package.json b/package.json index a752978..ba23cf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workout-cli", - "version": "0.1.0", + "version": "0.2.0", "description": "Workout CLI", "type": "module", "bin": { diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts new file mode 100644 index 0000000..c41a211 --- /dev/null +++ b/src/commands/analytics.ts @@ -0,0 +1,347 @@ +import { Command } from 'commander'; +import { getStorage } from '../data/storage.js'; + +interface PR { + exercise: string; + exerciseName: string; + weight: number; + reps: number; + e1rm: number; + date: string; + workoutId: string; +} + +function calculateE1RM(weight: number, reps: number): number { + if (reps === 1) return weight; + return Math.round(weight * (1 + reps / 30)); +} + +function findPRs(storage: ReturnType): Map { + const workouts = storage.getAllWorkouts(); + const prs = new Map(); + + for (const workout of workouts) { + for (const log of workout.exercises) { + const exercise = storage.getExercise(log.exercise); + if (!exercise) continue; + + for (const set of log.sets) { + const e1rm = calculateE1RM(set.weight, set.reps); + const existing = prs.get(exercise.id); + + if (!existing || e1rm > existing.e1rm) { + prs.set(exercise.id, { + exercise: exercise.id, + exerciseName: exercise.name, + weight: set.weight, + reps: set.reps, + e1rm, + date: workout.date, + workoutId: workout.id, + }); + } + } + } + } + + return prs; +} + +export function createPRCommand(): Command { + return new Command('pr') + .description('Show personal records') + .argument('[exercise]', 'Exercise ID (optional, shows all if omitted)') + .option('-m, --muscle ', 'Filter by muscle group') + .option('--json', 'Output as JSON') + .action((exerciseId: string | undefined, options: { muscle?: string; json?: boolean }) => { + const storage = getStorage(); + const config = storage.getConfig(); + const unit = config.units; + const prs = findPRs(storage); + + let prList = Array.from(prs.values()); + + if (exerciseId) { + const exercise = storage.getExercise(exerciseId); + if (!exercise) { + console.error(`Exercise "${exerciseId}" not found.`); + process.exit(1); + } + prList = prList.filter((pr) => pr.exercise === exercise.id); + } + + if (options.muscle) { + const exercises = storage.getExercises(); + const muscleExerciseIds = new Set( + exercises + .filter((e) => + e.muscles.some((m) => m.toLowerCase().includes(options.muscle!.toLowerCase())) + ) + .map((e) => e.id) + ); + prList = prList.filter((pr) => muscleExerciseIds.has(pr.exercise)); + } + + prList.sort((a, b) => b.e1rm - a.e1rm); + + if (options.json) { + console.log(JSON.stringify(prList, null, 2)); + return; + } + + if (prList.length === 0) { + console.log('No personal records found.'); + return; + } + + console.log('Personal Records:'); + console.log(''); + for (const pr of prList) { + console.log( + `${pr.exerciseName}: ${pr.weight}${unit} x ${pr.reps} (est. 1RM: ${pr.e1rm}${unit}) - ${pr.date}` + ); + } + }); +} + +function getWeekStart(date: Date): Date { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + d.setDate(diff); + d.setHours(0, 0, 0, 0); + return d; +} + +function getMonthStart(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function formatDateRange(start: Date, end: Date): string { + const startStr = start.toISOString().split('T')[0]; + const endStr = end.toISOString().split('T')[0]; + return `${startStr} to ${endStr}`; +} + +export function createVolumeCommand(): Command { + return new Command('volume') + .description('Analyze training volume') + .option('-w, --week', 'Show current week') + .option('-m, --month', 'Show current month') + .option('--last-weeks ', 'Show last N weeks', '4') + .option('--by ', 'Group by: muscle, exercise, day') + .option('--json', 'Output as JSON') + .action( + (options: { + week?: boolean; + month?: boolean; + lastWeeks?: string; + by?: string; + json?: boolean; + }) => { + const storage = getStorage(); + const config = storage.getConfig(); + const unit = config.units; + const workouts = storage.getAllWorkouts(); + + if (workouts.length === 0) { + console.log('No workouts found.'); + return; + } + + const now = new Date(); + let startDate: Date; + let endDate: Date = now; + let periodLabel: string; + + if (options.week) { + startDate = getWeekStart(now); + periodLabel = 'This week'; + } else if (options.month) { + startDate = getMonthStart(now); + periodLabel = 'This month'; + } else { + const weeks = parseInt(options.lastWeeks ?? '4', 10); + startDate = new Date(now); + startDate.setDate(startDate.getDate() - weeks * 7); + periodLabel = `Last ${weeks} weeks`; + } + + const filteredWorkouts = workouts.filter((w) => { + const d = new Date(w.date); + return d >= startDate && d <= endDate; + }); + + if (filteredWorkouts.length === 0) { + console.log(`No workouts in period: ${periodLabel}`); + return; + } + + let totalSets = 0; + let totalVolume = 0; + const muscleVolume = new Map(); + const exerciseVolume = new Map(); + + for (const workout of filteredWorkouts) { + for (const log of workout.exercises) { + const exercise = storage.getExercise(log.exercise); + if (!exercise) continue; + + let exerciseSets = 0; + let exerciseVol = 0; + + for (const set of log.sets) { + const vol = set.weight * set.reps; + totalSets++; + totalVolume += vol; + exerciseSets++; + exerciseVol += vol; + + for (const muscle of exercise.muscles) { + muscleVolume.set(muscle, (muscleVolume.get(muscle) ?? 0) + vol); + } + } + + const existing = exerciseVolume.get(exercise.id); + if (existing) { + existing.sets += exerciseSets; + existing.volume += exerciseVol; + } else { + exerciseVolume.set(exercise.id, { + sets: exerciseSets, + volume: exerciseVol, + name: exercise.name, + }); + } + } + } + + if (options.json) { + const result = { + period: periodLabel, + startDate: startDate.toISOString().split('T')[0], + endDate: endDate.toISOString().split('T')[0], + workouts: filteredWorkouts.length, + totalSets, + totalVolume, + byMuscle: Object.fromEntries(muscleVolume), + byExercise: Object.fromEntries(exerciseVolume), + }; + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Volume Analysis: ${periodLabel}`); + console.log(`(${formatDateRange(startDate, endDate)})`); + console.log(''); + console.log(`Workouts: ${filteredWorkouts.length}`); + console.log(`Total sets: ${totalSets}`); + console.log(`Total volume: ${totalVolume.toLocaleString()}${unit}`); + console.log(''); + + if (options.by === 'muscle') { + console.log('By Muscle Group:'); + const sorted = Array.from(muscleVolume.entries()).sort((a, b) => b[1] - a[1]); + for (const [muscle, vol] of sorted) { + console.log(` ${muscle}: ${vol.toLocaleString()}${unit}`); + } + } else if (options.by === 'exercise') { + console.log('By Exercise:'); + const sorted = Array.from(exerciseVolume.entries()).sort( + (a, b) => b[1].volume - a[1].volume + ); + for (const [, data] of sorted) { + console.log( + ` ${data.name}: ${data.sets} sets, ${data.volume.toLocaleString()}${unit}` + ); + } + } else { + console.log('Top Muscles:'); + const sortedMuscles = Array.from(muscleVolume.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + for (const [muscle, vol] of sortedMuscles) { + console.log(` ${muscle}: ${vol.toLocaleString()}${unit}`); + } + } + } + ); +} + +export function createProgressionCommand(): Command { + return new Command('progression') + .description('Show progression over time for an exercise') + .argument('', 'Exercise ID') + .option('-n, --last ', 'Show last N sessions', '10') + .option('--json', 'Output as JSON') + .action((exerciseId: string, options: { last: string; json?: boolean }) => { + const storage = getStorage(); + const config = storage.getConfig(); + const unit = config.units; + const exercise = storage.getExercise(exerciseId); + + if (!exercise) { + console.error(`Exercise "${exerciseId}" not found.`); + process.exit(1); + } + + const history = storage.getExerciseHistory(exercise.id); + const limit = parseInt(options.last, 10); + const limited = history.slice(0, limit).reverse(); + + if (limited.length === 0) { + console.log(`No history for ${exercise.name}.`); + return; + } + + const progressionData = limited.map(({ workout, log }) => { + const bestSet = log.sets.reduce((best, set) => { + const e1rm = calculateE1RM(set.weight, set.reps); + const bestE1rm = calculateE1RM(best.weight, best.reps); + return e1rm > bestE1rm ? set : best; + }, log.sets[0]!); + + const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps, 0); + const e1rm = calculateE1RM(bestSet.weight, bestSet.reps); + + return { + date: workout.date, + sets: log.sets.length, + bestWeight: bestSet.weight, + bestReps: bestSet.reps, + e1rm, + totalVolume, + }; + }); + + if (options.json) { + console.log( + JSON.stringify({ exercise: exercise.name, progression: progressionData }, null, 2) + ); + return; + } + + console.log(`Progression for ${exercise.name}:`); + console.log(''); + + const first = progressionData[0]; + const last = progressionData[progressionData.length - 1]; + + if (first && last && progressionData.length > 1) { + const e1rmChange = last.e1rm - first.e1rm; + const sign = e1rmChange >= 0 ? '+' : ''; + console.log(`Est. 1RM change: ${sign}${e1rmChange}${unit} (${first.e1rm} → ${last.e1rm})`); + console.log(''); + } + + console.log('Date | Best Set | Est 1RM | Volume'); + console.log('-----------|------------------|---------|--------'); + for (const entry of progressionData) { + const bestStr = `${entry.bestWeight}${unit} x ${entry.bestReps}`.padEnd(16); + const e1rmStr = `${entry.e1rm}${unit}`.padEnd(7); + console.log( + `${entry.date} | ${bestStr} | ${e1rmStr} | ${entry.totalVolume.toLocaleString()}${unit}` + ); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 7939db3..0045c69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,18 @@ import { createCancelCommand, } from './commands/session.js'; import { createLastCommand, createHistoryCommand } from './commands/history.js'; +import { + createPRCommand, + createVolumeCommand, + createProgressionCommand, +} from './commands/analytics.js'; const program = new Command(); program .name('workout') .description('CLI for tracking workouts, managing exercises, and querying training history') - .version('0.1.0'); + .version('0.2.0'); program.addCommand(createExercisesCommand()); program.addCommand(createTemplatesCommand()); @@ -27,5 +32,8 @@ program.addCommand(createDoneCommand()); program.addCommand(createCancelCommand()); program.addCommand(createLastCommand()); program.addCommand(createHistoryCommand()); +program.addCommand(createPRCommand()); +program.addCommand(createVolumeCommand()); +program.addCommand(createProgressionCommand()); program.parse(); diff --git a/test/analytics.test.ts b/test/analytics.test.ts new file mode 100644 index 0000000..3ea246b --- /dev/null +++ b/test/analytics.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Storage, resetStorage } from '../src/data/storage.js'; + +function cli(args: string, dataDir: string): { stdout: string; exitCode: number } { + try { + const stdout = execSync(`bun run src/index.ts ${args}`, { + cwd: process.cwd(), + env: { ...process.env, HOME: dataDir }, + encoding: 'utf-8', + }); + return { stdout, exitCode: 0 }; + } catch (err) { + const error = err as { stdout?: string; status?: number }; + return { + stdout: error.stdout ?? '', + exitCode: error.status ?? 1, + }; + } +} + +describe('PR tracking', () => { + let testHome: string; + let storage: Storage; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(path.join(testHome, '.workout')); + }); + + afterEach(() => { + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + it('shows no PRs when no workouts exist', () => { + const { stdout, exitCode } = cli('pr', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('No personal records found'); + }); + + it('tracks PRs based on estimated 1RM', () => { + storage.finishWorkout({ + id: '2026-01-20-push', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [ + { + exercise: 'bench-press', + sets: [ + { weight: 135, reps: 10, rir: null }, + { weight: 155, reps: 5, rir: null }, + ], + }, + ], + notes: [], + }); + + const { stdout, exitCode } = cli('pr', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Bench Press'); + expect(stdout).toContain('155'); + }); + + it('updates PR when new record is set', () => { + storage.finishWorkout({ + id: '2026-01-20-push', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }], + notes: [], + }); + + storage.finishWorkout({ + id: '2026-01-22-push', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: '2026-01-22T11:00:00Z', + exercises: [{ exercise: 'bench-press', sets: [{ weight: 185, reps: 5, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('pr bench-press', testHome); + expect(stdout).toContain('185'); + expect(stdout).toContain('2026-01-22'); + }); + + it('filters PRs by muscle group', () => { + storage.finishWorkout({ + id: '2026-01-20-full', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [ + { exercise: 'bench-press', sets: [{ weight: 185, reps: 5, rir: null }] }, + { exercise: 'squat', sets: [{ weight: 225, reps: 5, rir: null }] }, + ], + notes: [], + }); + + const { stdout: chestPRs } = cli('pr --muscle chest', testHome); + expect(chestPRs).toContain('Bench Press'); + expect(chestPRs).not.toContain('Squat'); + + const { stdout: legPRs } = cli('pr --muscle quads', testHome); + expect(legPRs).toContain('Squat'); + expect(legPRs).not.toContain('Bench Press'); + }); + + it('outputs JSON format', () => { + storage.finishWorkout({ + id: '2026-01-20-push', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bench-press', sets: [{ weight: 185, reps: 5, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('pr --json', testHome); + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].exercise).toBe('bench-press'); + expect(parsed[0].e1rm).toBeGreaterThan(185); + }); +}); + +describe('Volume analysis', () => { + let testHome: string; + let storage: Storage; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(path.join(testHome, '.workout')); + }); + + afterEach(() => { + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + it('shows no workouts message when empty', () => { + const { stdout, exitCode } = cli('volume', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('No workouts found'); + }); + + it('calculates total volume', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-push`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [ + { + exercise: 'bench-press', + sets: [ + { weight: 135, reps: 10, rir: null }, + { weight: 135, reps: 10, rir: null }, + { weight: 135, reps: 10, rir: null }, + ], + }, + ], + notes: [], + }); + + const { stdout } = cli('volume --week', testHome); + expect(stdout).toContain('Total sets: 3'); + expect(stdout).toContain('4,050'); // 135 * 10 * 3 = 4050 + }); + + it('groups by muscle', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-full`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [ + { exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }, + { exercise: 'squat', sets: [{ weight: 185, reps: 10, rir: null }] }, + ], + notes: [], + }); + + const { stdout } = cli('volume --week --by muscle', testHome); + expect(stdout).toContain('By Muscle Group'); + expect(stdout).toContain('chest'); + expect(stdout).toContain('quads'); + }); + + it('groups by exercise', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-push`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [ + { + exercise: 'bench-press', + sets: [ + { weight: 135, reps: 10, rir: null }, + { weight: 135, reps: 8, rir: null }, + ], + }, + ], + notes: [], + }); + + const { stdout } = cli('volume --week --by exercise', testHome); + expect(stdout).toContain('By Exercise'); + expect(stdout).toContain('Bench Press'); + expect(stdout).toContain('2 sets'); + }); + + it('outputs JSON format', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-push`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [{ exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('volume --week --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.totalSets).toBe(1); + expect(parsed.totalVolume).toBe(1350); + expect(parsed.byMuscle.chest).toBeDefined(); + }); +}); + +describe('Progression tracking', () => { + let testHome: string; + let storage: Storage; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(path.join(testHome, '.workout')); + }); + + afterEach(() => { + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + it('shows no history message when empty', () => { + const { stdout, exitCode } = cli('progression bench-press', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('No history'); + }); + + it('shows progression over time', () => { + const dates = ['2026-01-15', '2026-01-18', '2026-01-22']; + const weights = [135, 140, 145]; + + for (let i = 0; i < dates.length; i++) { + storage.finishWorkout({ + id: `${dates[i]}-push`, + date: dates[i]!, + template: null, + startTime: `${dates[i]}T10:00:00Z`, + endTime: `${dates[i]}T11:00:00Z`, + exercises: [ + { exercise: 'bench-press', sets: [{ weight: weights[i]!, reps: 8, rir: null }] }, + ], + notes: [], + }); + } + + const { stdout } = cli('progression bench-press', testHome); + expect(stdout).toContain('Progression for Bench Press'); + expect(stdout).toContain('2026-01-15'); + expect(stdout).toContain('2026-01-22'); + expect(stdout).toContain('135'); + expect(stdout).toContain('145'); + }); + + it('calculates e1rm change', () => { + storage.finishWorkout({ + id: '2026-01-15-push', + date: '2026-01-15', + template: null, + startTime: '2026-01-15T10:00:00Z', + endTime: '2026-01-15T11:00:00Z', + exercises: [{ exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }], + notes: [], + }); + + storage.finishWorkout({ + id: '2026-01-22-push', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: '2026-01-22T11:00:00Z', + exercises: [{ exercise: 'bench-press', sets: [{ weight: 155, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('progression bench-press', testHome); + expect(stdout).toContain('Est. 1RM change'); + expect(stdout).toContain('+'); + }); + + it('outputs JSON format', () => { + storage.finishWorkout({ + id: '2026-01-20-push', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('progression bench-press --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.exercise).toBe('Bench Press'); + expect(parsed.progression).toHaveLength(1); + expect(parsed.progression[0].bestWeight).toBe(135); + expect(parsed.progression[0].e1rm).toBeGreaterThan(135); + }); +});