Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/agents.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions .claude/skills/create-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/commands/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down Expand Up @@ -294,14 +295,15 @@ 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);
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 totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps * multiplier, 0);
const e1rm = calculateE1RM(bestSet.weight, bestSet.reps);

return {
Expand Down
10 changes: 10 additions & 0 deletions src/commands/exercises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type MuscleGroup,
type ExerciseType,
type Equipment,
type WeightInput,
} from '../types.js';

export function createExercisesCommand(_getProfile: () => string | undefined): Command {
Expand Down Expand Up @@ -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(', ')}`);
Expand All @@ -85,6 +87,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
.requiredOption('--equipment <equipment>', 'Equipment type')
.option('--id <id>', 'Custom ID (defaults to slugified name)')
.option('--aliases <aliases>', 'Comma-separated aliases')
.option('--weight-input <type>', 'Weight input type (total or per-side)', 'total')
.option('--notes <notes>', 'Exercise notes')
.action(
(
Expand All @@ -95,6 +98,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
equipment: string;
id?: string;
aliases?: string;
weightInput?: string;
notes?: string;
}
) => {
Expand All @@ -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,
});

Expand All @@ -132,6 +137,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C
.option('--equipment <equipment>', 'New equipment')
.option('--add-alias <alias>', 'Add an alias')
.option('--remove-alias <alias>', 'Remove an alias')
.option('--weight-input <type>', 'Weight input type (total or per-side)')
.option('--notes <notes>', 'New notes')
.action(
(
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 7 additions & 6 deletions src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@ function calculateStats(workout: Workout, storage: ReturnType<typeof getStorage>
const musclesSet = new Set<string>();

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);
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/data/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
10 changes: 8 additions & 2 deletions src/exercises.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -24,6 +24,7 @@ export const defaultExercises: Exercise[] = [
muscles: ['chest', 'triceps', 'front-delts'],
type: 'compound',
equipment: 'dumbbell',
weightInput: 'per-side',
},
{
id: 'overhead-press',
Expand All @@ -40,6 +41,7 @@ export const defaultExercises: Exercise[] = [
muscles: ['shoulders', 'triceps', 'front-delts'],
type: 'compound',
equipment: 'dumbbell',
weightInput: 'per-side',
},
{
id: 'squat',
Expand Down Expand Up @@ -120,6 +122,7 @@ export const defaultExercises: Exercise[] = [
muscles: ['side-delts', 'shoulders'],
type: 'isolation',
equipment: 'dumbbell',
weightInput: 'per-side',
},
{
id: 'rear-delt-fly',
Expand All @@ -128,6 +131,7 @@ export const defaultExercises: Exercise[] = [
muscles: ['rear-delts', 'back'],
type: 'isolation',
equipment: 'dumbbell',
weightInput: 'per-side',
},
{
id: 'bicep-curl',
Expand All @@ -136,6 +140,7 @@ export const defaultExercises: Exercise[] = [
muscles: ['biceps'],
type: 'isolation',
equipment: 'dumbbell',
weightInput: 'per-side',
},
{
id: 'barbell-curl',
Expand All @@ -152,6 +157,7 @@ export const defaultExercises: Exercise[] = [
muscles: ['biceps', 'forearms'],
type: 'isolation',
equipment: 'dumbbell',
weightInput: 'per-side',
},
{
id: 'tricep-pushdown',
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,21 @@ export const Equipment = z.enum([
]);
export type Equipment = z.infer<typeof Equipment>;

export const WeightInput = z.enum(['total', 'per-side']);
export type WeightInput = z.infer<typeof WeightInput>;

export const Exercise = z.object({
id: z.string(),
name: z.string(),
aliases: z.array(z.string()).default([]),
muscles: z.array(MuscleGroup),
type: ExerciseType,
equipment: Equipment,
weightInput: WeightInput.default('total'),
notes: z.string().optional(),
});
export type Exercise = z.infer<typeof Exercise>;
export type ExerciseInput = z.input<typeof Exercise>;

export const TemplateExercise = z.object({
exercise: z.string(),
Expand Down
85 changes: 85 additions & 0 deletions test/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
25 changes: 25 additions & 0 deletions test/exercises.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading