diff --git a/.claude/skills/create-pr.md b/.claude/skills/create-pr.md new file mode 100644 index 0000000..4ee9f12 --- /dev/null +++ b/.claude/skills/create-pr.md @@ -0,0 +1,51 @@ +--- +description: Create a pull request with a concise description. Use when ready to submit changes for review. +--- + +# Create PR + +Create a pull request for the current branch. + +## Prerequisites + +1. All changes committed +2. Branch pushed to remote +3. Validation passing (`bun run validate`) + +## Steps + +1. Ensure clean state and validation passes: + ```bash + git status + bun run validate + ``` + +2. Push branch if needed: + ```bash + git push -u origin HEAD + ``` + +3. Create PR with gh CLI: + ```bash + gh pr create --title "Brief title" --body "## Summary + - Change 1 + - Change 2" + ``` + +## PR Description Format + +Keep it concise: + +```markdown +## Summary +- 1-3 bullet points describing what changed and why + +## Test Plan +- How the changes were verified +``` + +## Notes + +- Title should be brief and descriptive (imperative mood) +- Body should focus on "why" not "what" (code shows the what) +- Link to issues if applicable: `Fixes #123` diff --git a/.claude/skills/release.md b/.claude/skills/release.md new file mode 100644 index 0000000..ac873b7 --- /dev/null +++ b/.claude/skills/release.md @@ -0,0 +1,53 @@ +--- +description: Cut a new version release. Use when ready to publish a new version. +--- + +# Release + +Create a new release by tagging a version. + +## Prerequisites + +1. All changes committed and pushed +2. CI passing on main branch +3. Version bump in package.json (if needed) + +## Steps + +1. Ensure clean state: + ```bash + git status + git pull origin main + ``` + +2. Update version in package.json if needed: + ```bash + # Edit package.json version field + bun run validate + git add package.json + git commit -m "Bump version to X.Y.Z" + git push + ``` + +3. Create and push tag: + ```bash + git tag v0.X.Y + git push origin v0.X.Y + ``` + +4. The release workflow will automatically: + - Build binaries for all platforms (linux, darwin, windows - x64 and arm64) + - Create GitHub release with artifacts + - Generate checksums + +## Version Guidelines + +- **Patch** (0.0.X): Bug fixes, minor changes +- **Minor** (0.X.0): New features, backwards compatible +- **Major** (X.0.0): Breaking changes + +## Notes + +- Tags must start with `v` (e.g., `v0.1.0`) +- Release notes are auto-generated from commits +- Binaries available via `install.sh` after release diff --git a/.claude/skills/test.md b/.claude/skills/test.md new file mode 100644 index 0000000..b52e55f --- /dev/null +++ b/.claude/skills/test.md @@ -0,0 +1,41 @@ +--- +description: Run targeted tests based on changed files. Use after making code changes to verify they work. +--- + +# Test + +Run tests for the workout CLI. + +## Commands + +| Command | What it runs | +|---------|--------------| +| `bun run test` | All tests once | +| `bun run test:watch` | Tests in watch mode | + +## Test Location + +- `test/*.test.ts` - All tests + +## Steps + +1. Check what changed: + ```bash + git diff --name-only HEAD + ``` + +2. Run tests: + ```bash + bun run test + ``` + +3. For specific test file: + ```bash + bun run test test/exercises.test.ts + ``` + +## Notes + +- Tests use vitest +- Focus on functionality, not coverage metrics +- Keep output concise - only report failures in detail diff --git a/.claude/skills/validate.md b/.claude/skills/validate.md new file mode 100644 index 0000000..72dfe37 --- /dev/null +++ b/.claude/skills/validate.md @@ -0,0 +1,30 @@ +--- +description: Run full validation (lint, format, typecheck, tests, build). Use before committing or after changes. +--- + +# Validate + +Run the full validation suite to ensure code quality. + +## Command + +```bash +bun run validate +``` + +This runs: +1. `oxlint` - Linting with type-aware rules +2. `oxfmt --check` - Format verification +3. `tsc --noEmit` - Type checking +4. `vitest run` - Unit tests +5. `tsc` - Build + +## When to Use + +- Before committing changes +- After making significant changes +- When CI fails and you need to reproduce locally + +## Expected Output + +All checks should pass with no errors. If any step fails, fix the issues before proceeding. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6eeaadb..d1d2564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,16 @@ jobs: with: bun-version: latest + - name: Cache bun dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'package.json') }} + restore-keys: | + ${{ runner.os }}-bun- + - name: Install dependencies run: bun install @@ -32,4 +42,26 @@ jobs: run: bun run test - name: Build - run: bun run build:ts + run: bun run build + + binary: + runs-on: ubuntu-latest + needs: validate + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Compile binary + run: bun run build:bin + + - name: Test binary runs + run: | + ./dist/workout --version + ./dist/workout --help + ./dist/workout exercises list | head -5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..819a655 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + binaries: + strategy: + fail-fast: false + matrix: + include: + - target: bun-linux-x64 + name: linux-x64 + archive: tar.gz + - target: bun-linux-arm64 + name: linux-arm64 + archive: tar.gz + - target: bun-darwin-x64 + name: darwin-x64 + archive: tar.gz + - target: bun-darwin-arm64 + name: darwin-arm64 + archive: tar.gz + - target: bun-windows-x64 + name: windows-x64 + archive: zip + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Set version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Compile binary + run: | + mkdir -p dist-binaries + BINARY_NAME="workout" + if [[ "${{ matrix.name }}" == windows-* ]]; then + BINARY_NAME="workout.exe" + fi + bun build ./src/index.ts --compile --target=${{ matrix.target }} --minify --outfile=dist-binaries/$BINARY_NAME + + - name: Create archive + run: | + VERSION=${{ steps.version.outputs.version }} + ARCHIVE_DIR="workout-${VERSION}-${{ matrix.name }}" + mkdir -p "$ARCHIVE_DIR" + + if [[ "${{ matrix.name }}" == windows-* ]]; then + cp dist-binaries/workout.exe "$ARCHIVE_DIR/" + else + cp dist-binaries/workout "$ARCHIVE_DIR/" + fi + + if [[ "${{ matrix.archive }}" == "tar.gz" ]]; then + tar -czvf "dist-binaries/workout-${VERSION}-${{ matrix.name }}.tar.gz" "$ARCHIVE_DIR" + else + zip -r "dist-binaries/workout-${VERSION}-${{ matrix.name }}.zip" "$ARCHIVE_DIR" + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.name }} + path: dist-binaries/workout-*.* + retention-days: 1 + + release: + needs: binaries + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist-binaries + pattern: binary-* + merge-multiple: true + + - name: Generate checksums + run: | + cd dist-binaries + sha256sum workout-*.tar.gz workout-*.zip > checksums.txt + cat checksums.txt + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + dist-binaries/*.tar.gz + dist-binaries/*.zip + dist-binaries/checksums.txt + fail_on_unmatched_files: true + generate_release_notes: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a95ede7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# Agent Instructions + +## Skills + +Use the `skill` tool for common workflows: + +| Skill | When to use | +|-------|-------------| +| `validate` | Before committing, after changes, to verify code quality | +| `test` | To run targeted tests for your changes | +| `create-pr` | To create a pull request with concise description | +| `release` | To cut a new version release | + +## Architecture + +Workout CLI is a command-line tool for tracking workouts, managing exercises, and querying training history. + +- **Runtime**: Bun (not Node.js) +- **Language**: TypeScript with ES modules, strict mode +- **CLI**: Commander.js for command parsing +- **Storage**: JSON files in `~/.workout/` +- **Validation**: Zod schemas + +## Project Structure + +``` +src/ +├── index.ts # CLI entry point +├── commands/ # Command implementations +├── data/ # Storage and data access +├── types.ts # Zod schemas and types +└── exercises.ts # Pre-populated exercise library +test/ +└── *.test.ts # Vitest tests +``` + +## Code Style + +- Fight entropy - leave the codebase better than you found it +- Prefer simpler solutions where it reasonably makes sense +- Minimal dependencies +- Early returns, fail fast +- TypeScript strict mode +- No comments in code (self-documenting) + +## Testing Philosophy + +- Focus on functionality testing, not coverage metrics +- Test behavior, not implementation details +- Write tests for commands and data operations +- Don't chase vanity metrics + +## Constraints + +- No skipping failing tests +- Run `bun run validate` before committing +- Pre-commit hooks run linting and tests automatically diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..740c3f9 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,287 @@ +# Workout CLI Spec + +A command-line tool for tracking workouts, managing exercises, and querying training history. + +## Goals + +- Fast set logging (one command per set) +- Predefined exercise library with muscle group tagging +- Workout templates (Push, Pull, Legs, etc.) +- Progression tracking and PR detection +- Data export for visualization +- Natural querying of history + +--- + +## CLI Commands + +### Exercise Library + +```bash +# List all exercises +workout exercises list +workout exercises list --muscle back +workout exercises list --type compound + +# Add new exercise +workout exercises add "lat-pulldown" \ + --muscles lats,back \ + --type isolation \ + --equipment cable + +# Get exercise details +workout exercises show lat-pulldown + +# Edit exercise +workout exercises edit lat-pulldown --add-alias "lat pull" +``` + +### Templates + +```bash +# List templates +workout templates list + +# Show template details +workout templates show pull-a + +# Create template +workout templates create pull-b \ + --exercises "lat-pulldown:3x8-12, cable-row:3x8-12, face-pulls:3x15-20" + +# Clone and modify +workout templates create pull-b --from pull-a --remove deadlift --add shrugs + +# Delete template +workout templates delete pull-b +``` + +### Workout Session (Hot Path) + +```bash +# Start workout +workout start pull-a # from template +workout start --empty # freestyle session +workout start --continue # resume interrupted session + +# Check current status +workout status # shows current workout, exercises done/remaining + +# Log sets (most frequent operation) +workout log lat-pulldown 100 8 # exercise weight reps +workout log lat-pulldown 100 10 --rir 2 # with RIR +workout log lat-pulldown 100 8,10,10 # batch multiple sets +workout log lat-pulldown +5 10 # relative weight (+5 from last) + +# Mid-workout modifications +workout swap face-pulls reverse-pec-deck # swap exercise +workout add shrugs # add exercise not in template +workout skip deadlift # skip exercise (log reason optional) +workout reorder # interactive reorder + +# Notes +workout note "elbow pain on curls" # session-level note +workout note lat-pulldown "went lighter" # exercise-level note + +# Finish +workout done # end session, calculate stats +workout cancel # abort without saving +``` + +### History & Queries + +```bash +# Last workout +workout last # summary of last session +workout last --full # detailed view + +# Exercise history +workout history lat-pulldown # all-time progression +workout history lat-pulldown --last 10 # last 10 sessions +workout history lat-pulldown --graph # ASCII chart + +# Personal records +workout pr # all PRs +workout pr lat-pulldown # PR for specific exercise +workout pr --muscle back # PRs by muscle group + +# Volume analysis +workout volume # this week +workout volume --week # current week +workout volume --month # current month +workout volume --by muscle # breakdown by muscle group + +# Search/query +workout search "back workouts january" # natural language search +workout workouts --from 2026-01-01 --to 2026-01-31 +workout workouts --type pull +``` + +### Export & Sync + +```bash +# Export data +workout export --format json > workouts.json +workout export --format csv > workouts.csv +workout export --format markdown > workouts.md + +# Backup/restore +workout backup # creates timestamped backup +workout restore backup-2026-01-22.tar.gz +``` + +--- + +## Data Model + +### Exercise +```json +{ + "id": "lat-pulldown", + "name": "Lat Pulldown", + "aliases": ["lat pull", "pulldown"], + "muscles": ["lats", "back", "biceps"], + "type": "isolation", + "equipment": "cable", + "notes": "Keep chest up, pull to upper chest" +} +``` + +### Template +```json +{ + "id": "pull-a", + "name": "Pull A", + "description": "Pull day with deadlifts", + "exercises": [ + {"exercise": "deadlift", "sets": 3, "reps": "5-8"}, + {"exercise": "lat-pulldown", "sets": 3, "reps": "8-12"}, + {"exercise": "cable-row", "sets": 3, "reps": "8-12"}, + {"exercise": "face-pulls", "sets": 3, "reps": "15-20"}, + {"exercise": "bayesian-cable-curl", "sets": 2, "reps": "10-12"}, + {"exercise": "hammer-curl", "sets": 2, "reps": "10-12"} + ] +} +``` + +### Workout Session +```json +{ + "id": "2026-01-22-pull", + "date": "2026-01-22", + "template": "pull-a", + "startTime": "2026-01-22T19:41:00Z", + "endTime": "2026-01-22T20:26:00Z", + "duration": 45, + "exercises": [ + { + "exercise": "lat-pulldown", + "sets": [ + {"weight": 100, "reps": 8, "rir": null}, + {"weight": 100, "reps": 10, "rir": null}, + {"weight": 100, "reps": 10, "rir": null} + ], + "notes": "dropped from 120 due to elbow" + } + ], + "notes": ["No deadlifts - heel injury", "Left elbow pain on pulling"], + "stats": { + "totalSets": 18, + "totalVolume": 12500, + "musclesWorked": ["back", "biceps", "rear delts", "traps"] + } +} +``` + +--- + +## Storage + +``` +~/.workout/ +├── config.json # user preferences +├── exercises.json # exercise library (ships with defaults) +├── templates.json # workout templates +├── current.json # active session (if workout in progress) +└── workouts/ + ├── 2026-01-22.json + ├── 2026-01-21.json + └── ... +``` + +Primary storage: JSON files (human-readable, git-friendly) + +### Config Schema + +```json +{ + "units": "lbs", // "lbs" (default) or "kg" + "dataDir": "~/.workout" // override data location +} +``` + +--- + +## Implementation Notes + +### Language +TypeScript/Bun for fast iteration. + +### Key Design Decisions + +1. **JSON-only storage** — No SQLite; JSON files are fast enough for personal workout history +2. **Offline-first** — No network required, all local +3. **Fast logging** — `workout log` must be <100ms +4. **Forgiving input** — `workout log lat-pull 100 8` should fuzzy-match "lat-pulldown" +5. **Sensible defaults** — `workout start` with no args prompts for template or uses last +6. **Active session in separate file** — `~/.workout/current.json` holds in-progress workout; moved to `workouts/` on `workout done` +7. **Auto-generated exercise IDs** — Slugified from name ("Lat Pulldown" → "lat-pulldown"), can override +8. **Pre-populated exercise library** — Ships with common exercises; user can add/edit/delete +9. **Configurable units** — Default to pounds (lbs), configurable in `config.json` + +### Integration with Clawdbot + +Clawdbot can call workout CLI directly: +```bash +workout start pull-a +workout log lat-pulldown 100 8 +workout log lat-pulldown 100 10 +workout done +workout history lat-pulldown --last 3 --json +``` + +The `--json` flag on queries returns structured data for the agent to parse. + +--- + +## Future Ideas + +- **Plate calculator** — `workout plates 135` shows plate breakdown +- **Rest timer** — `workout rest 90` starts countdown +- **Watch/phone companion** — quick logging from wrist +- **AI suggestions** — "You hit 100x10 last time, try 105 today" +- **Supersets** — `workout log -ss bench-press 135 10 / cable-fly 30 12` +- **RPE/RIR tracking** — Already supported, could add trends + +--- + +## MVP Scope + +Phase 1: +- [ ] `workout exercises list/add/show/edit/delete` +- [ ] `workout templates list/show/create/delete` +- [ ] `workout start/log/done/status/cancel` +- [ ] `workout history ` +- [ ] `workout last` +- [ ] JSON storage with pre-populated exercise library +- [ ] Configurable units (lbs/kg) + +Phase 2: +- [ ] `workout pr`, `workout volume` +- [ ] Export commands +- [ ] Fuzzy matching + +Phase 3: +- [ ] Natural language queries +- [ ] Visualization helpers +- [ ] Sync/backup diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ff1f630 --- /dev/null +++ b/install.sh @@ -0,0 +1,195 @@ +#!/bin/bash +set -euo pipefail + +REPO="gricha/workout-cli" +INSTALL_DIR="${WORKOUT_INSTALL_DIR:-$HOME/.workout-cli}" +BIN_DIR="$INSTALL_DIR/bin" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}==>${NC} $1"; } +success() { echo -e "${GREEN}==>${NC} $1"; } +warn() { echo -e "${YELLOW}==>${NC} $1"; } +error() { echo -e "${RED}Error:${NC} $1" >&2; exit 1; } + +VERSION="" +NO_MODIFY_PATH=false + +while [[ $# -gt 0 ]]; do + case $1 in + -v|--version) VERSION="$2"; shift 2 ;; + --no-modify-path) NO_MODIFY_PATH=true; shift ;; + -h|--help) + echo "Workout CLI installer" + echo "" + echo "Usage: install.sh [OPTIONS]" + echo "" + echo "Options:" + echo " -v, --version VERSION Install specific version (default: latest)" + echo " --no-modify-path Don't modify shell PATH" + echo " -h, --help Show this help" + exit 0 + ;; + *) error "Unknown option: $1" ;; + esac +done + +detect_platform() { + local os arch + + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) error "Unsupported operating system: $(uname -s)" ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) error "Unsupported architecture: $(uname -m)" ;; + esac + + echo "${os}-${arch}" +} + +get_latest_version() { + local url="https://api.github.com/repos/${REPO}/releases/latest" + local version + + if command -v curl &>/dev/null; then + version=$(curl -fsSL "$url" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') + elif command -v wget &>/dev/null; then + version=$(wget -qO- "$url" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') + else + error "curl or wget is required" + fi + + if [[ -z "$version" ]]; then + error "Failed to fetch latest version" + fi + + echo "$version" +} + +download_and_install() { + local version="$1" + local platform="$2" + local archive_ext="tar.gz" + local binary_name="workout" + + if [[ "$platform" == windows-* ]]; then + archive_ext="zip" + binary_name="workout.exe" + fi + + local archive_name="workout-${version}-${platform}.${archive_ext}" + local download_url="https://github.com/${REPO}/releases/download/v${version}/${archive_name}" + + info "Downloading workout-cli v${version} for ${platform}..." + + local tmp_dir + tmp_dir=$(mktemp -d) + trap "rm -rf '$tmp_dir'" EXIT + + local archive_path="$tmp_dir/$archive_name" + + if command -v curl &>/dev/null; then + curl -fsSL --progress-bar "$download_url" -o "$archive_path" || error "Download failed. Check if version v${version} exists." + else + wget -q --show-progress "$download_url" -O "$archive_path" || error "Download failed. Check if version v${version} exists." + fi + + info "Extracting..." + + mkdir -p "$BIN_DIR" + + if [[ "$archive_ext" == "tar.gz" ]]; then + tar -xzf "$archive_path" -C "$tmp_dir" + else + unzip -q "$archive_path" -d "$tmp_dir" + fi + + local extracted_dir="$tmp_dir/workout-${version}-${platform}" + + local target="$BIN_DIR/workout" + local tmp_target="$BIN_DIR/.workout.tmp.$$" + + cp "$extracted_dir/$binary_name" "$tmp_target" + chmod +x "$tmp_target" + mv -f "$tmp_target" "$target" + + success "Installed workout to $BIN_DIR/workout" +} + +update_path() { + if [[ "$NO_MODIFY_PATH" == "true" ]]; then + return + fi + + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$BIN_DIR" >> "$GITHUB_PATH" + info "Added $BIN_DIR to GITHUB_PATH" + return + fi + + local shell_config="" + local path_export="export PATH=\"$BIN_DIR:\$PATH\"" + + if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$SHELL" == */zsh ]]; then + shell_config="$HOME/.zshrc" + elif [[ -n "${BASH_VERSION:-}" ]] || [[ "$SHELL" == */bash ]]; then + if [[ -f "$HOME/.bashrc" ]]; then + shell_config="$HOME/.bashrc" + elif [[ -f "$HOME/.bash_profile" ]]; then + shell_config="$HOME/.bash_profile" + fi + elif [[ "$SHELL" == */fish ]]; then + shell_config="$HOME/.config/fish/config.fish" + path_export="set -gx PATH $BIN_DIR \$PATH" + fi + + if [[ -n "$shell_config" ]]; then + if ! grep -q "$BIN_DIR" "$shell_config" 2>/dev/null; then + echo "" >> "$shell_config" + echo "# Workout CLI" >> "$shell_config" + echo "$path_export" >> "$shell_config" + info "Added $BIN_DIR to PATH in $shell_config" + fi + fi +} + +main() { + info "Workout CLI installer" + echo "" + + local platform + platform=$(detect_platform) + info "Detected platform: $platform" + + if [[ -z "$VERSION" ]]; then + VERSION=$(get_latest_version) + fi + + download_and_install "$VERSION" "$platform" + update_path + + echo "" + success "Workout CLI v${VERSION} installed successfully!" + echo "" + echo "To get started, run:" + echo "" + + if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + echo " export PATH=\"$BIN_DIR:\$PATH\"" + fi + + echo " workout --help" + echo "" +} + +main diff --git a/package.json b/package.json index c4bdfd5..a752978 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ ], "scripts": { "dev": "bun --watch src/index.ts", - "build": "rm -rf ./dist && bun run build:ts && bun link", + "build": "rm -rf ./dist && bun run build:ts && bun run build:bin && bun link", "build:ts": "tsc && chmod +x dist/index.js", + "build:bin": "bun build src/index.ts --compile --outfile dist/workout --target=bun", "test": "vitest run", "test:watch": "vitest", "lint": "oxlint --type-aware --tsconfig=tsconfig.json src/", diff --git a/src/commands/exercises.ts b/src/commands/exercises.ts new file mode 100644 index 0000000..ef728bb --- /dev/null +++ b/src/commands/exercises.ts @@ -0,0 +1,207 @@ +import { Command } from 'commander'; +import { getStorage } from '../data/storage.js'; +import { + Exercise, + slugify, + type MuscleGroup, + type ExerciseType, + type Equipment, +} from '../types.js'; + +export function createExercisesCommand(): Command { + const exercises = new Command('exercises').description('Manage exercise library'); + + exercises + .command('list') + .description('List all exercises') + .option('-m, --muscle ', 'Filter by muscle group') + .option('-t, --type ', 'Filter by exercise type (compound/isolation)') + .option('--json', 'Output as JSON') + .action((options: { muscle?: string; type?: string; json?: boolean }) => { + const storage = getStorage(); + let exerciseList = storage.getExercises(); + + if (options.muscle) { + exerciseList = exerciseList.filter((e) => + e.muscles.some((m) => m.toLowerCase().includes(options.muscle!.toLowerCase())) + ); + } + + if (options.type) { + exerciseList = exerciseList.filter((e) => e.type === options.type); + } + + if (options.json) { + console.log(JSON.stringify(exerciseList, null, 2)); + return; + } + + if (exerciseList.length === 0) { + console.log('No exercises found.'); + return; + } + + for (const e of exerciseList) { + console.log(`${e.id} - ${e.name} (${e.type}, ${e.muscles.join(', ')})`); + } + }); + + exercises + .command('show ') + .description('Show exercise details') + .option('--json', 'Output as JSON') + .action((id: string, options: { json?: boolean }) => { + const storage = getStorage(); + const exercise = storage.getExercise(id); + + if (!exercise) { + console.error(`Exercise "${id}" not found.`); + process.exit(1); + } + + if (options.json) { + console.log(JSON.stringify(exercise, null, 2)); + return; + } + + console.log(`Name: ${exercise.name}`); + console.log(`ID: ${exercise.id}`); + console.log(`Type: ${exercise.type}`); + console.log(`Equipment: ${exercise.equipment}`); + console.log(`Muscles: ${exercise.muscles.join(', ')}`); + if (exercise.aliases.length > 0) { + console.log(`Aliases: ${exercise.aliases.join(', ')}`); + } + if (exercise.notes) { + console.log(`Notes: ${exercise.notes}`); + } + }); + + exercises + .command('add ') + .description('Add a new exercise') + .requiredOption('--muscles ', 'Comma-separated muscle groups') + .requiredOption('--type ', 'Exercise type (compound/isolation)') + .requiredOption('--equipment ', 'Equipment type') + .option('--id ', 'Custom ID (defaults to slugified name)') + .option('--aliases ', 'Comma-separated aliases') + .option('--notes ', 'Exercise notes') + .action( + ( + name: string, + options: { + muscles: string; + type: string; + equipment: string; + id?: string; + aliases?: string; + notes?: string; + } + ) => { + const storage = getStorage(); + const id = options.id ?? slugify(name); + const muscles = options.muscles.split(',').map((m) => m.trim()) as MuscleGroup[]; + const aliases = options.aliases ? options.aliases.split(',').map((a) => a.trim()) : []; + + const exercise = Exercise.parse({ + id, + name, + aliases, + muscles, + type: options.type as ExerciseType, + equipment: options.equipment as Equipment, + notes: options.notes, + }); + + try { + storage.addExercise(exercise); + console.log(`Added exercise: ${exercise.name} (${exercise.id})`); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + ); + + exercises + .command('edit ') + .description('Edit an exercise') + .option('--name ', 'New name') + .option('--muscles ', 'New muscle groups (comma-separated)') + .option('--type ', 'New type') + .option('--equipment ', 'New equipment') + .option('--add-alias ', 'Add an alias') + .option('--remove-alias ', 'Remove an alias') + .option('--notes ', 'New notes') + .action( + ( + id: string, + options: { + name?: string; + muscles?: string; + type?: string; + equipment?: string; + addAlias?: string; + removeAlias?: string; + notes?: string; + } + ) => { + const storage = getStorage(); + const exercise = storage.getExercise(id); + + if (!exercise) { + console.error(`Exercise "${id}" not found.`); + process.exit(1); + } + + const updates: Partial = {}; + + if (options.name) { + updates.name = options.name; + } + if (options.muscles) { + updates.muscles = options.muscles.split(',').map((m) => m.trim()) as MuscleGroup[]; + } + if (options.type) { + updates.type = options.type as ExerciseType; + } + if (options.equipment) { + updates.equipment = options.equipment as Equipment; + } + if (options.notes) { + updates.notes = options.notes; + } + if (options.addAlias) { + updates.aliases = [...exercise.aliases, options.addAlias]; + } + if (options.removeAlias) { + updates.aliases = exercise.aliases.filter((a) => a !== options.removeAlias); + } + + try { + storage.updateExercise(id, updates); + console.log(`Updated exercise: ${id}`); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + ); + + exercises + .command('delete ') + .description('Delete an exercise') + .action((id: string) => { + const storage = getStorage(); + + try { + storage.deleteExercise(id); + console.log(`Deleted exercise: ${id}`); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + }); + + return exercises; +} diff --git a/src/commands/history.ts b/src/commands/history.ts new file mode 100644 index 0000000..8631e6c --- /dev/null +++ b/src/commands/history.ts @@ -0,0 +1,126 @@ +import { Command } from 'commander'; +import { getStorage } from '../data/storage.js'; + +export function createLastCommand(): Command { + return new Command('last') + .description('Show last workout') + .option('--full', 'Show full details') + .option('--json', 'Output as JSON') + .action((options: { full?: boolean; json?: boolean }) => { + const storage = getStorage(); + const workout = storage.getLastWorkout(); + + if (!workout) { + console.log('No workouts found.'); + return; + } + + if (options.json) { + console.log(JSON.stringify(workout, null, 2)); + return; + } + + const config = storage.getConfig(); + const unit = config.units; + + console.log(`Workout: ${workout.id}`); + console.log(`Date: ${workout.date}`); + if (workout.template) { + console.log(`Template: ${workout.template}`); + } + if (workout.stats) { + console.log( + `Sets: ${workout.stats.totalSets} | Volume: ${workout.stats.totalVolume}${unit}` + ); + } + + if (options.full) { + console.log(''); + console.log('Exercises:'); + for (const log of workout.exercises) { + const exercise = storage.getExercise(log.exercise); + const name = exercise?.name ?? log.exercise; + console.log(` ${name}:`); + for (const set of log.sets) { + const rirStr = set.rir !== null ? ` @${set.rir}RIR` : ''; + console.log(` ${set.weight}${unit} x ${set.reps}${rirStr}`); + } + if (log.notes) { + console.log(` Note: ${log.notes}`); + } + } + if (workout.notes.length > 0) { + console.log(''); + console.log('Notes:'); + for (const note of workout.notes) { + console.log(` - ${note}`); + } + } + } else { + console.log(''); + for (const log of workout.exercises) { + const exercise = storage.getExercise(log.exercise); + const name = exercise?.name ?? log.exercise; + const bestSet = log.sets.reduce( + (best, set) => (set.weight * set.reps > best.weight * best.reps ? set : best), + log.sets[0]! + ); + if (bestSet) { + console.log( + ` ${name}: ${bestSet.weight}${unit} x ${bestSet.reps} (${log.sets.length} sets)` + ); + } + } + } + }); +} + +export function createHistoryCommand(): Command { + return new Command('history') + .description('Show exercise history') + .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 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); + + if (options.json) { + console.log(JSON.stringify(limited, null, 2)); + return; + } + + if (limited.length === 0) { + console.log(`No history for ${exercise.name}.`); + return; + } + + const config = storage.getConfig(); + const unit = config.units; + + console.log(`History for ${exercise.name} (last ${limited.length} sessions):`); + console.log(''); + + for (const { workout, log } of limited) { + const bestSet = log.sets.reduce( + (best, set) => (set.weight * set.reps > best.weight * best.reps ? set : best), + log.sets[0]! + ); + if (bestSet) { + const setsStr = log.sets.map((s) => `${s.weight}x${s.reps}`).join(', '); + console.log( + `${workout.date}: ${setsStr} (best: ${bestSet.weight}${unit} x ${bestSet.reps})` + ); + } + } + }); +} diff --git a/src/commands/session.ts b/src/commands/session.ts new file mode 100644 index 0000000..a7a9eab --- /dev/null +++ b/src/commands/session.ts @@ -0,0 +1,252 @@ +import { Command } from 'commander'; +import { getStorage } from '../data/storage.js'; +import type { Workout, ExerciseLog, SetLog, WorkoutStats } from '../types.js'; + +function formatDate(date: Date): string { + return date.toISOString().split('T')[0]!; +} + +function generateWorkoutId(date: string, template: string | null): string { + const suffix = template ?? 'freestyle'; + return `${date}-${suffix}`; +} + +function calculateStats(workout: Workout, storage: ReturnType): WorkoutStats { + let totalSets = 0; + let totalVolume = 0; + const musclesSet = new Set(); + + for (const exerciseLog of workout.exercises) { + totalSets += exerciseLog.sets.length; + for (const set of exerciseLog.sets) { + totalVolume += set.weight * set.reps; + } + const exercise = storage.getExercise(exerciseLog.exercise); + if (exercise) { + for (const muscle of exercise.muscles) { + musclesSet.add(muscle); + } + } + } + + return { + totalSets, + totalVolume, + musclesWorked: Array.from(musclesSet), + }; +} + +export function createStartCommand(): Command { + return new Command('start') + .description('Start a new workout session') + .argument('[template]', 'Template ID to use') + .option('--empty', 'Start an empty freestyle session') + .option('--continue', 'Resume an interrupted session') + .action((templateId: string | undefined, options: { empty?: boolean; continue?: boolean }) => { + const storage = getStorage(); + + if (options.continue) { + const current = storage.getCurrentWorkout(); + if (!current) { + console.error('No active workout to continue.'); + process.exit(1); + } + console.log(`Resuming workout: ${current.id}`); + console.log(`Started: ${current.startTime}`); + console.log(`Exercises logged: ${current.exercises.length}`); + return; + } + + const existing = storage.getCurrentWorkout(); + if (existing) { + console.error(`Already have an active workout: ${existing.id}`); + console.error('Use "workout done" to finish or "workout cancel" to abort.'); + process.exit(1); + } + + const now = new Date(); + const date = formatDate(now); + let exercises: ExerciseLog[] = []; + + if (!options.empty && templateId) { + const template = storage.getTemplate(templateId); + if (!template) { + console.error(`Template "${templateId}" not found.`); + process.exit(1); + } + exercises = template.exercises.map((e) => ({ + exercise: e.exercise, + sets: [], + })); + } + + const workout: Workout = { + id: generateWorkoutId(date, options.empty ? null : (templateId ?? null)), + date, + template: options.empty ? null : (templateId ?? null), + startTime: now.toISOString(), + endTime: null, + exercises, + notes: [], + }; + + storage.saveCurrentWorkout(workout); + console.log(`Started workout: ${workout.id}`); + if (templateId && !options.empty) { + console.log(`Template: ${templateId}`); + console.log(`Exercises: ${exercises.map((e) => e.exercise).join(', ')}`); + } else { + console.log('Freestyle session - add exercises with "workout log"'); + } + }); +} + +export function createLogCommand(): Command { + return new Command('log') + .description('Log a set') + .argument('', 'Exercise ID') + .argument('', 'Weight (number or +/- for relative)') + .argument('', 'Reps (single number or comma-separated for multiple sets)') + .option('--rir ', 'Reps in reserve (0-10)') + .action((exerciseId: string, weightStr: string, repsStr: string, options: { rir?: string }) => { + const storage = getStorage(); + const workout = storage.getCurrentWorkout(); + + if (!workout) { + console.error('No active workout. Start one with "workout start".'); + process.exit(1); + } + + const exercise = storage.getExercise(exerciseId); + if (!exercise) { + console.error(`Exercise "${exerciseId}" not found.`); + process.exit(1); + } + + let weight: number; + if (weightStr.startsWith('+') || weightStr.startsWith('-')) { + const history = storage.getExerciseHistory(exercise.id); + if (history.length === 0) { + console.error(`No history for "${exercise.id}" to calculate relative weight.`); + process.exit(1); + } + const lastLog = history[0]?.log; + const lastSet = lastLog?.sets[lastLog.sets.length - 1]; + if (!lastSet) { + console.error(`No previous sets for "${exercise.id}".`); + process.exit(1); + } + weight = lastSet.weight + parseFloat(weightStr); + } else { + weight = parseFloat(weightStr); + } + + const repsList = repsStr.split(',').map((r) => parseInt(r.trim(), 10)); + const rir = options.rir ? parseInt(options.rir, 10) : null; + + let exerciseLog = workout.exercises.find((e) => e.exercise === exercise.id); + if (!exerciseLog) { + exerciseLog = { exercise: exercise.id, sets: [] }; + workout.exercises.push(exerciseLog); + } + + const newSets: SetLog[] = repsList.map((reps) => ({ + weight, + reps, + rir, + })); + + exerciseLog.sets.push(...newSets); + storage.saveCurrentWorkout(workout); + + const setCount = newSets.length; + const config = storage.getConfig(); + const unit = config.units; + console.log( + `Logged ${setCount} set${setCount > 1 ? 's' : ''}: ${exercise.name} @ ${weight}${unit} x ${repsList.join(', ')}` + ); + }); +} + +export function createStatusCommand(): Command { + return new Command('status').description('Show current workout status').action(() => { + const storage = getStorage(); + const workout = storage.getCurrentWorkout(); + + if (!workout) { + console.log('No active workout.'); + return; + } + + const config = storage.getConfig(); + const unit = config.units; + + console.log(`Workout: ${workout.id}`); + console.log(`Started: ${workout.startTime}`); + if (workout.template) { + console.log(`Template: ${workout.template}`); + } + console.log(''); + + if (workout.exercises.length === 0) { + console.log('No exercises logged yet.'); + return; + } + + console.log('Exercises:'); + for (const log of workout.exercises) { + const exercise = storage.getExercise(log.exercise); + const name = exercise?.name ?? log.exercise; + if (log.sets.length === 0) { + console.log(` ${name}: (no sets)`); + } else { + const setsStr = log.sets.map((s) => `${s.weight}${unit}x${s.reps}`).join(', '); + console.log(` ${name}: ${setsStr}`); + } + } + }); +} + +export function createDoneCommand(): Command { + return new Command('done').description('Finish current workout').action(() => { + const storage = getStorage(); + const workout = storage.getCurrentWorkout(); + + if (!workout) { + console.error('No active workout to finish.'); + process.exit(1); + } + + workout.endTime = new Date().toISOString(); + const stats = calculateStats(workout, storage); + workout.stats = stats; + + storage.finishWorkout(workout); + + const config = storage.getConfig(); + const unit = config.units; + + console.log(`Workout complete: ${workout.id}`); + console.log( + `Duration: ${Math.round((new Date(workout.endTime).getTime() - new Date(workout.startTime).getTime()) / 60000)} minutes` + ); + console.log(`Total sets: ${stats.totalSets}`); + console.log(`Total volume: ${stats.totalVolume}${unit}`); + console.log(`Muscles worked: ${stats.musclesWorked.join(', ')}`); + }); +} + +export function createCancelCommand(): Command { + return new Command('cancel').description('Cancel current workout without saving').action(() => { + const storage = getStorage(); + const workout = storage.getCurrentWorkout(); + + if (!workout) { + console.error('No active workout to cancel.'); + process.exit(1); + } + + storage.clearCurrentWorkout(); + console.log(`Cancelled workout: ${workout.id}`); + }); +} diff --git a/src/commands/templates.ts b/src/commands/templates.ts new file mode 100644 index 0000000..ed0f4b9 --- /dev/null +++ b/src/commands/templates.ts @@ -0,0 +1,143 @@ +import { Command } from 'commander'; +import { getStorage } from '../data/storage.js'; +import { Template, slugify, type TemplateExercise } from '../types.js'; + +function parseExerciseSpec(spec: string): TemplateExercise { + const match = spec.match(/^([^:]+):(\d+)x(.+)$/); + if (!match) { + throw new Error(`Invalid exercise spec: ${spec}. Expected format: exercise:setsxreps`); + } + const [, exercise, sets, reps] = match; + if (!exercise || !sets || !reps) { + throw new Error(`Invalid exercise spec: ${spec}`); + } + return { + exercise: exercise.trim(), + sets: parseInt(sets, 10), + reps: reps.trim(), + }; +} + +export function createTemplatesCommand(): Command { + const templates = new Command('templates').description('Manage workout templates'); + + templates + .command('list') + .description('List all templates') + .option('--json', 'Output as JSON') + .action((options: { json?: boolean }) => { + const storage = getStorage(); + const templateList = storage.getTemplates(); + + if (options.json) { + console.log(JSON.stringify(templateList, null, 2)); + return; + } + + if (templateList.length === 0) { + console.log('No templates found.'); + return; + } + + for (const t of templateList) { + const exerciseCount = t.exercises.length; + console.log(`${t.id} - ${t.name} (${exerciseCount} exercises)`); + } + }); + + templates + .command('show ') + .description('Show template details') + .option('--json', 'Output as JSON') + .action((id: string, options: { json?: boolean }) => { + const storage = getStorage(); + const template = storage.getTemplate(id); + + if (!template) { + console.error(`Template "${id}" not found.`); + process.exit(1); + } + + if (options.json) { + console.log(JSON.stringify(template, null, 2)); + return; + } + + console.log(`Name: ${template.name}`); + console.log(`ID: ${template.id}`); + if (template.description) { + console.log(`Description: ${template.description}`); + } + console.log('\nExercises:'); + for (const e of template.exercises) { + console.log(` - ${e.exercise}: ${e.sets}x${e.reps}`); + } + }); + + templates + .command('create ') + .description('Create a new template') + .requiredOption( + '-e, --exercises ', + 'Exercise specs (e.g., "bench-press:3x8-12, squat:4x5")' + ) + .option('--id ', 'Custom ID (defaults to slugified name)') + .option('-d, --description ', 'Template description') + .action( + ( + name: string, + options: { + exercises: string; + id?: string; + description?: string; + } + ) => { + const storage = getStorage(); + const id = options.id ?? slugify(name); + + const exerciseSpecs = options.exercises.split(',').map((s) => s.trim()); + const exercises: TemplateExercise[] = []; + + for (const spec of exerciseSpecs) { + try { + exercises.push(parseExerciseSpec(spec)); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + + const template = Template.parse({ + id, + name, + description: options.description, + exercises, + }); + + try { + storage.addTemplate(template); + console.log(`Created template: ${template.name} (${template.id})`); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + ); + + templates + .command('delete ') + .description('Delete a template') + .action((id: string) => { + const storage = getStorage(); + + try { + storage.deleteTemplate(id); + console.log(`Deleted template: ${id}`); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + }); + + return templates; +} diff --git a/src/data/storage.ts b/src/data/storage.ts new file mode 100644 index 0000000..4abf330 --- /dev/null +++ b/src/data/storage.ts @@ -0,0 +1,257 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + Config, + Exercise, + Template, + Workout, + type Config as ConfigType, + type Exercise as ExerciseType, + type Template as TemplateType, + type Workout as WorkoutType, +} from '../types.js'; +import { defaultExercises } from '../exercises.js'; + +function expandPath(p: string): string { + if (p.startsWith('~/')) { + return path.join(os.homedir(), p.slice(2)); + } + return p; +} + +export class Storage { + private dataDir: string; + + constructor(dataDir?: string) { + this.dataDir = expandPath(dataDir ?? '~/.workout'); + } + + private ensureDir(): void { + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }); + } + const workoutsDir = path.join(this.dataDir, 'workouts'); + if (!fs.existsSync(workoutsDir)) { + fs.mkdirSync(workoutsDir, { recursive: true }); + } + } + + private configPath(): string { + return path.join(this.dataDir, 'config.json'); + } + + private exercisesPath(): string { + return path.join(this.dataDir, 'exercises.json'); + } + + private templatesPath(): string { + return path.join(this.dataDir, 'templates.json'); + } + + private currentPath(): string { + return path.join(this.dataDir, 'current.json'); + } + + private workoutPath(date: string): string { + return path.join(this.dataDir, 'workouts', `${date}.json`); + } + + getConfig(): ConfigType { + this.ensureDir(); + const configPath = this.configPath(); + if (!fs.existsSync(configPath)) { + const defaultConfig = Config.parse({}); + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2)); + return defaultConfig; + } + const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + return Config.parse(raw); + } + + saveConfig(config: ConfigType): void { + this.ensureDir(); + fs.writeFileSync(this.configPath(), JSON.stringify(config, null, 2)); + } + + getExercises(): ExerciseType[] { + this.ensureDir(); + const exercisesPath = this.exercisesPath(); + if (!fs.existsSync(exercisesPath)) { + fs.writeFileSync(exercisesPath, JSON.stringify(defaultExercises, null, 2)); + return defaultExercises; + } + const raw = JSON.parse(fs.readFileSync(exercisesPath, 'utf-8')); + return raw.map((e: unknown) => Exercise.parse(e)); + } + + saveExercises(exercises: ExerciseType[]): void { + this.ensureDir(); + fs.writeFileSync(this.exercisesPath(), JSON.stringify(exercises, null, 2)); + } + + getExercise(id: string): ExerciseType | undefined { + const exercises = this.getExercises(); + return exercises.find((e) => e.id === id || e.aliases.includes(id)); + } + + addExercise(exercise: ExerciseType): void { + const exercises = this.getExercises(); + const existing = exercises.find((e) => e.id === exercise.id); + if (existing) { + throw new Error(`Exercise "${exercise.id}" already exists`); + } + exercises.push(exercise); + this.saveExercises(exercises); + } + + updateExercise(id: string, updates: Partial): void { + const exercises = this.getExercises(); + const index = exercises.findIndex((e) => e.id === id); + if (index === -1) { + throw new Error(`Exercise "${id}" not found`); + } + const current = exercises[index]; + if (!current) { + throw new Error(`Exercise "${id}" not found`); + } + exercises[index] = { ...current, ...updates }; + this.saveExercises(exercises); + } + + deleteExercise(id: string): void { + const exercises = this.getExercises(); + const index = exercises.findIndex((e) => e.id === id); + if (index === -1) { + throw new Error(`Exercise "${id}" not found`); + } + exercises.splice(index, 1); + this.saveExercises(exercises); + } + + getTemplates(): TemplateType[] { + this.ensureDir(); + const templatesPath = this.templatesPath(); + if (!fs.existsSync(templatesPath)) { + fs.writeFileSync(templatesPath, JSON.stringify([], null, 2)); + return []; + } + const raw = JSON.parse(fs.readFileSync(templatesPath, 'utf-8')); + return raw.map((t: unknown) => Template.parse(t)); + } + + saveTemplates(templates: TemplateType[]): void { + this.ensureDir(); + fs.writeFileSync(this.templatesPath(), JSON.stringify(templates, null, 2)); + } + + getTemplate(id: string): TemplateType | undefined { + const templates = this.getTemplates(); + return templates.find((t) => t.id === id); + } + + addTemplate(template: TemplateType): void { + const templates = this.getTemplates(); + const existing = templates.find((t) => t.id === template.id); + if (existing) { + throw new Error(`Template "${template.id}" already exists`); + } + templates.push(template); + this.saveTemplates(templates); + } + + deleteTemplate(id: string): void { + const templates = this.getTemplates(); + const index = templates.findIndex((t) => t.id === id); + if (index === -1) { + throw new Error(`Template "${id}" not found`); + } + templates.splice(index, 1); + this.saveTemplates(templates); + } + + getCurrentWorkout(): WorkoutType | null { + this.ensureDir(); + const currentPath = this.currentPath(); + if (!fs.existsSync(currentPath)) { + return null; + } + const raw = JSON.parse(fs.readFileSync(currentPath, 'utf-8')); + return Workout.parse(raw); + } + + saveCurrentWorkout(workout: WorkoutType): void { + this.ensureDir(); + fs.writeFileSync(this.currentPath(), JSON.stringify(workout, null, 2)); + } + + clearCurrentWorkout(): void { + const currentPath = this.currentPath(); + if (fs.existsSync(currentPath)) { + fs.unlinkSync(currentPath); + } + } + + finishWorkout(workout: WorkoutType): void { + this.ensureDir(); + const workoutPath = this.workoutPath(workout.date); + fs.writeFileSync(workoutPath, JSON.stringify(workout, null, 2)); + this.clearCurrentWorkout(); + } + + getWorkout(date: string): WorkoutType | null { + const workoutPath = this.workoutPath(date); + if (!fs.existsSync(workoutPath)) { + return null; + } + const raw = JSON.parse(fs.readFileSync(workoutPath, 'utf-8')); + return Workout.parse(raw); + } + + getAllWorkouts(): WorkoutType[] { + this.ensureDir(); + const workoutsDir = path.join(this.dataDir, 'workouts'); + if (!fs.existsSync(workoutsDir)) { + return []; + } + const files = fs.readdirSync(workoutsDir).filter((f) => f.endsWith('.json')); + return files + .map((f) => { + const raw = JSON.parse(fs.readFileSync(path.join(workoutsDir, f), 'utf-8')); + return Workout.parse(raw); + }) + .sort((a, b) => b.date.localeCompare(a.date)); + } + + getLastWorkout(): WorkoutType | null { + const workouts = this.getAllWorkouts(); + return workouts[0] ?? null; + } + + getExerciseHistory( + exerciseId: string + ): { workout: WorkoutType; log: WorkoutType['exercises'][0] }[] { + const workouts = this.getAllWorkouts(); + const history: { workout: WorkoutType; log: WorkoutType['exercises'][0] }[] = []; + for (const workout of workouts) { + const log = workout.exercises.find((e) => e.exercise === exerciseId); + if (log) { + history.push({ workout, log }); + } + } + return history; + } +} + +let storageInstance: Storage | null = null; + +export function getStorage(dataDir?: string): Storage { + if (!storageInstance || dataDir) { + storageInstance = new Storage(dataDir); + } + return storageInstance; +} + +export function resetStorage(): void { + storageInstance = null; +} diff --git a/src/exercises.ts b/src/exercises.ts new file mode 100644 index 0000000..f1f4ba3 --- /dev/null +++ b/src/exercises.ts @@ -0,0 +1,268 @@ +import type { Exercise } from './types.js'; + +export const defaultExercises: Exercise[] = [ + { + id: 'bench-press', + name: 'Bench Press', + aliases: ['bench', 'flat bench'], + muscles: ['chest', 'triceps', 'front-delts'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'incline-bench-press', + name: 'Incline Bench Press', + aliases: ['incline bench', 'incline press'], + muscles: ['chest', 'triceps', 'front-delts'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'dumbbell-bench-press', + name: 'Dumbbell Bench Press', + aliases: ['db bench'], + muscles: ['chest', 'triceps', 'front-delts'], + type: 'compound', + equipment: 'dumbbell', + }, + { + id: 'overhead-press', + name: 'Overhead Press', + aliases: ['ohp', 'shoulder press', 'military press'], + muscles: ['shoulders', 'triceps', 'front-delts'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'dumbbell-shoulder-press', + name: 'Dumbbell Shoulder Press', + aliases: ['db shoulder press', 'db ohp'], + muscles: ['shoulders', 'triceps', 'front-delts'], + type: 'compound', + equipment: 'dumbbell', + }, + { + id: 'squat', + name: 'Squat', + aliases: ['back squat', 'barbell squat'], + muscles: ['quads', 'glutes', 'hamstrings'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'front-squat', + name: 'Front Squat', + aliases: [], + muscles: ['quads', 'glutes'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'deadlift', + name: 'Deadlift', + aliases: ['conventional deadlift'], + muscles: ['back', 'hamstrings', 'glutes', 'traps'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'romanian-deadlift', + name: 'Romanian Deadlift', + aliases: ['rdl', 'stiff leg deadlift'], + muscles: ['hamstrings', 'glutes', 'back'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'barbell-row', + name: 'Barbell Row', + aliases: ['bent over row', 'bb row'], + muscles: ['back', 'lats', 'biceps', 'rear-delts'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'pull-up', + name: 'Pull Up', + aliases: ['pullup', 'chin up'], + muscles: ['lats', 'back', 'biceps'], + type: 'compound', + equipment: 'bodyweight', + }, + { + id: 'lat-pulldown', + name: 'Lat Pulldown', + aliases: ['lat pull', 'pulldown'], + muscles: ['lats', 'back', 'biceps'], + type: 'isolation', + equipment: 'cable', + }, + { + id: 'cable-row', + name: 'Cable Row', + aliases: ['seated cable row', 'seated row'], + muscles: ['back', 'lats', 'biceps', 'rear-delts'], + type: 'compound', + equipment: 'cable', + }, + { + id: 'face-pulls', + name: 'Face Pulls', + aliases: ['face pull'], + muscles: ['rear-delts', 'traps', 'back'], + type: 'isolation', + equipment: 'cable', + }, + { + id: 'lateral-raise', + name: 'Lateral Raise', + aliases: ['side raise', 'lat raise'], + muscles: ['side-delts', 'shoulders'], + type: 'isolation', + equipment: 'dumbbell', + }, + { + id: 'rear-delt-fly', + name: 'Rear Delt Fly', + aliases: ['reverse fly', 'rear delt raise'], + muscles: ['rear-delts', 'back'], + type: 'isolation', + equipment: 'dumbbell', + }, + { + id: 'bicep-curl', + name: 'Bicep Curl', + aliases: ['curl', 'dumbbell curl'], + muscles: ['biceps'], + type: 'isolation', + equipment: 'dumbbell', + }, + { + id: 'barbell-curl', + name: 'Barbell Curl', + aliases: ['bb curl'], + muscles: ['biceps'], + type: 'isolation', + equipment: 'barbell', + }, + { + id: 'hammer-curl', + name: 'Hammer Curl', + aliases: ['hammer curls'], + muscles: ['biceps', 'forearms'], + type: 'isolation', + equipment: 'dumbbell', + }, + { + id: 'tricep-pushdown', + name: 'Tricep Pushdown', + aliases: ['cable pushdown', 'pushdown'], + muscles: ['triceps'], + type: 'isolation', + equipment: 'cable', + }, + { + id: 'skull-crusher', + name: 'Skull Crusher', + aliases: ['lying tricep extension'], + muscles: ['triceps'], + type: 'isolation', + equipment: 'barbell', + }, + { + id: 'dips', + name: 'Dips', + aliases: ['tricep dips', 'chest dips'], + muscles: ['triceps', 'chest', 'front-delts'], + type: 'compound', + equipment: 'bodyweight', + }, + { + id: 'leg-press', + name: 'Leg Press', + aliases: [], + muscles: ['quads', 'glutes'], + type: 'compound', + equipment: 'machine', + }, + { + id: 'leg-curl', + name: 'Leg Curl', + aliases: ['hamstring curl', 'lying leg curl'], + muscles: ['hamstrings'], + type: 'isolation', + equipment: 'machine', + }, + { + id: 'leg-extension', + name: 'Leg Extension', + aliases: ['quad extension'], + muscles: ['quads'], + type: 'isolation', + equipment: 'machine', + }, + { + id: 'calf-raise', + name: 'Calf Raise', + aliases: ['standing calf raise'], + muscles: ['calves'], + type: 'isolation', + equipment: 'machine', + }, + { + id: 'hip-thrust', + name: 'Hip Thrust', + aliases: ['barbell hip thrust'], + muscles: ['glutes', 'hamstrings'], + type: 'compound', + equipment: 'barbell', + }, + { + id: 'lunges', + name: 'Lunges', + aliases: ['walking lunges', 'lunge'], + muscles: ['quads', 'glutes', 'hamstrings'], + type: 'compound', + equipment: 'bodyweight', + }, + { + id: 'cable-fly', + name: 'Cable Fly', + aliases: ['cable chest fly', 'cable crossover'], + muscles: ['chest'], + type: 'isolation', + equipment: 'cable', + }, + { + id: 'pec-deck', + name: 'Pec Deck', + aliases: ['machine fly', 'chest fly machine'], + muscles: ['chest'], + type: 'isolation', + equipment: 'machine', + }, + { + id: 'shrugs', + name: 'Shrugs', + aliases: ['barbell shrug', 'dumbbell shrug'], + muscles: ['traps'], + type: 'isolation', + equipment: 'barbell', + }, + { + id: 'plank', + name: 'Plank', + aliases: [], + muscles: ['abs'], + type: 'isolation', + equipment: 'bodyweight', + }, + { + id: 'cable-crunch', + name: 'Cable Crunch', + aliases: ['kneeling cable crunch'], + muscles: ['abs'], + type: 'isolation', + equipment: 'cable', + }, +]; diff --git a/src/index.ts b/src/index.ts index 1b5fe91..7939db3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,31 @@ #!/usr/bin/env bun import { Command } from 'commander'; +import { createExercisesCommand } from './commands/exercises.js'; +import { createTemplatesCommand } from './commands/templates.js'; +import { + createStartCommand, + createLogCommand, + createStatusCommand, + createDoneCommand, + createCancelCommand, +} from './commands/session.js'; +import { createLastCommand, createHistoryCommand } from './commands/history.js'; const program = new Command(); -program.name('workout').description('Workout CLI').version('0.1.0'); +program + .name('workout') + .description('CLI for tracking workouts, managing exercises, and querying training history') + .version('0.1.0'); + +program.addCommand(createExercisesCommand()); +program.addCommand(createTemplatesCommand()); +program.addCommand(createStartCommand()); +program.addCommand(createLogCommand()); +program.addCommand(createStatusCommand()); +program.addCommand(createDoneCommand()); +program.addCommand(createCancelCommand()); +program.addCommand(createLastCommand()); +program.addCommand(createHistoryCommand()); program.parse(); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..94314ef --- /dev/null +++ b/src/types.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; + +export const MuscleGroup = z.enum([ + 'chest', + 'back', + 'shoulders', + 'biceps', + 'triceps', + 'forearms', + 'quads', + 'hamstrings', + 'glutes', + 'calves', + 'abs', + 'traps', + 'lats', + 'rear-delts', + 'side-delts', + 'front-delts', +]); +export type MuscleGroup = z.infer; + +export const ExerciseType = z.enum(['compound', 'isolation']); +export type ExerciseType = z.infer; + +export const Equipment = z.enum([ + 'barbell', + 'dumbbell', + 'cable', + 'machine', + 'bodyweight', + 'kettlebell', + 'band', + 'other', +]); +export type Equipment = z.infer; + +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, + notes: z.string().optional(), +}); +export type Exercise = z.infer; + +export const TemplateExercise = z.object({ + exercise: z.string(), + sets: z.number().int().positive(), + reps: z.string(), +}); +export type TemplateExercise = z.infer; + +export const Template = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + exercises: z.array(TemplateExercise), +}); +export type Template = z.infer; + +export const SetLog = z.object({ + weight: z.number(), + reps: z.number().int().positive(), + rir: z.number().int().min(0).max(10).nullable().default(null), +}); +export type SetLog = z.infer; + +export const ExerciseLog = z.object({ + exercise: z.string(), + sets: z.array(SetLog), + notes: z.string().optional(), +}); +export type ExerciseLog = z.infer; + +export const WorkoutStats = z.object({ + totalSets: z.number().int(), + totalVolume: z.number(), + musclesWorked: z.array(z.string()), +}); +export type WorkoutStats = z.infer; + +export const Workout = z.object({ + id: z.string(), + date: z.string(), + template: z.string().nullable(), + startTime: z.string(), + endTime: z.string().nullable(), + exercises: z.array(ExerciseLog), + notes: z.array(z.string()).default([]), + stats: WorkoutStats.optional(), +}); +export type Workout = z.infer; + +export const Units = z.enum(['lbs', 'kg']); +export type Units = z.infer; + +export const Config = z.object({ + units: Units.default('lbs'), + dataDir: z.string().default('~/.workout'), +}); +export type Config = z.infer; + +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..8e74d87 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,253 @@ +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'; + +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('CLI integration', () => { + let testHome: string; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-cli-test-')); + }); + + afterEach(() => { + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + describe('exercises', () => { + it('lists default exercises', () => { + const { stdout, exitCode } = cli('exercises list', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('bench-press'); + expect(stdout).toContain('squat'); + expect(stdout).toContain('deadlift'); + }); + + it('filters by muscle', () => { + const { stdout, exitCode } = cli('exercises list --muscle chest', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('bench-press'); + expect(stdout).not.toContain('squat'); + }); + + it('filters by type', () => { + const { stdout, exitCode } = cli('exercises list --type isolation', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('bicep-curl'); + expect(stdout).not.toContain('deadlift'); + }); + + it('shows exercise details', () => { + const { stdout, exitCode } = cli('exercises show bench-press', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Name: Bench Press'); + expect(stdout).toContain('Type: compound'); + expect(stdout).toContain('Equipment: barbell'); + expect(stdout).toContain('Muscles: chest'); + }); + + it('shows exercise by alias', () => { + const { stdout, exitCode } = cli('exercises show bench', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Name: Bench Press'); + }); + + it('adds custom exercise', () => { + const { exitCode } = cli( + 'exercises add "Zercher Squat" --muscles quads,glutes --type compound --equipment barbell', + testHome + ); + expect(exitCode).toBe(0); + + const { stdout } = cli('exercises show zercher-squat', testHome); + expect(stdout).toContain('Name: Zercher Squat'); + }); + + it('outputs JSON', () => { + const { stdout, exitCode } = cli('exercises show bench-press --json', testHome); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.id).toBe('bench-press'); + expect(parsed.muscles).toContain('chest'); + }); + + it('fails for unknown exercise', () => { + const { exitCode } = cli('exercises show nonexistent', testHome); + expect(exitCode).toBe(1); + }); + }); + + describe('templates', () => { + it('starts with no templates', () => { + const { stdout, exitCode } = cli('templates list', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('No templates found'); + }); + + it('creates template', () => { + const { exitCode } = cli( + 'templates create "Push A" --exercises "bench-press:3x8-12, overhead-press:3x8-12"', + testHome + ); + expect(exitCode).toBe(0); + + const { stdout } = cli('templates show push-a', testHome); + expect(stdout).toContain('Name: Push A'); + expect(stdout).toContain('bench-press: 3x8-12'); + }); + + it('lists templates', () => { + cli('templates create "Push" --exercises "bench-press:3x8"', testHome); + cli('templates create "Pull" --exercises "barbell-row:3x8"', testHome); + + const { stdout } = cli('templates list', testHome); + expect(stdout).toContain('push'); + expect(stdout).toContain('pull'); + }); + + it('deletes template', () => { + cli('templates create "To Delete" --exercises "squat:3x5"', testHome); + const { exitCode } = cli('templates delete to-delete', testHome); + expect(exitCode).toBe(0); + + const { stdout } = cli('templates list', testHome); + expect(stdout).toContain('No templates found'); + }); + }); + + describe('workout session', () => { + it('shows no active workout initially', () => { + const { stdout, exitCode } = cli('status', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('No active workout'); + }); + + it('starts freestyle workout', () => { + const { stdout, exitCode } = cli('start --empty', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Started workout'); + expect(stdout).toContain('Freestyle'); + }); + + it('starts workout from template', () => { + cli('templates create "Push" --exercises "bench-press:3x8"', testHome); + const { stdout, exitCode } = cli('start push', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Template: push'); + }); + + it('prevents starting second workout', () => { + cli('start --empty', testHome); + const { exitCode } = cli('start --empty', testHome); + expect(exitCode).toBe(1); + }); + + it('logs sets', () => { + cli('start --empty', testHome); + const { stdout, exitCode } = cli('log bench-press 135 10', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Logged 1 set'); + expect(stdout).toContain('Bench Press'); + expect(stdout).toContain('135'); + }); + + it('logs multiple sets at once', () => { + cli('start --empty', testHome); + const { stdout, exitCode } = cli('log squat 185 5,5,5', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Logged 3 sets'); + }); + + it('shows workout status', () => { + cli('start --empty', testHome); + cli('log bench-press 135 10', testHome); + cli('log bench-press 135 8', testHome); + + const { stdout } = cli('status', testHome); + expect(stdout).toContain('Bench Press'); + expect(stdout).toContain('135'); + }); + + it('cancels workout', () => { + cli('start --empty', testHome); + cli('log squat 225 5', testHome); + + const { exitCode } = cli('cancel', testHome); + expect(exitCode).toBe(0); + + const { stdout } = cli('status', testHome); + expect(stdout).toContain('No active workout'); + }); + + it('finishes workout', () => { + cli('start --empty', testHome); + cli('log bench-press 135 10', testHome); + cli('log bench-press 135 8', testHome); + + const { stdout, exitCode } = cli('done', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Workout complete'); + expect(stdout).toContain('Total sets: 2'); + + const { stdout: statusOut } = cli('status', testHome); + expect(statusOut).toContain('No active workout'); + }); + }); + + describe('history', () => { + beforeEach(() => { + cli('start --empty', testHome); + cli('log bench-press 135 10', testHome); + cli('log bench-press 135 8', testHome); + cli('log squat 185 5', testHome); + cli('done', testHome); + }); + + it('shows last workout', () => { + const { stdout, exitCode } = cli('last', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('Bench Press'); + expect(stdout).toContain('Squat'); + }); + + it('shows last workout with full details', () => { + const { stdout, exitCode } = cli('last --full', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('135lbs x 10'); + expect(stdout).toContain('135lbs x 8'); + }); + + it('shows exercise history', () => { + const { stdout, exitCode } = cli('history bench-press', testHome); + expect(exitCode).toBe(0); + expect(stdout).toContain('History for Bench Press'); + expect(stdout).toContain('135'); + }); + + it('outputs history as JSON', () => { + const { stdout, exitCode } = cli('history bench-press --json', testHome); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].log.exercise).toBe('bench-press'); + }); + }); +}); diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..8d209ec --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Storage, resetStorage } from '../src/data/storage.js'; + +describe('workout session flow', () => { + let testDir: string; + let storage: Storage; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(testDir); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('complete workout flow: start -> log -> done', () => { + storage.addTemplate({ + id: 'push-a', + name: 'Push A', + exercises: [ + { exercise: 'bench-press', sets: 3, reps: '8-12' }, + { exercise: 'overhead-press', sets: 3, reps: '8-12' }, + ], + }); + + const now = new Date(); + const date = now.toISOString().split('T')[0]!; + const template = storage.getTemplate('push-a')!; + + const workout = { + id: `${date}-push-a`, + date, + template: 'push-a', + startTime: now.toISOString(), + endTime: null, + exercises: template.exercises.map((e) => ({ + exercise: e.exercise, + sets: [], + })), + notes: [], + }; + storage.saveCurrentWorkout(workout); + + expect(storage.getCurrentWorkout()).not.toBeNull(); + expect(storage.getCurrentWorkout()?.template).toBe('push-a'); + + const current = storage.getCurrentWorkout()!; + const benchLog = current.exercises.find((e) => e.exercise === 'bench-press')!; + benchLog.sets.push( + { weight: 135, reps: 10, rir: null }, + { weight: 135, reps: 9, rir: null }, + { weight: 135, reps: 8, rir: 1 } + ); + storage.saveCurrentWorkout(current); + + const updated = storage.getCurrentWorkout()!; + const benchSets = updated.exercises.find((e) => e.exercise === 'bench-press')!.sets; + expect(benchSets).toHaveLength(3); + expect(benchSets[0]?.weight).toBe(135); + expect(benchSets[2]?.rir).toBe(1); + + updated.endTime = new Date().toISOString(); + updated.stats = { + totalSets: 3, + totalVolume: 135 * 10 + 135 * 9 + 135 * 8, + musclesWorked: ['chest', 'triceps', 'front-delts'], + }; + storage.finishWorkout(updated); + + expect(storage.getCurrentWorkout()).toBeNull(); + expect(storage.getWorkout(date)).not.toBeNull(); + expect(storage.getLastWorkout()?.id).toBe(`${date}-push-a`); + }); + + it('freestyle workout without template', () => { + const now = new Date(); + const date = now.toISOString().split('T')[0]!; + + const workout = { + id: `${date}-freestyle`, + date, + template: null, + startTime: now.toISOString(), + endTime: null, + exercises: [], + notes: [], + }; + storage.saveCurrentWorkout(workout); + + const current = storage.getCurrentWorkout()!; + current.exercises.push({ + exercise: 'deadlift', + sets: [{ weight: 225, reps: 5, rir: null }], + }); + storage.saveCurrentWorkout(current); + + expect(storage.getCurrentWorkout()?.exercises).toHaveLength(1); + expect(storage.getCurrentWorkout()?.template).toBeNull(); + }); + + it('cancel workout clears current without saving', () => { + const now = new Date(); + const date = now.toISOString().split('T')[0]!; + + storage.saveCurrentWorkout({ + id: `${date}-test`, + date, + template: null, + startTime: now.toISOString(), + endTime: null, + exercises: [{ exercise: 'squat', sets: [{ weight: 185, reps: 5, rir: null }] }], + notes: [], + }); + + storage.clearCurrentWorkout(); + + expect(storage.getCurrentWorkout()).toBeNull(); + expect(storage.getWorkout(date)).toBeNull(); + }); + + it('tracks exercise history across workouts', () => { + const dates = ['2026-01-20', '2026-01-22', '2026-01-24']; + const weights = [135, 140, 145]; + + for (let i = 0; i < dates.length; i++) { + storage.finishWorkout({ + id: `${dates[i]}-push`, + date: dates[i]!, + template: 'push-a', + startTime: `${dates[i]}T10:00:00Z`, + endTime: `${dates[i]}T11:00:00Z`, + exercises: [ + { + exercise: 'bench-press', + sets: [ + { weight: weights[i]!, reps: 10, rir: null }, + { weight: weights[i]!, reps: 8, rir: null }, + ], + }, + ], + notes: [], + }); + } + + const history = storage.getExerciseHistory('bench-press'); + expect(history).toHaveLength(3); + expect(history[0]?.log.sets[0]?.weight).toBe(145); + expect(history[1]?.log.sets[0]?.weight).toBe(140); + expect(history[2]?.log.sets[0]?.weight).toBe(135); + }); + + it('handles multiple sets logged at once', () => { + const now = new Date(); + const date = now.toISOString().split('T')[0]!; + + storage.saveCurrentWorkout({ + id: `${date}-test`, + date, + template: null, + startTime: now.toISOString(), + endTime: null, + exercises: [], + notes: [], + }); + + const current = storage.getCurrentWorkout()!; + current.exercises.push({ + exercise: 'lat-pulldown', + sets: [ + { weight: 100, reps: 12, rir: null }, + { weight: 100, reps: 10, rir: null }, + { weight: 100, reps: 8, rir: 2 }, + ], + }); + storage.saveCurrentWorkout(current); + + const updated = storage.getCurrentWorkout()!; + const sets = updated.exercises[0]!.sets; + expect(sets).toHaveLength(3); + expect(sets.map((s) => s.reps)).toEqual([12, 10, 8]); + }); +}); + +describe('exercise management', () => { + let testDir: string; + let storage: Storage; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(testDir); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('filters exercises by muscle group', () => { + const exercises = storage.getExercises(); + const chestExercises = exercises.filter((e) => e.muscles.includes('chest')); + + expect(chestExercises.length).toBeGreaterThan(0); + for (const e of chestExercises) { + expect(e.muscles).toContain('chest'); + } + }); + + it('filters exercises by type', () => { + const exercises = storage.getExercises(); + const compounds = exercises.filter((e) => e.type === 'compound'); + const isolations = exercises.filter((e) => e.type === 'isolation'); + + expect(compounds.length).toBeGreaterThan(0); + expect(isolations.length).toBeGreaterThan(0); + expect(compounds.length + isolations.length).toBe(exercises.length); + }); + + it('finds exercise by alias', () => { + const byId = storage.getExercise('bench-press'); + const byAlias = storage.getExercise('bench'); + const byAnotherAlias = storage.getExercise('flat bench'); + + expect(byId).toBeDefined(); + expect(byAlias).toBeDefined(); + expect(byAnotherAlias).toBeDefined(); + expect(byId?.id).toBe(byAlias?.id); + expect(byId?.id).toBe(byAnotherAlias?.id); + }); + + it('adds custom exercise', () => { + storage.addExercise({ + id: 'zercher-squat', + name: 'Zercher Squat', + aliases: ['zercher'], + muscles: ['quads', 'glutes', 'abs'], + type: 'compound', + equipment: 'barbell', + }); + + const exercise = storage.getExercise('zercher-squat'); + expect(exercise).toBeDefined(); + expect(exercise?.name).toBe('Zercher Squat'); + + const byAlias = storage.getExercise('zercher'); + expect(byAlias?.id).toBe('zercher-squat'); + }); + + it('edits exercise to add alias', () => { + const original = storage.getExercise('squat')!; + expect(original.aliases).not.toContain('squats'); + + storage.updateExercise('squat', { + aliases: [...original.aliases, 'squats'], + }); + + const updated = storage.getExercise('squat')!; + expect(updated.aliases).toContain('squats'); + + const byNewAlias = storage.getExercise('squats'); + expect(byNewAlias?.id).toBe('squat'); + }); + + it('deleting exercise removes it from library', () => { + const before = storage.getExercises().length; + storage.deleteExercise('plank'); + + expect(storage.getExercise('plank')).toBeUndefined(); + expect(storage.getExercises().length).toBe(before - 1); + }); +}); + +describe('template management', () => { + let testDir: string; + let storage: Storage; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(testDir); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('creates template with exercise specs', () => { + storage.addTemplate({ + id: 'pull-a', + name: 'Pull A', + description: 'Back and biceps focus', + exercises: [ + { exercise: 'deadlift', sets: 3, reps: '5' }, + { exercise: 'barbell-row', sets: 3, reps: '8-12' }, + { exercise: 'lat-pulldown', sets: 3, reps: '10-15' }, + { exercise: 'bicep-curl', sets: 2, reps: '12-15' }, + ], + }); + + const template = storage.getTemplate('pull-a')!; + expect(template.exercises).toHaveLength(4); + expect(template.exercises[0]?.reps).toBe('5'); + expect(template.exercises[2]?.reps).toBe('10-15'); + }); + + it('lists all templates', () => { + storage.addTemplate({ id: 'push', name: 'Push', exercises: [] }); + storage.addTemplate({ id: 'pull', name: 'Pull', exercises: [] }); + storage.addTemplate({ id: 'legs', name: 'Legs', exercises: [] }); + + const templates = storage.getTemplates(); + expect(templates).toHaveLength(3); + expect(templates.map((t) => t.id).sort()).toEqual(['legs', 'pull', 'push']); + }); + + it('deletes template', () => { + storage.addTemplate({ id: 'to-delete', name: 'Delete Me', exercises: [] }); + expect(storage.getTemplate('to-delete')).toBeDefined(); + + storage.deleteTemplate('to-delete'); + expect(storage.getTemplate('to-delete')).toBeUndefined(); + }); +}); + +describe('config management', () => { + let testDir: string; + let storage: Storage; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(testDir); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('defaults to pounds', () => { + const config = storage.getConfig(); + expect(config.units).toBe('lbs'); + }); + + it('switches to kilograms', () => { + storage.saveConfig({ units: 'kg', dataDir: testDir }); + const config = storage.getConfig(); + expect(config.units).toBe('kg'); + }); + + it('persists config across storage instances', () => { + storage.saveConfig({ units: 'kg', dataDir: testDir }); + + resetStorage(); + const newStorage = new Storage(testDir); + expect(newStorage.getConfig().units).toBe('kg'); + }); +}); diff --git a/test/example.test.ts b/test/example.test.ts deleted file mode 100644 index 3d71fa5..0000000 --- a/test/example.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('example', () => { - it('should pass', () => { - expect(1 + 1).toBe(2); - }); -}); diff --git a/test/exercises.test.ts b/test/exercises.test.ts new file mode 100644 index 0000000..fa38ad2 --- /dev/null +++ b/test/exercises.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { defaultExercises } from '../src/exercises.js'; + +describe('defaultExercises', () => { + it('contains common compound movements', () => { + const compoundIds = ['bench-press', 'squat', 'deadlift', 'overhead-press', 'barbell-row']; + for (const id of compoundIds) { + const exercise = defaultExercises.find((e) => e.id === id); + expect(exercise, `${id} should exist`).toBeDefined(); + expect(exercise?.type).toBe('compound'); + } + }); + + it('contains common isolation movements', () => { + const isolationIds = ['bicep-curl', 'tricep-pushdown', 'lateral-raise', 'leg-curl']; + for (const id of isolationIds) { + const exercise = defaultExercises.find((e) => e.id === id); + expect(exercise, `${id} should exist`).toBeDefined(); + expect(exercise?.type).toBe('isolation'); + } + }); + + it('all exercises have required fields', () => { + for (const exercise of defaultExercises) { + expect(exercise.id).toBeTruthy(); + expect(exercise.name).toBeTruthy(); + expect(exercise.muscles.length).toBeGreaterThan(0); + expect(['compound', 'isolation']).toContain(exercise.type); + expect(exercise.equipment).toBeTruthy(); + } + }); + + it('has unique ids', () => { + const ids = defaultExercises.map((e) => e.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + + it('exercises have useful aliases', () => { + const benchPress = defaultExercises.find((e) => e.id === 'bench-press'); + expect(benchPress?.aliases).toContain('bench'); + + const ohp = defaultExercises.find((e) => e.id === 'overhead-press'); + expect(ohp?.aliases).toContain('ohp'); + + const rdl = defaultExercises.find((e) => e.id === 'romanian-deadlift'); + expect(rdl?.aliases).toContain('rdl'); + }); +}); diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..4ebf018 --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Storage, resetStorage } from '../src/data/storage.js'; + +describe('Storage', () => { + let testDir: string; + let storage: Storage; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + resetStorage(); + storage = new Storage(testDir); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + describe('config', () => { + it('returns default config when none exists', () => { + const config = storage.getConfig(); + expect(config.units).toBe('lbs'); + }); + + it('saves and retrieves config', () => { + storage.saveConfig({ units: 'kg', dataDir: testDir }); + const config = storage.getConfig(); + expect(config.units).toBe('kg'); + }); + }); + + describe('exercises', () => { + it('initializes with default exercises', () => { + const exercises = storage.getExercises(); + expect(exercises.length).toBeGreaterThan(0); + expect(exercises.find((e) => e.id === 'bench-press')).toBeDefined(); + }); + + it('finds exercise by id', () => { + const exercise = storage.getExercise('bench-press'); + expect(exercise).toBeDefined(); + expect(exercise?.name).toBe('Bench Press'); + }); + + it('finds exercise by alias', () => { + const exercise = storage.getExercise('bench'); + expect(exercise).toBeDefined(); + expect(exercise?.id).toBe('bench-press'); + }); + + it('adds a new exercise', () => { + storage.addExercise({ + id: 'custom-exercise', + name: 'Custom Exercise', + aliases: [], + muscles: ['chest'], + type: 'isolation', + equipment: 'cable', + }); + const exercise = storage.getExercise('custom-exercise'); + expect(exercise).toBeDefined(); + expect(exercise?.name).toBe('Custom Exercise'); + }); + + it('throws when adding duplicate exercise', () => { + expect(() => + storage.addExercise({ + id: 'bench-press', + name: 'Duplicate', + aliases: [], + muscles: ['chest'], + type: 'compound', + equipment: 'barbell', + }) + ).toThrow('already exists'); + }); + + it('updates an exercise', () => { + storage.updateExercise('bench-press', { name: 'Updated Bench' }); + const exercise = storage.getExercise('bench-press'); + expect(exercise?.name).toBe('Updated Bench'); + }); + + it('deletes an exercise', () => { + storage.deleteExercise('bench-press'); + const exercise = storage.getExercise('bench-press'); + expect(exercise).toBeUndefined(); + }); + }); + + describe('templates', () => { + it('starts with empty templates', () => { + const templates = storage.getTemplates(); + expect(templates).toEqual([]); + }); + + it('adds and retrieves a template', () => { + storage.addTemplate({ + id: 'push-a', + name: 'Push A', + exercises: [{ exercise: 'bench-press', sets: 3, reps: '8-12' }], + }); + const template = storage.getTemplate('push-a'); + expect(template).toBeDefined(); + expect(template?.name).toBe('Push A'); + expect(template?.exercises).toHaveLength(1); + }); + + it('deletes a template', () => { + storage.addTemplate({ + id: 'to-delete', + name: 'To Delete', + exercises: [], + }); + storage.deleteTemplate('to-delete'); + const template = storage.getTemplate('to-delete'); + expect(template).toBeUndefined(); + }); + }); + + describe('workouts', () => { + it('returns null when no current workout', () => { + const current = storage.getCurrentWorkout(); + expect(current).toBeNull(); + }); + + it('saves and retrieves current workout', () => { + const workout = { + id: '2026-01-22-test', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: null, + exercises: [], + notes: [], + }; + storage.saveCurrentWorkout(workout); + const current = storage.getCurrentWorkout(); + expect(current).toBeDefined(); + expect(current?.id).toBe('2026-01-22-test'); + }); + + it('finishes workout and moves to workouts folder', () => { + const workout = { + id: '2026-01-22-test', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: '2026-01-22T11:00:00Z', + exercises: [], + notes: [], + }; + storage.saveCurrentWorkout(workout); + storage.finishWorkout(workout); + + expect(storage.getCurrentWorkout()).toBeNull(); + expect(storage.getWorkout('2026-01-22')).toBeDefined(); + }); + + it('gets all workouts sorted by date descending', () => { + storage.finishWorkout({ + id: '2026-01-20-test', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [], + notes: [], + }); + storage.finishWorkout({ + id: '2026-01-22-test', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: '2026-01-22T11:00:00Z', + exercises: [], + notes: [], + }); + + const workouts = storage.getAllWorkouts(); + expect(workouts).toHaveLength(2); + expect(workouts[0]?.date).toBe('2026-01-22'); + expect(workouts[1]?.date).toBe('2026-01-20'); + }); + + it('gets exercise history', () => { + storage.finishWorkout({ + id: '2026-01-20-test', + 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-test', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: '2026-01-22T11:00:00Z', + exercises: [ + { + exercise: 'bench-press', + sets: [{ weight: 140, reps: 8, rir: null }], + }, + ], + notes: [], + }); + + const history = storage.getExerciseHistory('bench-press'); + expect(history).toHaveLength(2); + expect(history[0]?.log.sets[0]?.weight).toBe(140); + expect(history[1]?.log.sets[0]?.weight).toBe(135); + }); + }); +}); diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..d9f8613 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { slugify, Exercise, Template, Workout, Config } from '../src/types.js'; + +describe('slugify', () => { + it('converts name to lowercase kebab-case', () => { + expect(slugify('Bench Press')).toBe('bench-press'); + }); + + it('handles multiple spaces', () => { + expect(slugify('Lat Pulldown')).toBe('lat-pulldown'); + }); + + it('removes special characters', () => { + expect(slugify("Farmer's Walk")).toBe('farmer-s-walk'); + }); + + it('handles leading/trailing spaces', () => { + expect(slugify(' Squat ')).toBe('squat'); + }); +}); + +describe('Exercise schema', () => { + it('parses valid exercise', () => { + const result = Exercise.parse({ + id: 'test', + name: 'Test Exercise', + aliases: ['alias1'], + muscles: ['chest'], + type: 'compound', + equipment: 'barbell', + }); + expect(result.id).toBe('test'); + expect(result.aliases).toEqual(['alias1']); + }); + + it('defaults aliases to empty array', () => { + const result = Exercise.parse({ + id: 'test', + name: 'Test', + muscles: ['chest'], + type: 'compound', + equipment: 'barbell', + }); + expect(result.aliases).toEqual([]); + }); + + it('rejects invalid muscle group', () => { + expect(() => + Exercise.parse({ + id: 'test', + name: 'Test', + muscles: ['invalid-muscle'], + type: 'compound', + equipment: 'barbell', + }) + ).toThrow(); + }); +}); + +describe('Template schema', () => { + it('parses valid template', () => { + const result = Template.parse({ + id: 'push-a', + name: 'Push A', + exercises: [{ exercise: 'bench-press', sets: 3, reps: '8-12' }], + }); + expect(result.id).toBe('push-a'); + expect(result.exercises).toHaveLength(1); + }); + + it('allows optional description', () => { + const result = Template.parse({ + id: 'push-a', + name: 'Push A', + description: 'Push day workout', + exercises: [], + }); + expect(result.description).toBe('Push day workout'); + }); +}); + +describe('Workout schema', () => { + it('parses complete workout', () => { + const result = Workout.parse({ + id: '2026-01-22-push', + date: '2026-01-22', + template: 'push-a', + startTime: '2026-01-22T10:00:00Z', + endTime: '2026-01-22T11:00:00Z', + exercises: [ + { + exercise: 'bench-press', + sets: [{ weight: 135, reps: 10, rir: 2 }], + }, + ], + notes: ['Good session'], + stats: { + totalSets: 1, + totalVolume: 1350, + musclesWorked: ['chest'], + }, + }); + expect(result.template).toBe('push-a'); + expect(result.exercises[0]?.sets[0]?.rir).toBe(2); + }); + + it('allows null template for freestyle', () => { + const result = Workout.parse({ + id: '2026-01-22-freestyle', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: null, + exercises: [], + notes: [], + }); + expect(result.template).toBeNull(); + }); + + it('defaults rir to null', () => { + const result = Workout.parse({ + id: 'test', + date: '2026-01-22', + template: null, + startTime: '2026-01-22T10:00:00Z', + endTime: null, + exercises: [ + { + exercise: 'bench-press', + sets: [{ weight: 135, reps: 10 }], + }, + ], + notes: [], + }); + expect(result.exercises[0]?.sets[0]?.rir).toBeNull(); + }); +}); + +describe('Config schema', () => { + it('defaults to lbs', () => { + const result = Config.parse({}); + expect(result.units).toBe('lbs'); + }); + + it('accepts kg', () => { + const result = Config.parse({ units: 'kg' }); + expect(result.units).toBe('kg'); + }); + + it('rejects invalid units', () => { + expect(() => Config.parse({ units: 'stones' })).toThrow(); + }); +});