Skip to content

Commit 2fe9d35

Browse files
authored
feat: add per-side weight input for volume calculations (#7)
* feat: integrate warden for automated code review Add warden.toml with code-simplifier (getsentry/skills) and find-bugs (getsentry/warden) triggers. Update validate skill to run warden -v after tests pass. Address warden findings: extract shared exercise parsing helper and add schema validation in updateTemplate. * feat: add per-side weight input tracking for volume calculations Dumbbell exercises now correctly report total weight moved by applying a 2x multiplier in volume calculations. PRs and E1RM remain based on input weight. Adds weightInput field to Exercise schema, tags 6 dumbbell exercises as per-side, and updates CLI commands.
1 parent ae2e00e commit 2fe9d35

11 files changed

Lines changed: 171 additions & 15 deletions

File tree

.claude/agents.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Agent Workflow
2+
3+
## Warden Code Review
4+
5+
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.
6+
7+
### When to run
8+
9+
- After all code changes are complete and `bun run validate` passes
10+
- Before creating a pull request
11+
- Only once per change set (no need to re-run after fixing warden findings unless substantial new code was added)
12+
13+
### Workflow
14+
15+
```
16+
make changes → bun run validate → npx @sentry/warden -v → fix findings → create PR
17+
```
18+
19+
Skipping warden risks shipping code that warden will flag during PR review, creating unnecessary back-and-forth. Always run it locally first.

.claude/skills/create-pr.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ Create a pull request for the current branch.
2020
bun run validate
2121
```
2222

23-
2. Push branch if needed:
23+
2. Ensure warden has been run and findings addressed (see validate skill). Do not re-run if already done.
24+
25+
3. Push branch if needed:
2426
```bash
2527
git push -u origin HEAD
2628
```
2729

28-
3. Create PR with gh CLI:
30+
4. Create PR with gh CLI:
2931
```bash
3032
gh pr create --title "Brief title" --body "## Summary
3133
- Change 1

.claude/skills/validate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Run the full validation suite and code review to ensure code quality.
2121

2222
2. If validation passes, run warden for code review feedback:
2323
```bash
24-
warden -v
24+
npx @sentry/warden -v
2525
```
2626
The `-v` flag streams findings in real-time (code simplification, bug detection).
2727
Fix any issues warden finds before proceeding.

src/commands/analytics.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,10 @@ export function createVolumeCommand(getProfile: () => string | undefined): Comma
189189

190190
let exerciseSets = 0;
191191
let exerciseVol = 0;
192+
const multiplier = exercise.weightInput === 'per-side' ? 2 : 1;
192193

193194
for (const set of log.sets) {
194-
const vol = set.weight * set.reps;
195+
const vol = set.weight * set.reps * multiplier;
195196
totalSets++;
196197
totalVolume += vol;
197198
exerciseSets++;
@@ -294,14 +295,15 @@ export function createProgressionCommand(getProfile: () => string | undefined):
294295
return;
295296
}
296297

298+
const multiplier = exercise.weightInput === 'per-side' ? 2 : 1;
297299
const progressionData = limited.map(({ workout, log }) => {
298300
const bestSet = log.sets.reduce((best, set) => {
299301
const e1rm = calculateE1RM(set.weight, set.reps);
300302
const bestE1rm = calculateE1RM(best.weight, best.reps);
301303
return e1rm > bestE1rm ? set : best;
302304
}, log.sets[0]!);
303305

304-
const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps, 0);
306+
const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps * multiplier, 0);
305307
const e1rm = calculateE1RM(bestSet.weight, bestSet.reps);
306308

307309
return {

src/commands/exercises.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type MuscleGroup,
77
type ExerciseType,
88
type Equipment,
9+
type WeightInput,
910
} from '../types.js';
1011

1112
export function createExercisesCommand(_getProfile: () => string | undefined): Command {
@@ -68,6 +69,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
6869
console.log(`ID: ${exercise.id}`);
6970
console.log(`Type: ${exercise.type}`);
7071
console.log(`Equipment: ${exercise.equipment}`);
72+
console.log(`Weight input: ${exercise.weightInput}`);
7173
console.log(`Muscles: ${exercise.muscles.join(', ')}`);
7274
if (exercise.aliases.length > 0) {
7375
console.log(`Aliases: ${exercise.aliases.join(', ')}`);
@@ -85,6 +87,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
8587
.requiredOption('--equipment <equipment>', 'Equipment type')
8688
.option('--id <id>', 'Custom ID (defaults to slugified name)')
8789
.option('--aliases <aliases>', 'Comma-separated aliases')
90+
.option('--weight-input <type>', 'Weight input type (total or per-side)', 'total')
8891
.option('--notes <notes>', 'Exercise notes')
8992
.action(
9093
(
@@ -95,6 +98,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
9598
equipment: string;
9699
id?: string;
97100
aliases?: string;
101+
weightInput?: string;
98102
notes?: string;
99103
}
100104
) => {
@@ -110,6 +114,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
110114
muscles,
111115
type: options.type as ExerciseType,
112116
equipment: options.equipment as Equipment,
117+
weightInput: options.weightInput as WeightInput,
113118
notes: options.notes,
114119
});
115120

@@ -132,6 +137,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
132137
.option('--equipment <equipment>', 'New equipment')
133138
.option('--add-alias <alias>', 'Add an alias')
134139
.option('--remove-alias <alias>', 'Remove an alias')
140+
.option('--weight-input <type>', 'Weight input type (total or per-side)')
135141
.option('--notes <notes>', 'New notes')
136142
.action(
137143
(
@@ -141,6 +147,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
141147
muscles?: string;
142148
type?: string;
143149
equipment?: string;
150+
weightInput?: string;
144151
addAlias?: string;
145152
removeAlias?: string;
146153
notes?: string;
@@ -168,6 +175,9 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
168175
if (options.equipment) {
169176
updates.equipment = options.equipment as Equipment;
170177
}
178+
if (options.weightInput) {
179+
updates.weightInput = options.weightInput as WeightInput;
180+
}
171181
if (options.notes) {
172182
updates.notes = options.notes;
173183
}

src/commands/session.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ function calculateStats(workout: Workout, storage: ReturnType<typeof getStorage>
1717
const musclesSet = new Set<string>();
1818

1919
for (const exerciseLog of workout.exercises) {
20+
const exercise = storage.getExercise(exerciseLog.exercise);
21+
if (!exercise) continue;
22+
2023
totalSets += exerciseLog.sets.length;
24+
const multiplier = exercise.weightInput === 'per-side' ? 2 : 1;
2125
for (const set of exerciseLog.sets) {
22-
totalVolume += set.weight * set.reps;
26+
totalVolume += set.weight * set.reps * multiplier;
2327
}
24-
const exercise = storage.getExercise(exerciseLog.exercise);
25-
if (exercise) {
26-
for (const muscle of exercise.muscles) {
27-
musclesSet.add(muscle);
28-
}
28+
for (const muscle of exercise.muscles) {
29+
musclesSet.add(muscle);
2930
}
3031
}
3132

src/data/storage.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ export class Storage {
8181
this.ensureDir();
8282
const exercisesPath = this.exercisesPath();
8383
if (!fs.existsSync(exercisesPath)) {
84-
fs.writeFileSync(exercisesPath, JSON.stringify(defaultExercises, null, 2));
85-
return defaultExercises;
84+
const parsed = defaultExercises.map((e) => Exercise.parse(e));
85+
fs.writeFileSync(exercisesPath, JSON.stringify(parsed, null, 2));
86+
return parsed;
8687
}
8788
const raw = JSON.parse(fs.readFileSync(exercisesPath, 'utf-8'));
8889
return raw.map((e: unknown) => Exercise.parse(e));

src/exercises.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { Exercise } from './types.js';
1+
import type { ExerciseInput } from './types.js';
22

3-
export const defaultExercises: Exercise[] = [
3+
export const defaultExercises: ExerciseInput[] = [
44
{
55
id: 'bench-press',
66
name: 'Bench Press',
@@ -24,6 +24,7 @@ export const defaultExercises: Exercise[] = [
2424
muscles: ['chest', 'triceps', 'front-delts'],
2525
type: 'compound',
2626
equipment: 'dumbbell',
27+
weightInput: 'per-side',
2728
},
2829
{
2930
id: 'overhead-press',
@@ -40,6 +41,7 @@ export const defaultExercises: Exercise[] = [
4041
muscles: ['shoulders', 'triceps', 'front-delts'],
4142
type: 'compound',
4243
equipment: 'dumbbell',
44+
weightInput: 'per-side',
4345
},
4446
{
4547
id: 'squat',
@@ -120,6 +122,7 @@ export const defaultExercises: Exercise[] = [
120122
muscles: ['side-delts', 'shoulders'],
121123
type: 'isolation',
122124
equipment: 'dumbbell',
125+
weightInput: 'per-side',
123126
},
124127
{
125128
id: 'rear-delt-fly',
@@ -128,6 +131,7 @@ export const defaultExercises: Exercise[] = [
128131
muscles: ['rear-delts', 'back'],
129132
type: 'isolation',
130133
equipment: 'dumbbell',
134+
weightInput: 'per-side',
131135
},
132136
{
133137
id: 'bicep-curl',
@@ -136,6 +140,7 @@ export const defaultExercises: Exercise[] = [
136140
muscles: ['biceps'],
137141
type: 'isolation',
138142
equipment: 'dumbbell',
143+
weightInput: 'per-side',
139144
},
140145
{
141146
id: 'barbell-curl',
@@ -152,6 +157,7 @@ export const defaultExercises: Exercise[] = [
152157
muscles: ['biceps', 'forearms'],
153158
type: 'isolation',
154159
equipment: 'dumbbell',
160+
weightInput: 'per-side',
155161
},
156162
{
157163
id: 'tricep-pushdown',

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,21 @@ export const Equipment = z.enum([
3535
]);
3636
export type Equipment = z.infer<typeof Equipment>;
3737

38+
export const WeightInput = z.enum(['total', 'per-side']);
39+
export type WeightInput = z.infer<typeof WeightInput>;
40+
3841
export const Exercise = z.object({
3942
id: z.string(),
4043
name: z.string(),
4144
aliases: z.array(z.string()).default([]),
4245
muscles: z.array(MuscleGroup),
4346
type: ExerciseType,
4447
equipment: Equipment,
48+
weightInput: WeightInput.default('total'),
4549
notes: z.string().optional(),
4650
});
4751
export type Exercise = z.infer<typeof Exercise>;
52+
export type ExerciseInput = z.input<typeof Exercise>;
4853

4954
export const TemplateExercise = z.object({
5055
exercise: z.string(),

test/analytics.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,88 @@ describe('Progression tracking', () => {
323323
expect(parsed.progression).toHaveLength(1);
324324
});
325325
});
326+
327+
describe('Per-side weight input', () => {
328+
let testHome: string;
329+
let storage: Storage;
330+
331+
beforeEach(() => {
332+
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-'));
333+
process.env.HOME = testHome;
334+
resetStorage();
335+
createProfile('default');
336+
storage = getStorage('default');
337+
});
338+
339+
afterEach(() => {
340+
process.env.HOME = originalHome;
341+
fs.rmSync(testHome, { recursive: true, force: true });
342+
});
343+
344+
it('doubles volume for per-side exercises', () => {
345+
const today = new Date().toISOString().split('T')[0]!;
346+
storage.finishWorkout({
347+
id: `${today}-arms`,
348+
date: today,
349+
template: null,
350+
startTime: `${today}T10:00:00Z`,
351+
endTime: `${today}T11:00:00Z`,
352+
exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 25, reps: 10, rir: null }] }],
353+
notes: [],
354+
});
355+
356+
const { stdout } = cli('volume --week --json', testHome);
357+
const parsed = JSON.parse(stdout);
358+
expect(parsed.totalVolume).toBe(500);
359+
});
360+
361+
it('does not double volume for barbell exercises', () => {
362+
const today = new Date().toISOString().split('T')[0]!;
363+
storage.finishWorkout({
364+
id: `${today}-push`,
365+
date: today,
366+
template: null,
367+
startTime: `${today}T10:00:00Z`,
368+
endTime: `${today}T11:00:00Z`,
369+
exercises: [{ exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }],
370+
notes: [],
371+
});
372+
373+
const { stdout } = cli('volume --week --json', testHome);
374+
const parsed = JSON.parse(stdout);
375+
expect(parsed.totalVolume).toBe(1350);
376+
});
377+
378+
it('PRs use input weight not doubled', () => {
379+
storage.finishWorkout({
380+
id: '2026-01-20-arms',
381+
date: '2026-01-20',
382+
template: null,
383+
startTime: '2026-01-20T10:00:00Z',
384+
endTime: '2026-01-20T11:00:00Z',
385+
exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 30, reps: 10, rir: null }] }],
386+
notes: [],
387+
});
388+
389+
const { stdout } = cli('pr bicep-curl --json', testHome);
390+
const parsed = JSON.parse(stdout);
391+
expect(parsed[0].weight).toBe(30);
392+
expect(parsed[0].e1rm).toBe(Math.round(30 * (1 + 10 / 30)));
393+
});
394+
395+
it('progression volume is doubled for per-side exercises', () => {
396+
storage.finishWorkout({
397+
id: '2026-01-20-arms',
398+
date: '2026-01-20',
399+
template: null,
400+
startTime: '2026-01-20T10:00:00Z',
401+
endTime: '2026-01-20T11:00:00Z',
402+
exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 25, reps: 10, rir: null }] }],
403+
notes: [],
404+
});
405+
406+
const { stdout } = cli('progression bicep-curl --json', testHome);
407+
const parsed = JSON.parse(stdout);
408+
expect(parsed.progression[0].totalVolume).toBe(500);
409+
});
410+
});

0 commit comments

Comments
 (0)