diff --git a/.claude/agents.md b/.claude/agents.md new file mode 100644 index 0000000..73102e6 --- /dev/null +++ b/.claude/agents.md @@ -0,0 +1,19 @@ +# Agent Workflow + +## Warden Code Review + +Running `npx @sentry/warden -v` is **required** once before creating a PR. Warden performs automated code review (simplification, bug detection) and its findings must be addressed before submitting. It only needs to be run once — do not re-run when creating the PR if it has already passed. + +### When to run + +- After all code changes are complete and `bun run validate` passes +- Before creating a pull request +- Only once per change set (no need to re-run after fixing warden findings unless substantial new code was added) + +### Workflow + +``` +make changes → bun run validate → npx @sentry/warden -v → fix findings → create PR +``` + +Skipping warden risks shipping code that warden will flag during PR review, creating unnecessary back-and-forth. Always run it locally first. diff --git a/.claude/skills/create-pr.md b/.claude/skills/create-pr.md index 4ee9f12..7aeb64b 100644 --- a/.claude/skills/create-pr.md +++ b/.claude/skills/create-pr.md @@ -20,12 +20,14 @@ Create a pull request for the current branch. bun run validate ``` -2. Push branch if needed: +2. Ensure warden has been run and findings addressed (see validate skill). Do not re-run if already done. + +3. Push branch if needed: ```bash git push -u origin HEAD ``` -3. Create PR with gh CLI: +4. Create PR with gh CLI: ```bash gh pr create --title "Brief title" --body "## Summary - Change 1 diff --git a/.claude/skills/validate.md b/.claude/skills/validate.md index 2dc7782..a979829 100644 --- a/.claude/skills/validate.md +++ b/.claude/skills/validate.md @@ -21,7 +21,7 @@ Run the full validation suite and code review to ensure code quality. 2. If validation passes, run warden for code review feedback: ```bash - warden -v + npx @sentry/warden -v ``` The `-v` flag streams findings in real-time (code simplification, bug detection). Fix any issues warden finds before proceeding. diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index c271fbf..ee63b65 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -189,9 +189,10 @@ export function createVolumeCommand(getProfile: () => string | undefined): Comma let exerciseSets = 0; let exerciseVol = 0; + const multiplier = exercise.weightInput === 'per-side' ? 2 : 1; for (const set of log.sets) { - const vol = set.weight * set.reps; + const vol = set.weight * set.reps * multiplier; totalSets++; totalVolume += vol; exerciseSets++; @@ -294,6 +295,7 @@ export function createProgressionCommand(getProfile: () => string | undefined): return; } + const multiplier = exercise.weightInput === 'per-side' ? 2 : 1; const progressionData = limited.map(({ workout, log }) => { const bestSet = log.sets.reduce((best, set) => { const e1rm = calculateE1RM(set.weight, set.reps); @@ -301,7 +303,7 @@ export function createProgressionCommand(getProfile: () => string | undefined): return e1rm > bestE1rm ? set : best; }, log.sets[0]!); - const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps, 0); + const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps * multiplier, 0); const e1rm = calculateE1RM(bestSet.weight, bestSet.reps); return { diff --git a/src/commands/exercises.ts b/src/commands/exercises.ts index 3b32050..8e68ffd 100644 --- a/src/commands/exercises.ts +++ b/src/commands/exercises.ts @@ -6,6 +6,7 @@ import { type MuscleGroup, type ExerciseType, type Equipment, + type WeightInput, } from '../types.js'; export function createExercisesCommand(_getProfile: () => string | undefined): Command { @@ -68,6 +69,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C console.log(`ID: ${exercise.id}`); console.log(`Type: ${exercise.type}`); console.log(`Equipment: ${exercise.equipment}`); + console.log(`Weight input: ${exercise.weightInput}`); console.log(`Muscles: ${exercise.muscles.join(', ')}`); if (exercise.aliases.length > 0) { console.log(`Aliases: ${exercise.aliases.join(', ')}`); @@ -85,6 +87,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C .requiredOption('--equipment ', 'Equipment type') .option('--id ', 'Custom ID (defaults to slugified name)') .option('--aliases ', 'Comma-separated aliases') + .option('--weight-input ', 'Weight input type (total or per-side)', 'total') .option('--notes ', 'Exercise notes') .action( ( @@ -95,6 +98,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C equipment: string; id?: string; aliases?: string; + weightInput?: string; notes?: string; } ) => { @@ -110,6 +114,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C muscles, type: options.type as ExerciseType, equipment: options.equipment as Equipment, + weightInput: options.weightInput as WeightInput, notes: options.notes, }); @@ -132,6 +137,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C .option('--equipment ', 'New equipment') .option('--add-alias ', 'Add an alias') .option('--remove-alias ', 'Remove an alias') + .option('--weight-input ', 'Weight input type (total or per-side)') .option('--notes ', 'New notes') .action( ( @@ -141,6 +147,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C muscles?: string; type?: string; equipment?: string; + weightInput?: string; addAlias?: string; removeAlias?: string; notes?: string; @@ -168,6 +175,9 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C if (options.equipment) { updates.equipment = options.equipment as Equipment; } + if (options.weightInput) { + updates.weightInput = options.weightInput as WeightInput; + } if (options.notes) { updates.notes = options.notes; } diff --git a/src/commands/session.ts b/src/commands/session.ts index 0ee0498..8326f2d 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -17,15 +17,16 @@ function calculateStats(workout: Workout, storage: ReturnType const musclesSet = new Set(); for (const exerciseLog of workout.exercises) { + const exercise = storage.getExercise(exerciseLog.exercise); + if (!exercise) continue; + totalSets += exerciseLog.sets.length; + const multiplier = exercise.weightInput === 'per-side' ? 2 : 1; for (const set of exerciseLog.sets) { - totalVolume += set.weight * set.reps; + totalVolume += set.weight * set.reps * multiplier; } - const exercise = storage.getExercise(exerciseLog.exercise); - if (exercise) { - for (const muscle of exercise.muscles) { - musclesSet.add(muscle); - } + for (const muscle of exercise.muscles) { + musclesSet.add(muscle); } } diff --git a/src/data/storage.ts b/src/data/storage.ts index b702e75..1056ccf 100644 --- a/src/data/storage.ts +++ b/src/data/storage.ts @@ -81,8 +81,9 @@ export class Storage { this.ensureDir(); const exercisesPath = this.exercisesPath(); if (!fs.existsSync(exercisesPath)) { - fs.writeFileSync(exercisesPath, JSON.stringify(defaultExercises, null, 2)); - return defaultExercises; + const parsed = defaultExercises.map((e) => Exercise.parse(e)); + fs.writeFileSync(exercisesPath, JSON.stringify(parsed, null, 2)); + return parsed; } const raw = JSON.parse(fs.readFileSync(exercisesPath, 'utf-8')); return raw.map((e: unknown) => Exercise.parse(e)); diff --git a/src/exercises.ts b/src/exercises.ts index f1f4ba3..0cf91c5 100644 --- a/src/exercises.ts +++ b/src/exercises.ts @@ -1,6 +1,6 @@ -import type { Exercise } from './types.js'; +import type { ExerciseInput } from './types.js'; -export const defaultExercises: Exercise[] = [ +export const defaultExercises: ExerciseInput[] = [ { id: 'bench-press', name: 'Bench Press', @@ -24,6 +24,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['chest', 'triceps', 'front-delts'], type: 'compound', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'overhead-press', @@ -40,6 +41,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['shoulders', 'triceps', 'front-delts'], type: 'compound', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'squat', @@ -120,6 +122,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['side-delts', 'shoulders'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'rear-delt-fly', @@ -128,6 +131,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['rear-delts', 'back'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'bicep-curl', @@ -136,6 +140,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['biceps'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'barbell-curl', @@ -152,6 +157,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['biceps', 'forearms'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'tricep-pushdown', diff --git a/src/types.ts b/src/types.ts index 94314ef..1a12ec9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,9 @@ export const Equipment = z.enum([ ]); export type Equipment = z.infer; +export const WeightInput = z.enum(['total', 'per-side']); +export type WeightInput = z.infer; + export const Exercise = z.object({ id: z.string(), name: z.string(), @@ -42,9 +45,11 @@ export const Exercise = z.object({ muscles: z.array(MuscleGroup), type: ExerciseType, equipment: Equipment, + weightInput: WeightInput.default('total'), notes: z.string().optional(), }); export type Exercise = z.infer; +export type ExerciseInput = z.input; export const TemplateExercise = z.object({ exercise: z.string(), diff --git a/test/analytics.test.ts b/test/analytics.test.ts index ccef3ce..1091f65 100644 --- a/test/analytics.test.ts +++ b/test/analytics.test.ts @@ -323,3 +323,88 @@ describe('Progression tracking', () => { expect(parsed.progression).toHaveLength(1); }); }); + +describe('Per-side weight input', () => { + let testHome: string; + let storage: Storage; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + process.env.HOME = testHome; + resetStorage(); + createProfile('default'); + storage = getStorage('default'); + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + it('doubles volume for per-side exercises', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-arms`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 25, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('volume --week --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.totalVolume).toBe(500); + }); + + it('does not double volume for barbell exercises', () => { + 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.totalVolume).toBe(1350); + }); + + it('PRs use input weight not doubled', () => { + storage.finishWorkout({ + id: '2026-01-20-arms', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 30, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('pr bicep-curl --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed[0].weight).toBe(30); + expect(parsed[0].e1rm).toBe(Math.round(30 * (1 + 10 / 30))); + }); + + it('progression volume is doubled for per-side exercises', () => { + storage.finishWorkout({ + id: '2026-01-20-arms', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 25, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('progression bicep-curl --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.progression[0].totalVolume).toBe(500); + }); +}); diff --git a/test/exercises.test.ts b/test/exercises.test.ts index fa38ad2..a586491 100644 --- a/test/exercises.test.ts +++ b/test/exercises.test.ts @@ -36,6 +36,31 @@ describe('defaultExercises', () => { expect(uniqueIds.size).toBe(ids.length); }); + it('dumbbell exercises have per-side weightInput', () => { + const perSideIds = [ + 'dumbbell-bench-press', + 'dumbbell-shoulder-press', + 'lateral-raise', + 'rear-delt-fly', + 'bicep-curl', + 'hammer-curl', + ]; + for (const id of perSideIds) { + const exercise = defaultExercises.find((e) => e.id === id); + expect(exercise, `${id} should exist`).toBeDefined(); + expect(exercise?.weightInput, `${id} should be per-side`).toBe('per-side'); + } + }); + + it('non-dumbbell exercises omit weightInput (defaults to total via schema)', () => { + const totalIds = ['bench-press', 'squat', 'deadlift', 'barbell-curl', 'leg-press']; + for (const id of totalIds) { + const exercise = defaultExercises.find((e) => e.id === id); + expect(exercise, `${id} should exist`).toBeDefined(); + expect(exercise?.weightInput, `${id} should not set weightInput`).toBeUndefined(); + } + }); + it('exercises have useful aliases', () => { const benchPress = defaultExercises.find((e) => e.id === 'bench-press'); expect(benchPress?.aliases).toContain('bench');