From 074ec29d193a461951bf8c46d93dd00d1a2e926e Mon Sep 17 00:00:00 2001 From: Jordan Tryon Date: Sat, 20 Dec 2025 23:39:44 -0500 Subject: [PATCH] feat: Implement Thicc weight tracking CLI with full feature set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command-line weight tracker with visualization, built with Go, Cobra, Lipgloss, and SQLite. Features: - Add, modify, delete, and view weight entries - Automatic BMI calculation and storage - Support for both metric (kg/cm) and imperial (lbs/in) units - ASCII line graph showing weight trend over time - Goal weight tracking with visual goal line on graph - Table display with date, weight, and BMI - First-launch setup wizard for units, height, and goal weight - SQLite database storage in ~/.thicc/weights.db Commands: - add [date] - Add weight entry (defaults to today) - show [number|date] - Display table and graph (default: last 20) - modify - Update existing entry - delete - Remove entry - goal - Set goal weight - reset - Wipe all data and settings Technical Implementation: - Validation package with proper date/number validation and bounds checking - BMI calculator supporting all unit combinations - Graph rendering with Bresenham's line algorithm - Modular architecture with separate packages for calculator, database, display, models - Comprehensive test suite (23 tests) - GitHub Actions for PR testing and releases - Demo data scripts for testing ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .github/thicc.png | Bin 0 -> 66931 bytes .github/workflows/pr-tests.yml | 67 ++++++ .github/workflows/release.yml | 203 ++++++++++++++++ CLAUDE.md | 132 +++++++++++ README.md | 120 +++++++++- cmd/add.go | 55 +++++ cmd/delete.go | 39 ++++ cmd/goal.go | 43 ++++ cmd/modify.go | 52 +++++ cmd/reset.go | 54 +++++ cmd/root.go | 101 ++++++++ cmd/show.go | 79 +++++++ go.mod | 34 +++ go.sum | 86 +++++++ internal/calculator/bmi.go | 26 +++ internal/config/paths.go | 24 ++ internal/database/db.go | 35 +++ internal/database/schema.go | 28 +++ internal/display/constants.go | 21 ++ internal/display/formatter.go | 18 ++ internal/display/styles.go | 25 ++ internal/display/table.go | 371 ++++++++++++++++++++++++++++++ internal/models/settings.go | 171 ++++++++++++++ internal/models/weight.go | 84 +++++++ internal/validation/validation.go | 128 +++++++++++ main.go | 7 + setup-demo.bat | 55 +++++ setup-demo.sh | 55 +++++ tests/calculator_test.go | 89 +++++++ tests/display_test.go | 62 +++++ tests/goal_test.go | 170 ++++++++++++++ tests/models_test.go | 292 +++++++++++++++++++++++ 32 files changed, 2724 insertions(+), 2 deletions(-) create mode 100644 .github/thicc.png create mode 100644 .github/workflows/pr-tests.yml create mode 100644 .github/workflows/release.yml create mode 100644 CLAUDE.md create mode 100644 cmd/add.go create mode 100644 cmd/delete.go create mode 100644 cmd/goal.go create mode 100644 cmd/modify.go create mode 100644 cmd/reset.go create mode 100644 cmd/root.go create mode 100644 cmd/show.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/calculator/bmi.go create mode 100644 internal/config/paths.go create mode 100644 internal/database/db.go create mode 100644 internal/database/schema.go create mode 100644 internal/display/constants.go create mode 100644 internal/display/formatter.go create mode 100644 internal/display/styles.go create mode 100644 internal/display/table.go create mode 100644 internal/models/settings.go create mode 100644 internal/models/weight.go create mode 100644 internal/validation/validation.go create mode 100644 main.go create mode 100644 setup-demo.bat create mode 100644 setup-demo.sh create mode 100644 tests/calculator_test.go create mode 100644 tests/display_test.go create mode 100644 tests/goal_test.go create mode 100644 tests/models_test.go diff --git a/.github/thicc.png b/.github/thicc.png new file mode 100644 index 0000000000000000000000000000000000000000..68dfd7f9955b9bbeb814e9fb1069c8eaf7a90ab2 GIT binary patch literal 66931 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 | tee test-output.txt + echo "test_exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + + - name: Comment PR with test results + uses: actions/github-script@v7 + if: always() + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const testOutput = fs.readFileSync('test-output.txt', 'utf8'); + const testPassed = ${{ steps.test.outputs.test_exit_code }} === 0; + + const emoji = testPassed ? 'โœ…' : 'โŒ'; + const status = testPassed ? 'PASSED' : 'FAILED'; + + const body = `## ${emoji} Test Results: ${status} + +
+ Test Output + + \`\`\` + ${testOutput} + \`\`\` + +
`; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + - name: Fail if tests failed + if: steps.test.outputs.test_exit_code != '0' + run: exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3f5dae5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,203 @@ +name: Build and Release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v ./tests/... + + version: + name: Calculate Version + runs-on: ubuntu-latest + needs: test + outputs: + new_version: ${{ steps.version.outputs.new_version }} + changelog: ${{ steps.version.outputs.changelog }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get latest tag + id: get_tag + run: | + # Get the latest tag, or use v0.0.0 if no tags exist + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "Latest tag: $LATEST_TAG" + + - name: Calculate new version + id: version + run: | + LATEST_TAG="${{ steps.get_tag.outputs.latest_tag }}" + + # Remove 'v' prefix + VERSION=${LATEST_TAG#v} + + # Split version into parts + IFS='.' read -ra VERSION_PARTS <<< "$VERSION" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=${VERSION_PARTS[2]} + + # Get commit messages since last tag + if [ "$LATEST_TAG" = "v0.0.0" ]; then + COMMITS=$(git log --pretty=format:"%s") + else + COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s") + fi + + echo "Commits since last tag:" + echo "$COMMITS" + + # Check for breaking changes (MAJOR) + if echo "$COMMITS" | grep -qiE "BREAKING CHANGE|major:"; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + BUMP_TYPE="major" + # Check for features (MINOR) + elif echo "$COMMITS" | grep -qiE "^feat|^feature|minor:"; then + MINOR=$((MINOR + 1)) + PATCH=0 + BUMP_TYPE="minor" + # Default to PATCH + else + PATCH=$((PATCH + 1)) + BUMP_TYPE="patch" + fi + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION (${BUMP_TYPE} bump)" + + # Generate changelog + CHANGELOG="## Changes in $NEW_VERSION\n\n" + if [ "$LATEST_TAG" = "v0.0.0" ]; then + CHANGELOG+="Initial release\n\n" + CHANGELOG+=$(git log --pretty=format:"- %s (%h)" | head -20) + else + CHANGELOG+=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s (%h)") + fi + + echo "changelog<> $GITHUB_OUTPUT + echo -e "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + build: + name: Build Binaries + runs-on: ubuntu-latest + needs: version + strategy: + matrix: + include: + # Windows + - goos: windows + goarch: amd64 + output: thicc-windows-amd64.exe + - goos: windows + goarch: arm64 + output: thicc-windows-arm64.exe + + # macOS + - goos: darwin + goarch: amd64 + output: thicc-macos-amd64 + - goos: darwin + goarch: arm64 + output: thicc-macos-arm64 + + # Linux + - goos: linux + goarch: amd64 + output: thicc-linux-amd64 + - goos: linux + goarch: arm64 + output: thicc-linux-arm64 + - goos: linux + goarch: 386 + output: thicc-linux-386 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Download dependencies + run: go mod download + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION="${{ needs.version.outputs.new_version }}" + go build -ldflags "-s -w -X main.Version=${VERSION}" -o ${{ matrix.output }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output }} + path: ${{ matrix.output }} + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [version, build] + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./binaries + + - name: Display structure of downloaded files + run: ls -R ./binaries + + - name: Prepare release assets + run: | + mkdir -p release + find ./binaries -type f -exec cp {} ./release/ \; + ls -lh ./release + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.version.outputs.new_version }} + name: Release ${{ needs.version.outputs.new_version }} + body: ${{ needs.version.outputs.changelog }} + files: ./release/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0826598 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +THICC is a weight tracking CLI tool built with Go that visualizes weight progress over time with tables and ASCII line graphs. Uses SQLite for local storage and Cobra for CLI framework. + +## Build and Development Commands + +```bash +# Build executable +go build -o thicc.exe + +# Run tests +go test ./tests/... -v + +# Run specific test +go test ./tests/... -v -run TestGoalWeightSetting + +# Download dependencies +go mod tidy + +# Seed demo data (requires built executable) +./setup-demo.sh # Unix/Linux/macOS +setup-demo.bat # Windows +``` + +## Architecture + +### Core Flow +1. **Initialization** (`cmd/root.go`): On startup, `initDatabase()` runs via Cobra's `OnInitialize` + - Opens database at `~/.thicc/weights.db` + - Checks if settings exist (`models.GetSettings()`) + - If no settings (first launch), runs `models.SetupSettings()` which prompts for units, height, and goal weight + - Settings are stored in package-level variables `db` and `settings` accessible via `GetDB()` and `GetSettings()` + +2. **Command Pattern**: All commands in `cmd/` access shared DB and settings via `GetDB()` and `GetSettings()` + - Commands call model functions to perform database operations + - After mutation commands (add, modify, delete, goal), `showCmd.Run()` is called to display updated data + - Running just `thicc` defaults to `show` command + +3. **Display Pipeline** (`internal/display/table.go`): + - `RenderWeightsTable()` orchestrates the entire output + - Calculates statistics (min, max, avg) from weight slice + - Computes goal difference: `latestWeight - goalWeight` + - Positive = need to lose + - Negative = need to gain + - Creates table (left) and graph (right) separately, then joins horizontally with lipgloss + - Graph uses Bresenham's line algorithm to connect data points + +### Data Layer + +**Settings** (`internal/models/settings.go`): +- Stored as key-value pairs in `settings` table +- Keys: `weight_unit`, `height_unit`, `height`, `goal_weight` +- `GetSettings()` returns nil on first launch (triggers setup flow) + +**Weights** (`internal/models/weight.go`): +- Each entry has: ID (auto-increment), date (YYYY-MM-DD), weight, BMI +- BMI is **calculated once on add** and stored (not recalculated on retrieval) +- Queries always return descending by date: `ORDER BY date DESC, id DESC` + +**BMI Calculation** (`internal/calculator/bmi.go`): +- Supports 4 unit combinations: kg+cm, lbs+in, kg+in, lbs+cm +- Called by `add` and `modify` commands to compute BMI before storage + +### Graph Rendering Details + +The ASCII line graph (`createLineGraph()` in `internal/display/table.go`): +- Normalizes weights to fit 40x20 character grid +- Includes goal weight in min/max range calculation to ensure goal line is visible +- Characters used: + - Data points: `ยท` (middle dot - smallest) + - Connecting lines: `โˆ™` (bullet operator - lighter) + - Goal line: `โ”€` (horizontal box drawing) +- Goal line drawn at calculated Y position with label "Goal: X.X" on left axis +- Reverses weight order to show oldestโ†’newest (leftโ†’right) + +### Database Schema + +```sql +weights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + weight REAL NOT NULL, + bmi REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) + +settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +) +``` + +## Key Design Decisions + +1. **BMI Storage**: BMI is calculated once and stored (not computed on-the-fly) because it depends on user's height which is a setting, not per-weight data +2. **First Launch Detection**: Uses `sql.ErrNoRows` when querying settings to detect first launch +3. **Date Format**: All dates are YYYY-MM-DD strings (Go format: "2006-01-02") +4. **Graph Range**: Goal weight is always included in min/max calculation to ensure the goal line appears on the graph +5. **Table Truncation**: Show command can fetch unlimited weights for graphing, but table display always truncates to 20 entries +6. **Default Command**: Running `thicc` with no args shows the table (mimics `thicc show`) + +## Testing + +Tests are in `tests/` directory: +- `calculator_test.go`: BMI calculations across all unit combinations +- `models_test.go`: Database CRUD operations, settings management, goal weight functionality +- `goal_test.go`: Goal difference calculations and integration tests +- `display_test.go`: Formatters for weight, BMI, dates + +All tests use `setupTestDB()` helper which creates temporary SQLite file that auto-cleans on test completion. + +## GitHub Actions + +- **PR Tests** (`.github/workflows/pr-tests.yml`): Runs tests and comments results on PRs +- **Release** (`.github/workflows/release.yml`): + - Semantic versioning based on commit messages (BREAKING/feat/patch) + - Cross-compiles for Windows/macOS/Linux (amd64, arm64, 386) + - Creates GitHub release with all binaries + - Runs on push to main + +## Demo Data + +`setup-demo.sh` and `setup-demo.bat` scripts: +- Reset database +- Configure with imperial units (lbs/in, 70" height, 145 lbs goal) +- Seed ~27 weight entries spanning Jan-May showing realistic weight loss journey +- Used for screenshots and testing visualizations diff --git a/README.md b/README.md index 1086ba4..068779d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,118 @@ -# thicc -CLI Weight Tracker +# THICC - Weight Tracking CLI + +A simple command-line tool for tracking your weight over time with visualization. + +
+ THICC Screenshot +

Track your weight, visualize your progress, and reach your goals

+
+ +## Features + +- Track weight entries with automatic BMI calculation +- Visual table display with ASCII line graph +- Support for both metric (kg/cm) and imperial (lbs/in) units +- Date-based filtering and historical views +- SQLite database storage in `~/.thicc/weights.db` + +## Installation + +Build from source: + +```bash +go build -o thicc.exe +``` + +## First Launch + +On first launch, you'll be prompted to configure: +- Weight unit (lbs or kg) +- Height unit (in or cm) +- Your height +- Your goal weight + +These settings are stored and used for BMI calculations and goal tracking. + +## Commands + +### Add a weight entry + +```bash +# Add weight for today +thicc add 70.5 + +# Add weight for a specific date (YYYY-MM-DD) +thicc add 68.2 2024-12-15 +``` + +### Show weight history + +```bash +# Show last 20 entries (default) +thicc show + +# Show last 50 entries +thicc show 50 + +# Show entries from a specific date to today +thicc show 2024-01-01 +``` + +### Modify a weight entry + +```bash +# Update weight for entry ID 5 +thicc modify 5 69.8 +``` + +### Delete a weight entry + +```bash +# Delete entry ID 3 +thicc delete 3 +``` + +### Set goal weight + +```bash +# Set goal weight to 150 lbs +thicc goal 150 +``` + +### Reset everything + +```bash +# Wipe all data and settings (requires confirmation) +thicc reset +``` + +## Display + +The `show` command displays: +- **Top**: Goal weight with difference (to lose/to gain) +- **Left side**: Table with Weight ID, Date, Weight, and BMI +- **Right side**: Line graph showing weight trend over time with goal weight line +- **Header**: Latest weight, BMI, average, min/max statistics + +## BMI Categories + +- Underweight: < 18.5 +- Normal: 18.5 - 24.9 +- Overweight: 25 - 29.9 +- Obese: โ‰ฅ 30 + +## Database + +Data is stored in `~/.thicc/weights.db` using SQLite. + +## Testing + +Run unit tests: + +```bash +go test ./tests/... -v +``` + +## License + +MIT License diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..852eafd --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/calculator" + "github.com/tryonlinux/thicc/internal/models" + "github.com/tryonlinux/thicc/internal/validation" +) + +var addCmd = &cobra.Command{ + Use: "add [date]", + Short: "Add a new weight entry", + Long: `Add a new weight entry with optional date (defaults to today). Date format: YYYY-MM-DD`, + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + settings := GetSettings() + + // Parse and validate weight + weight, err := validation.ParseAndValidateWeight(args[0]) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + // Parse date (default to today) + date := models.GetTodayDate() + if len(args) == 2 { + date = strings.TrimSpace(args[1]) + // Validate date format and validity + if err := validation.ValidateDate(date); err != nil { + fmt.Printf("Error: %v\n", err) + return + } + } + + // Calculate BMI + bmi := calculator.CalculateBMI(weight, settings.Height, settings.WeightUnit, settings.HeightUnit) + + // Add to database + err = models.AddWeight(db, date, weight, bmi) + if err != nil { + fmt.Printf("Error adding weight: %v\n", err) + return + } + + fmt.Printf("Added weight: %.2f %s on %s (BMI: %.1f)\n", weight, settings.WeightUnit, date, bmi) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..9cde851 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/models" +) + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a weight entry", + Long: `Delete a weight entry by its ID (shown in the show command).`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Parse weight ID + id, err := strconv.Atoi(strings.TrimSpace(args[0])) + if err != nil || id <= 0 { + fmt.Println("Error: Weight ID must be a positive number") + return + } + + // Delete from database + err = models.DeleteWeight(db, id) + if err != nil { + fmt.Printf("Error deleting weight: %v\n", err) + return + } + + fmt.Printf("Deleted weight entry with ID %d\n", id) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/goal.go b/cmd/goal.go new file mode 100644 index 0000000..b7803f3 --- /dev/null +++ b/cmd/goal.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/validation" +) + +var goalCmd = &cobra.Command{ + Use: "goal ", + Short: "Set your goal weight", + Long: `Set or update your goal weight target.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + settings := GetSettings() + + // Parse and validate goal weight + goalWeight, err := validation.ParseAndValidateWeight(args[0]) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + // Update in database + _, err = db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('goal_weight', ?)", + strconv.FormatFloat(goalWeight, 'f', 2, 64)) + if err != nil { + fmt.Printf("Error updating goal weight: %v\n", err) + return + } + + // Update settings in memory + settings.GoalWeight = goalWeight + + fmt.Printf("Goal weight set to %.2f %s\n", goalWeight, settings.WeightUnit) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/modify.go b/cmd/modify.go new file mode 100644 index 0000000..4e5056c --- /dev/null +++ b/cmd/modify.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/calculator" + "github.com/tryonlinux/thicc/internal/models" + "github.com/tryonlinux/thicc/internal/validation" +) + +var modifyCmd = &cobra.Command{ + Use: "modify ", + Short: "Modify a weight entry", + Long: `Modify a weight entry by its ID (shown in the show command).`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + settings := GetSettings() + + // Parse weight ID + id, err := strconv.Atoi(strings.TrimSpace(args[0])) + if err != nil || id <= 0 { + fmt.Println("Error: Weight ID must be a positive number") + return + } + + // Parse and validate weight + weight, err := validation.ParseAndValidateWeight(args[1]) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + // Calculate new BMI + bmi := calculator.CalculateBMI(weight, settings.Height, settings.WeightUnit, settings.HeightUnit) + + // Update in database + err = models.ModifyWeight(db, id, weight, bmi) + if err != nil { + fmt.Printf("Error modifying weight: %v\n", err) + return + } + + fmt.Printf("Updated weight entry %d to %.2f %s (BMI: %.1f)\n", id, weight, settings.WeightUnit, bmi) + + // Show updated table + showCmd.Run(cmd, []string{}) + }, +} diff --git a/cmd/reset.go b/cmd/reset.go new file mode 100644 index 0000000..3d2022c --- /dev/null +++ b/cmd/reset.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/models" +) + +var resetCmd = &cobra.Command{ + Use: "reset", + Short: "Clear all data and start over", + Long: `Deletes all weight entries and settings. You will be prompted to reconfigure on next launch. This action cannot be undone.`, + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + + // Get confirmation from user + fmt.Println("WARNING: This will delete ALL weight entries and settings.") + fmt.Print("Are you sure you want to continue? (yes/no): ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "yes" && response != "y" { + fmt.Println("Reset cancelled.") + return + } + + // Delete all weights + _, err = db.Exec("DELETE FROM weights") + if err != nil { + fmt.Printf("Error deleting weights: %v\n", err) + return + } + + // Reset settings + err = models.ResetSettings(db) + if err != nil { + fmt.Printf("Error resetting settings: %v\n", err) + return + } + + fmt.Println("\nAll weight entries and settings have been deleted.") + fmt.Println("You will be prompted to reconfigure on next launch.") + }, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..3611b47 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/config" + "github.com/tryonlinux/thicc/internal/database" + "github.com/tryonlinux/thicc/internal/models" +) + +var db *database.DB +var settings *models.Settings + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "thicc", + Short: "THICC - Weight tracking CLI", + Long: `THICC helps you track your weight and visualize your progress over time.`, + Run: func(cmd *cobra.Command, args []string) { + // Default behavior: run show command + if settings != nil { + showCmd.Run(cmd, args) + } + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + // Clean up database connection on exit + cleanupDatabase() + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initDatabase) + + // Add all subcommands + rootCmd.AddCommand(showCmd) + rootCmd.AddCommand(addCmd) + rootCmd.AddCommand(deleteCmd) + rootCmd.AddCommand(modifyCmd) + rootCmd.AddCommand(goalCmd) + rootCmd.AddCommand(resetCmd) +} + +// initDatabase initializes the database connection +func initDatabase() { + dbPath, err := config.GetDatabasePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting database path: %v\n", err) + os.Exit(1) + } + + db, err = database.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err) + os.Exit(1) + } + + // Check if settings exist (first launch detection) + settings, err = models.GetSettings(db) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting settings: %v\n", err) + os.Exit(1) + } + + // First launch - prompt for setup + if settings == nil { + settings, err = models.SetupSettings(db) + if err != nil { + fmt.Fprintf(os.Stderr, "Error setting up: %v\n", err) + os.Exit(1) + } + } +} + +// GetDB returns the database connection (used by commands) +func GetDB() *database.DB { + return db +} + +// GetSettings returns the current settings (used by commands) +func GetSettings() *models.Settings { + return settings +} + +// cleanupDatabase closes the database connection +func cleanupDatabase() { + if db != nil { + if err := db.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Error closing database: %v\n", err) + } + } +} diff --git a/cmd/show.go b/cmd/show.go new file mode 100644 index 0000000..95d93e9 --- /dev/null +++ b/cmd/show.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/tryonlinux/thicc/internal/display" + "github.com/tryonlinux/thicc/internal/models" + "github.com/tryonlinux/thicc/internal/validation" +) + +var showCmd = &cobra.Command{ + Use: "show [number|date]", + Short: "Display weight table and graph", + Long: `Shows weight entries with a table and line graph. + +Examples: + thicc show # Show last 20 entries + thicc show 50 # Show last 50 entries + thicc show 2024-01-01 # Show entries from 2024-01-01 to today (table shows last 20)`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + db := GetDB() + settings := GetSettings() + + var weights []models.Weight + var err error + limit := display.DefaultDisplayLimit + + if len(args) == 0 { + // Default: show last entries + weights, err = models.GetWeights(db, display.DefaultDisplayLimit) + } else { + arg := strings.TrimSpace(args[0]) + + // Check if it's a number (limit) or date + if num, err := strconv.Atoi(arg); err == nil { + // It's a number + limit = num + if limit <= 0 { + fmt.Println("Error: Number must be positive") + return + } + weights, err = models.GetWeights(db, limit) + if err != nil { + fmt.Printf("Error retrieving weights: %v\n", err) + return + } + } else { + // Try to parse as a date + if err := validation.ValidateDate(arg); err == nil { + // It's a valid date + startDate := arg + endDate := models.GetTodayDate() + weights, err = models.GetWeightsBetweenDates(db, startDate, endDate) + if err != nil { + fmt.Printf("Error retrieving weights: %v\n", err) + return + } + // For graph, use all weights; for table display, it will be truncated in render + } else { + fmt.Println("Error: Argument must be a positive number or a date in YYYY-MM-DD format") + return + } + } + } + + if err != nil { + fmt.Printf("Error retrieving weights: %v\n", err) + return + } + + // Render table and graph + output := display.RenderWeightsTable(weights, settings, limit) + fmt.Println(output) + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0b3f1ad --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/tryonlinux/thicc + +go 1.25.5 + +require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.41.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..173ff1f --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck= +modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/calculator/bmi.go b/internal/calculator/bmi.go new file mode 100644 index 0000000..4de83c5 --- /dev/null +++ b/internal/calculator/bmi.go @@ -0,0 +1,26 @@ +package calculator + +// CalculateBMI calculates BMI based on weight, height, and units +func CalculateBMI(weight, height float64, weightUnit, heightUnit string) float64 { + var bmi float64 + + if weightUnit == "kg" && heightUnit == "cm" { + // BMI = kg / (m^2) + heightInMeters := height / 100.0 + bmi = weight / (heightInMeters * heightInMeters) + } else if weightUnit == "lbs" && heightUnit == "in" { + // BMI = (lbs / in^2) * 703 + bmi = (weight / (height * height)) * 703 + } else if weightUnit == "kg" && heightUnit == "in" { + // Convert inches to meters + heightInMeters := height * 0.0254 + bmi = weight / (heightInMeters * heightInMeters) + } else { // lbs and cm + // Convert lbs to kg and cm to meters + weightInKg := weight * 0.453592 + heightInMeters := height / 100.0 + bmi = weightInKg / (heightInMeters * heightInMeters) + } + + return bmi +} diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..5ab9f23 --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,24 @@ +package config + +import ( + "os" + "path/filepath" +) + +// GetDatabasePath returns the path to the SQLite database file. +// Creates the ~/.thicc directory if it doesn't exist. +func GetDatabasePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + thiccDir := filepath.Join(homeDir, ".thicc") + + // Create .thicc directory if it doesn't exist (0700 = owner only for security) + if err := os.MkdirAll(thiccDir, 0700); err != nil { + return "", err + } + + return filepath.Join(thiccDir, "weights.db"), nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..00bd268 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,35 @@ +package database + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +// DB wraps the sql.DB connection +type DB struct { + *sql.DB +} + +// Open opens a connection to the SQLite database and initializes the schema +func Open(dbPath string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + db := &DB{sqlDB} + + // Initialize schema + if err := InitializeSchema(db); err != nil { + sqlDB.Close() + return nil, err + } + + return db, nil +} + +// Close closes the database connection +func (db *DB) Close() error { + return db.DB.Close() +} diff --git a/internal/database/schema.go b/internal/database/schema.go new file mode 100644 index 0000000..bcb5fc8 --- /dev/null +++ b/internal/database/schema.go @@ -0,0 +1,28 @@ +package database + +const schema = ` +CREATE TABLE IF NOT EXISTS weights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + weight REAL NOT NULL, + bmi REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_weights_date ON weights(date DESC); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + +// InitializeSchema creates all tables +func InitializeSchema(db *DB) error { + if _, err := db.Exec(schema); err != nil { + return err + } + + return nil +} diff --git a/internal/display/constants.go b/internal/display/constants.go new file mode 100644 index 0000000..2c65971 --- /dev/null +++ b/internal/display/constants.go @@ -0,0 +1,21 @@ +package display + +const ( + // DefaultDisplayLimit is the default number of entries to show in the table + DefaultDisplayLimit = 20 + + // TableMaxRows is the maximum number of rows to display in the table + TableMaxRows = 20 + + // GraphWidth is the width of the ASCII graph in characters + GraphWidth = 40 + + // GraphHeight is the height of the ASCII graph in characters + GraphHeight = 20 + + // GoalHeaderWidth is the width for centering the goal header + GoalHeaderWidth = 80 + + // GoalLabelMinWidth is the minimum width for goal label padding + GoalLabelMinWidth = 8 +) diff --git a/internal/display/formatter.go b/internal/display/formatter.go new file mode 100644 index 0000000..b459235 --- /dev/null +++ b/internal/display/formatter.go @@ -0,0 +1,18 @@ +package display + +import "fmt" + +// FormatWeight formats a weight value with proper precision and unit +func FormatWeight(weight float64, unit string) string { + return fmt.Sprintf("%.2f %s", weight, unit) +} + +// FormatBMI formats a BMI value with proper precision +func FormatBMI(bmi float64) string { + return fmt.Sprintf("%.1f", bmi) +} + +// FormatDate returns the date as-is (already in YYYY-MM-DD format) +func FormatDate(date string) string { + return date +} diff --git a/internal/display/styles.go b/internal/display/styles.go new file mode 100644 index 0000000..8135913 --- /dev/null +++ b/internal/display/styles.go @@ -0,0 +1,25 @@ +package display + +import "github.com/charmbracelet/lipgloss" + +var ( + // HeaderStyle for main headers + HeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("13")). + MarginBottom(1) + + // InfoStyle for informational text + InfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(false) + + // TitleStyle for ASCII art + TitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true) + + // TableBorderStyle for table borders + TableBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) +) diff --git a/internal/display/table.go b/internal/display/table.go new file mode 100644 index 0000000..6a80f42 --- /dev/null +++ b/internal/display/table.go @@ -0,0 +1,371 @@ +package display + +import ( + "fmt" + "math" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/tryonlinux/thicc/internal/models" +) + +const asciiArt = ` + โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— + โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• + โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ + โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ + โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— + โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• + Weight Tracker +` + +// RenderWeightsTable creates a formatted table of weights with a line graph +func RenderWeightsTable(weights []models.Weight, settings *models.Settings, limit int) string { + if len(weights) == 0 { + return TitleStyle.Render(asciiArt) + "\n\nNo weights tracked. Add one with: thicc add [date]" + } + + // Start with ASCII art + var output strings.Builder + output.WriteString(TitleStyle.Render(asciiArt)) + output.WriteString("\n") + + // Calculate stats + var totalWeight, minWeight, maxWeight float64 + minWeight = math.MaxFloat64 + maxWeight = -math.MaxFloat64 + + for _, w := range weights { + totalWeight += w.Weight + if w.Weight < minWeight { + minWeight = w.Weight + } + if w.Weight > maxWeight { + maxWeight = w.Weight + } + } + + avgWeight := totalWeight / float64(len(weights)) + latestWeight := weights[0].Weight + latestBMI := weights[0].BMI + + // Build stats header (goes with ASCII art header) + var header strings.Builder + header.WriteString(HeaderStyle.Render(fmt.Sprintf("Latest: %s | BMI: %s | Avg: %s", + FormatWeight(latestWeight, settings.WeightUnit), + FormatBMI(latestBMI), + FormatWeight(avgWeight, settings.WeightUnit)))) + header.WriteString("\n") + header.WriteString(InfoStyle.Render(fmt.Sprintf("Min: %s | Max: %s | Entries: %d", + FormatWeight(minWeight, settings.WeightUnit), + FormatWeight(maxWeight, settings.WeightUnit), + len(weights)))) + header.WriteString("\n\n\n") + + // Calculate goal difference + goalDiff := latestWeight - settings.GoalWeight + var goalDiffStr string + if goalDiff > 0 { + // Current weight is above goal - need to lose + goalDiffStr = fmt.Sprintf("%.1f %s to lose", goalDiff, settings.WeightUnit) + } else if goalDiff < 0 { + // Current weight is below goal - need to gain + goalDiffStr = fmt.Sprintf("%.1f %s to gain", math.Abs(goalDiff), settings.WeightUnit) + } else { + goalDiffStr = "at goal!" + } + + // Build goal weight section (goes with table/graph below) + goalHeader := fmt.Sprintf("Goal Weight: %s (%s)", + FormatWeight(settings.GoalWeight, settings.WeightUnit), + goalDiffStr) + centeredGoalStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("11")). + Align(lipgloss.Center). + Width(GoalHeaderWidth) + header.WriteString(centeredGoalStyle.Render(goalHeader)) + header.WriteString("\n\n") + + // Truncate to TableMaxRows for table display + displayWeights := weights + if len(weights) > TableMaxRows { + displayWeights = weights[:TableMaxRows] + } + + // Create table and graph side by side + weightTable := createWeightTable(displayWeights, settings) + weightGraph := createLineGraph(weights, settings) + + // Combine table and graph + combined := lipgloss.JoinHorizontal(lipgloss.Top, weightTable, " ", weightGraph) + + return output.String() + header.String() + combined +} + +// createWeightTable creates the weight table +func createWeightTable(weights []models.Weight, settings *models.Settings) string { + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(TableBorderStyle). + Headers("ID", "Date", "Weight", "BMI") + + for _, w := range weights { + t.Row( + fmt.Sprintf("%d", w.ID), + FormatDate(w.Date), + FormatWeight(w.Weight, settings.WeightUnit), + FormatBMI(w.BMI), + ) + } + + return t.Render() +} + +// weightRange holds the min and max weight values for graph scaling +type weightRange struct { + min float64 + max float64 +} + +// calculateWeightRange determines the min and max weights including goal weight and padding +func calculateWeightRange(weights []models.Weight, goalWeight float64) weightRange { + minWeight := math.MaxFloat64 + maxWeight := -math.MaxFloat64 + + for _, w := range weights { + if w.Weight < minWeight { + minWeight = w.Weight + } + if w.Weight > maxWeight { + maxWeight = w.Weight + } + } + + // Include goal weight in range calculation + if goalWeight < minWeight { + minWeight = goalWeight + } + if goalWeight > maxWeight { + maxWeight = goalWeight + } + + // Add some padding to the range + padding := (maxWeight - minWeight) * 0.1 + if padding == 0 { + padding = 1 + } + minWeight -= padding + maxWeight += padding + + return weightRange{min: minWeight, max: maxWeight} +} + +// createGraphGrid initializes an empty graph grid +func createGraphGrid(width, height int) [][]rune { + graph := make([][]rune, height) + for i := range graph { + graph[i] = make([]rune, width) + for j := range graph[i] { + graph[i][j] = ' ' + } + } + return graph +} + +// reverseWeights returns a reversed copy of the weights slice (oldest to newest) +func reverseWeights(weights []models.Weight) []models.Weight { + reversed := make([]models.Weight, len(weights)) + copy(reversed, weights) + for i := 0; i < len(reversed)/2; i++ { + reversed[i], reversed[len(reversed)-1-i] = reversed[len(reversed)-1-i], reversed[i] + } + return reversed +} + +// normalizeToGraphY converts a weight value to a Y coordinate on the graph +func normalizeToGraphY(weight float64, wr weightRange, height int) int { + normalized := (weight - wr.min) / (wr.max - wr.min) + y := height - 1 - int(normalized*float64(height-1)) + + // Ensure y is within bounds + if y < 0 { + y = 0 + } + if y >= height { + y = height - 1 + } + return y +} + +// plotDataPoints plots weight data points and connects them with lines +func plotDataPoints(graph [][]rune, weights []models.Weight, wr weightRange, width, height int) { + // Sample weights if we have more than width + step := 1 + if len(weights) > width { + step = len(weights) / width + } + + prevX, prevY := -1, -1 + for i := 0; i < len(weights); i += step { + w := weights[i] + x := (i / step) % width + y := normalizeToGraphY(w.Weight, wr, height) + + // Draw line from previous point + if prevX >= 0 { + drawLine(graph, prevX, prevY, x, y) + } + + // Mark the point with smallest dot + if x < width && y < height && y >= 0 { + graph[y][x] = 'ยท' + } + + prevX, prevY = x, y + } +} + +// drawGoalLine draws a horizontal line representing the goal weight +func drawGoalLine(graph [][]rune, goalWeight float64, wr weightRange, width, height int) int { + goalY := normalizeToGraphY(goalWeight, wr, height) + if goalY >= 0 && goalY < height { + for x := 0; x < width; x++ { + // Don't overwrite weight data points + if graph[goalY][x] != 'ยท' { + graph[goalY][x] = 'โ”€' + } + } + } + return goalY +} + +// renderGraphWithLabels renders the graph grid with axis labels and styling +func renderGraphWithLabels(graph [][]rune, weights []models.Weight, settings *models.Settings, wr weightRange, goalY int) string { + width := len(graph[0]) + height := len(graph) + + var graphOutput strings.Builder + graphStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(0, 1) + + var graphLines strings.Builder + + // Add max weight label + graphLines.WriteString(fmt.Sprintf("%.1f %s โ”ค\n", wr.max, settings.WeightUnit)) + + // Add graph lines with goal weight label + for i := 0; i < height; i++ { + if i == goalY { + // Add goal weight label on the goal line + goalLabel := fmt.Sprintf("Goal: %.1f", settings.GoalWeight) + graphLines.WriteString(goalLabel) + if len(goalLabel) < GoalLabelMinWidth { + graphLines.WriteString(strings.Repeat(" ", GoalLabelMinWidth-len(goalLabel))) + } + graphLines.WriteString("โ”ค") + } else if i == 0 || i == height-1 { + graphLines.WriteString(" โ”‚") + } else { + graphLines.WriteString(" โ”‚") + } + graphLines.WriteString(string(graph[i])) + graphLines.WriteString("\n") + } + + // Add min weight label and x-axis + graphLines.WriteString(fmt.Sprintf("%.1f %s โ”ค", wr.min, settings.WeightUnit)) + graphLines.WriteString(strings.Repeat("โ”€", width)) + graphLines.WriteString("\n") + + // Add x-axis labels (date range) + if len(weights) > 0 { + oldestDate := weights[0].Date + newestDate := weights[len(weights)-1].Date + graphLines.WriteString(fmt.Sprintf(" %s%s%s\n", + oldestDate, + strings.Repeat(" ", width-len(oldestDate)-len(newestDate)), + newestDate)) + } + + graphOutput.WriteString(graphStyle.Render(graphLines.String())) + return graphOutput.String() +} + +// createLineGraph creates a simple ASCII line graph +func createLineGraph(weights []models.Weight, settings *models.Settings) string { + if len(weights) == 0 { + return "" + } + + // Graph dimensions + width := GraphWidth + height := GraphHeight + + // Calculate weight range for scaling + wr := calculateWeightRange(weights, settings.GoalWeight) + + // Create empty graph grid + graph := createGraphGrid(width, height) + + // Reverse weights to show oldest to newest (left to right) + reversedWeights := reverseWeights(weights) + + // Plot weight data points and connect them + plotDataPoints(graph, reversedWeights, wr, width, height) + + // Draw horizontal goal weight line + goalY := drawGoalLine(graph, settings.GoalWeight, wr, width, height) + + // Render graph with labels and styling + return renderGraphWithLabels(graph, reversedWeights, settings, wr, goalY) +} + +// drawLine draws a line between two points using Bresenham's algorithm +func drawLine(graph [][]rune, x0, y0, x1, y1 int) { + dx := abs(x1 - x0) + dy := abs(y1 - y0) + sx := -1 + if x0 < x1 { + sx = 1 + } + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx - dy + + for { + // Draw very light connecting line (don't overwrite data points) + if (x0 != x1 || y0 != y1) && x0 >= 0 && x0 < len(graph[0]) && y0 >= 0 && y0 < len(graph) { + if graph[y0][x0] != 'ยท' { + graph[y0][x0] = 'โˆ™' + } + } + + if x0 == x1 && y0 == y1 { + break + } + + e2 := 2 * err + if e2 > -dy { + err -= dy + x0 += sx + } + if e2 < dx { + err += dx + y0 += sy + } + } +} + +// abs returns the absolute value of an integer +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/internal/models/settings.go b/internal/models/settings.go new file mode 100644 index 0000000..3020c32 --- /dev/null +++ b/internal/models/settings.go @@ -0,0 +1,171 @@ +package models + +import ( + "bufio" + "database/sql" + "fmt" + "os" + "strconv" + "strings" + + "github.com/tryonlinux/thicc/internal/database" +) + +// Settings represents application settings +type Settings struct { + WeightUnit string // "lbs" or "kg" + HeightUnit string // "in" or "cm" + Height float64 // height in the specified unit + GoalWeight float64 // goal weight in the specified unit +} + +// GetSettings retrieves current application settings +func GetSettings(db *database.DB) (*Settings, error) { + var weightUnit, heightUnit, heightStr, goalWeightStr string + + err := db.QueryRow("SELECT value FROM settings WHERE key = 'weight_unit'").Scan(&weightUnit) + if err == sql.ErrNoRows { + // First launch - need to setup + return nil, nil + } else if err != nil { + return nil, err + } + + err = db.QueryRow("SELECT value FROM settings WHERE key = 'height_unit'").Scan(&heightUnit) + if err != nil { + return nil, err + } + + err = db.QueryRow("SELECT value FROM settings WHERE key = 'height'").Scan(&heightStr) + if err != nil { + return nil, err + } + + height, err := strconv.ParseFloat(heightStr, 64) + if err != nil { + return nil, err + } + + err = db.QueryRow("SELECT value FROM settings WHERE key = 'goal_weight'").Scan(&goalWeightStr) + if err != nil { + return nil, err + } + + goalWeight, err := strconv.ParseFloat(goalWeightStr, 64) + if err != nil { + return nil, err + } + + return &Settings{ + WeightUnit: weightUnit, + HeightUnit: heightUnit, + Height: height, + GoalWeight: goalWeight, + }, nil +} + +// SetupSettings prompts the user for initial settings +func SetupSettings(db *database.DB) (*Settings, error) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("\n=== First Time Setup ===") + fmt.Println("Please configure your preferences.\n") + + // Get weight unit + var weightUnit string + for { + fmt.Print("Weight unit (lbs/kg): ") + input, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + weightUnit = strings.TrimSpace(strings.ToLower(input)) + if weightUnit == "lbs" || weightUnit == "kg" { + break + } + fmt.Println("Invalid input. Please enter 'lbs' or 'kg'.") + } + + // Get height unit + var heightUnit string + for { + fmt.Print("Height unit (in/cm): ") + input, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + heightUnit = strings.TrimSpace(strings.ToLower(input)) + if heightUnit == "in" || heightUnit == "cm" { + break + } + fmt.Println("Invalid input. Please enter 'in' or 'cm'.") + } + + // Get height + var height float64 + for { + fmt.Printf("Your height (%s): ", heightUnit) + input, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + input = strings.TrimSpace(input) + height, err = strconv.ParseFloat(input, 64) + if err == nil && height > 0 { + break + } + fmt.Println("Invalid input. Please enter a positive number.") + } + + // Get goal weight + var goalWeight float64 + for { + fmt.Printf("Your goal weight (%s): ", weightUnit) + input, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + input = strings.TrimSpace(input) + goalWeight, err = strconv.ParseFloat(input, 64) + if err == nil && goalWeight > 0 { + break + } + fmt.Println("Invalid input. Please enter a positive number.") + } + + // Save to database + _, err := db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('weight_unit', ?)", weightUnit) + if err != nil { + return nil, err + } + + _, err = db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('height_unit', ?)", heightUnit) + if err != nil { + return nil, err + } + + _, err = db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('height', ?)", strconv.FormatFloat(height, 'f', 2, 64)) + if err != nil { + return nil, err + } + + _, err = db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('goal_weight', ?)", strconv.FormatFloat(goalWeight, 'f', 2, 64)) + if err != nil { + return nil, err + } + + fmt.Println("\nSettings saved successfully!\n") + + return &Settings{ + WeightUnit: weightUnit, + HeightUnit: heightUnit, + Height: height, + GoalWeight: goalWeight, + }, nil +} + +// ResetSettings clears all settings (used by reset command) +func ResetSettings(db *database.DB) error { + _, err := db.Exec("DELETE FROM settings") + return err +} diff --git a/internal/models/weight.go b/internal/models/weight.go new file mode 100644 index 0000000..a1b625f --- /dev/null +++ b/internal/models/weight.go @@ -0,0 +1,84 @@ +package models + +import ( + "time" + + "github.com/tryonlinux/thicc/internal/database" +) + +// Weight represents a weight entry +type Weight struct { + ID int + Date string + Weight float64 + BMI float64 +} + +// AddWeight adds a new weight entry +func AddWeight(db *database.DB, date string, weight float64, bmi float64) error { + _, err := db.Exec( + "INSERT INTO weights (date, weight, bmi) VALUES (?, ?, ?)", + date, weight, bmi, + ) + return err +} + +// DeleteWeight deletes a weight entry by ID +func DeleteWeight(db *database.DB, id int) error { + _, err := db.Exec("DELETE FROM weights WHERE id = ?", id) + return err +} + +// ModifyWeight updates a weight entry +func ModifyWeight(db *database.DB, id int, weight float64, bmi float64) error { + _, err := db.Exec("UPDATE weights SET weight = ?, bmi = ? WHERE id = ?", weight, bmi, id) + return err +} + +// GetWeights retrieves the last N weight entries +func GetWeights(db *database.DB, limit int) ([]Weight, error) { + query := "SELECT id, date, weight, bmi FROM weights ORDER BY date DESC, id DESC LIMIT ?" + rows, err := db.Query(query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var weights []Weight + for rows.Next() { + var w Weight + if err := rows.Scan(&w.ID, &w.Date, &w.Weight, &w.BMI); err != nil { + return nil, err + } + weights = append(weights, w) + } + + return weights, rows.Err() +} + +// GetWeightsBetweenDates retrieves weight entries between two dates +func GetWeightsBetweenDates(db *database.DB, startDate, endDate string) ([]Weight, error) { + query := "SELECT id, date, weight, bmi FROM weights WHERE date >= ? AND date <= ? ORDER BY date DESC, id DESC" + rows, err := db.Query(query, startDate, endDate) + if err != nil { + return nil, err + } + defer rows.Close() + + var weights []Weight + for rows.Next() { + var w Weight + if err := rows.Scan(&w.ID, &w.Date, &w.Weight, &w.BMI); err != nil { + return nil, err + } + weights = append(weights, w) + } + + return weights, rows.Err() +} + +// GetTodayDate returns today's date in YYYY-MM-DD format +// Note: "2006-01-02" is Go's reference time format (Jan 2, 2006 at 3:04:05 PM MST) +func GetTodayDate() string { + return time.Now().Format("2006-01-02") +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..4e94f86 --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,128 @@ +package validation + +import ( + "errors" + "strconv" + "strings" + "time" +) + +// Date format constants +const DateFormat = "2006-01-02" + +// Weight bounds (in any unit) +const ( + MinWeight = 1.0 + MaxWeight = 1000.0 +) + +// BMI bounds +const ( + MinBMI = 5.0 + MaxBMI = 100.0 +) + +// Height bounds +const ( + MinHeightCm = 50.0 + MaxHeightCm = 300.0 + MinHeightIn = 20.0 + MaxHeightIn = 120.0 +) + +// Common errors +var ( + ErrInvalidWeight = errors.New("weight must be between 1 and 1000") + ErrInvalidBMI = errors.New("BMI must be between 5 and 100") + ErrInvalidHeightCm = errors.New("height must be between 50 and 300 cm") + ErrInvalidHeightIn = errors.New("height must be between 20 and 120 inches") + ErrInvalidDate = errors.New("date must be in YYYY-MM-DD format and be a valid date") + ErrNegativeNumber = errors.New("value must be a positive number") + ErrInvalidDateFormat = errors.New("date format must be YYYY-MM-DD") +) + +// ValidateDate validates a date string is in YYYY-MM-DD format and is a valid date +func ValidateDate(dateStr string) error { + if dateStr == "" { + return ErrInvalidDateFormat + } + + // Parse the date using time.Parse to ensure it's a valid date + _, err := time.Parse(DateFormat, dateStr) + if err != nil { + return ErrInvalidDate + } + + return nil +} + +// ValidateWeight validates a weight value is within reasonable bounds +func ValidateWeight(weight float64) error { + if weight <= 0 { + return ErrNegativeNumber + } + if weight < MinWeight || weight > MaxWeight { + return ErrInvalidWeight + } + return nil +} + +// ValidateBMI validates a BMI value is within reasonable bounds +func ValidateBMI(bmi float64) error { + if bmi < MinBMI || bmi > MaxBMI { + return ErrInvalidBMI + } + return nil +} + +// ValidateHeight validates height based on unit +func ValidateHeight(height float64, unit string) error { + if height <= 0 { + return ErrNegativeNumber + } + + if unit == "cm" { + if height < MinHeightCm || height > MaxHeightCm { + return ErrInvalidHeightCm + } + } else if unit == "in" { + if height < MinHeightIn || height > MaxHeightIn { + return ErrInvalidHeightIn + } + } + + return nil +} + +// ParsePositiveFloat parses a string to float64, trims whitespace, and validates it's positive +func ParsePositiveFloat(s string) (float64, error) { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return 0, ErrNegativeNumber + } + + value, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, errors.New("invalid number format") + } + + if value <= 0 { + return 0, ErrNegativeNumber + } + + return value, nil +} + +// ParseAndValidateWeight parses and validates a weight value in one step +func ParseAndValidateWeight(s string) (float64, error) { + weight, err := ParsePositiveFloat(s) + if err != nil { + return 0, err + } + + if err := ValidateWeight(weight); err != nil { + return 0, err + } + + return weight, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b4b84eb --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/tryonlinux/thicc/cmd" + +func main() { + cmd.Execute() +} diff --git a/setup-demo.bat b/setup-demo.bat new file mode 100644 index 0000000..3d5b38e --- /dev/null +++ b/setup-demo.bat @@ -0,0 +1,55 @@ +@echo off +REM Demo data setup script for THICC + +echo Resetting database... +echo y | thicc.exe reset + +echo. +echo Configuring settings... +(echo lbs & echo in & echo 70 & echo 145) | thicc.exe add 160 2024-01-01 + +echo. +echo Adding demo weight entries... +REM January - starting weight +thicc.exe add 160.5 2024-01-01 +thicc.exe add 159.8 2024-01-05 +thicc.exe add 159.2 2024-01-10 +thicc.exe add 158.5 2024-01-15 +thicc.exe add 158.0 2024-01-20 +thicc.exe add 157.3 2024-01-25 + +REM February - steady progress +thicc.exe add 156.8 2024-02-01 +thicc.exe add 156.1 2024-02-05 +thicc.exe add 155.5 2024-02-10 +thicc.exe add 154.9 2024-02-15 +thicc.exe add 154.2 2024-02-20 +thicc.exe add 153.7 2024-02-25 + +REM March - plateau +thicc.exe add 153.4 2024-03-01 +thicc.exe add 153.2 2024-03-05 +thicc.exe add 153.6 2024-03-10 +thicc.exe add 153.0 2024-03-15 +thicc.exe add 153.4 2024-03-20 +thicc.exe add 152.8 2024-03-25 + +REM April - breakthrough +thicc.exe add 152.3 2024-04-01 +thicc.exe add 151.6 2024-04-05 +thicc.exe add 151.0 2024-04-10 +thicc.exe add 150.4 2024-04-15 +thicc.exe add 149.8 2024-04-20 +thicc.exe add 149.2 2024-04-25 + +REM May - goal reached +thicc.exe add 148.7 2024-05-01 +thicc.exe add 148.2 2024-05-05 +thicc.exe add 147.5 2024-05-10 + +echo. +echo Demo data setup complete! +echo. +echo Running thicc to show the table... +echo. +thicc.exe diff --git a/setup-demo.sh b/setup-demo.sh new file mode 100644 index 0000000..1f220df --- /dev/null +++ b/setup-demo.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Demo data setup script for THICC + +echo "Resetting database..." +echo "yes" | ./thicc reset + +echo "" +echo "Configuring settings..." +echo -e "lbs\nin\n70\n145" | ./thicc add 160 2024-01-01 + +echo "" +echo "Adding demo weight entries..." +# January - starting weight +./thicc add 160.5 2024-01-01 +./thicc add 159.8 2024-01-05 +./thicc add 159.2 2024-01-10 +./thicc add 158.5 2024-01-15 +./thicc add 158.0 2024-01-20 +./thicc add 157.3 2024-01-25 + +# February - steady progress +./thicc add 156.8 2024-02-01 +./thicc add 156.1 2024-02-05 +./thicc add 155.5 2024-02-10 +./thicc add 154.9 2024-02-15 +./thicc add 154.2 2024-02-20 +./thicc add 153.7 2024-02-25 + +# March - plateau +./thicc add 153.4 2024-03-01 +./thicc add 153.2 2024-03-05 +./thicc add 153.6 2024-03-10 +./thicc add 153.0 2024-03-15 +./thicc add 153.4 2024-03-20 +./thicc add 152.8 2024-03-25 + +# April - breakthrough +./thicc add 152.3 2024-04-01 +./thicc add 151.6 2024-04-05 +./thicc add 151.0 2024-04-10 +./thicc add 150.4 2024-04-15 +./thicc add 149.8 2024-04-20 +./thicc add 149.2 2024-04-25 + +# May - goal reached +./thicc add 148.7 2024-05-01 +./thicc add 148.2 2024-05-05 +./thicc add 147.5 2024-05-10 + +echo "" +echo "Demo data setup complete!" +echo "" +echo "Running thicc to show the table..." +echo "" +./thicc diff --git a/tests/calculator_test.go b/tests/calculator_test.go new file mode 100644 index 0000000..047a3f6 --- /dev/null +++ b/tests/calculator_test.go @@ -0,0 +1,89 @@ +package tests + +import ( + "math" + "testing" + + "github.com/tryonlinux/thicc/internal/calculator" +) + +func TestCalculateBMI_MetricUnits(t *testing.T) { + // Test BMI calculation with kg and cm + weight := 70.0 // kg + height := 175.0 // cm + bmi := calculator.CalculateBMI(weight, height, "kg", "cm") + + // BMI = 70 / (1.75^2) = 22.86 + expected := 22.86 + if math.Abs(bmi-expected) > 0.01 { + t.Errorf("Expected BMI %.2f, got %.2f", expected, bmi) + } +} + +func TestCalculateBMI_ImperialUnits(t *testing.T) { + // Test BMI calculation with lbs and in + weight := 154.0 // lbs (approximately 70 kg) + height := 69.0 // inches (approximately 175 cm) + bmi := calculator.CalculateBMI(weight, height, "lbs", "in") + + // BMI = (154 / 69^2) * 703 = 22.74 + expected := 22.74 + if math.Abs(bmi-expected) > 0.01 { + t.Errorf("Expected BMI %.2f, got %.2f", expected, bmi) + } +} + +func TestCalculateBMI_MixedUnits_KgInches(t *testing.T) { + // Test BMI calculation with kg and inches + weight := 70.0 // kg + height := 69.0 // inches + bmi := calculator.CalculateBMI(weight, height, "kg", "in") + + // Convert inches to meters: 69 * 0.0254 = 1.7526 m + // BMI = 70 / (1.7526^2) = 22.79 + expected := 22.79 + if math.Abs(bmi-expected) > 0.01 { + t.Errorf("Expected BMI %.2f, got %.2f", expected, bmi) + } +} + +func TestCalculateBMI_MixedUnits_LbsCm(t *testing.T) { + // Test BMI calculation with lbs and cm + weight := 154.0 // lbs + height := 175.0 // cm + bmi := calculator.CalculateBMI(weight, height, "lbs", "cm") + + // Convert lbs to kg: 154 * 0.453592 = 69.85 kg + // Convert cm to m: 175 / 100 = 1.75 m + // BMI = 69.85 / (1.75^2) = 22.81 + expected := 22.81 + if math.Abs(bmi-expected) > 0.01 { + t.Errorf("Expected BMI %.2f, got %.2f", expected, bmi) + } +} + +func TestCalculateBMI_Underweight(t *testing.T) { + // Test underweight BMI + weight := 50.0 // kg + height := 175.0 // cm + bmi := calculator.CalculateBMI(weight, height, "kg", "cm") + + // BMI = 50 / (1.75^2) = 16.33 (underweight) + expected := 16.33 + if math.Abs(bmi-expected) > 0.01 { + t.Errorf("Expected BMI %.2f, got %.2f", expected, bmi) + } +} + +func TestCalculateBMI_Overweight(t *testing.T) { + // Test overweight BMI + weight := 100.0 // kg + height := 175.0 // cm + bmi := calculator.CalculateBMI(weight, height, "kg", "cm") + + // BMI = 100 / (1.75^2) = 32.65 (overweight) + expected := 32.65 + if math.Abs(bmi-expected) > 0.01 { + t.Errorf("Expected BMI %.2f, got %.2f", expected, bmi) + } +} diff --git a/tests/display_test.go b/tests/display_test.go new file mode 100644 index 0000000..783b04f --- /dev/null +++ b/tests/display_test.go @@ -0,0 +1,62 @@ +package tests + +import ( + "testing" + + "github.com/tryonlinux/thicc/internal/display" +) + +func TestFormatWeight(t *testing.T) { + tests := []struct { + weight float64 + unit string + expected string + }{ + {70.5, "kg", "70.50 kg"}, + {154.32, "lbs", "154.32 lbs"}, + {100.0, "kg", "100.00 kg"}, + } + + for _, tt := range tests { + result := display.FormatWeight(tt.weight, tt.unit) + if result != tt.expected { + t.Errorf("FormatWeight(%.2f, %s) = %s, want %s", tt.weight, tt.unit, result, tt.expected) + } + } +} + +func TestFormatBMI(t *testing.T) { + tests := []struct { + bmi float64 + expected string + }{ + {22.86, "22.9"}, + {18.5, "18.5"}, + {30.12, "30.1"}, + {25.0, "25.0"}, + } + + for _, tt := range tests { + result := display.FormatBMI(tt.bmi) + if result != tt.expected { + t.Errorf("FormatBMI(%.2f) = %s, want %s", tt.bmi, result, tt.expected) + } + } +} + +func TestFormatDate(t *testing.T) { + tests := []struct { + date string + expected string + }{ + {"2024-01-01", "2024-01-01"}, + {"2023-12-25", "2023-12-25"}, + } + + for _, tt := range tests { + result := display.FormatDate(tt.date) + if result != tt.expected { + t.Errorf("FormatDate(%s) = %s, want %s", tt.date, result, tt.expected) + } + } +} diff --git a/tests/goal_test.go b/tests/goal_test.go new file mode 100644 index 0000000..5d7baf9 --- /dev/null +++ b/tests/goal_test.go @@ -0,0 +1,170 @@ +package tests + +import ( + "testing" + + "github.com/tryonlinux/thicc/internal/models" +) + +func TestGoalDifferenceCalculation(t *testing.T) { + testCases := []struct { + name string + currentWeight float64 + goalWeight float64 + expectedDiff float64 + shouldLose bool + shouldGain bool + atGoal bool + }{ + { + name: "Need to lose weight", + currentWeight: 160.0, + goalWeight: 150.0, + expectedDiff: 10.0, + shouldLose: true, + }, + { + name: "Need to gain weight", + currentWeight: 140.0, + goalWeight: 150.0, + expectedDiff: -10.0, + shouldGain: true, + }, + { + name: "At goal weight", + currentWeight: 150.0, + goalWeight: 150.0, + expectedDiff: 0.0, + atGoal: true, + }, + { + name: "Small amount to lose", + currentWeight: 151.5, + goalWeight: 150.0, + expectedDiff: 1.5, + shouldLose: true, + }, + { + name: "Large amount to gain", + currentWeight: 120.0, + goalWeight: 160.0, + expectedDiff: -40.0, + shouldGain: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + diff := tc.currentWeight - tc.goalWeight + + if diff != tc.expectedDiff { + t.Errorf("Expected difference %.2f, got %.2f", tc.expectedDiff, diff) + } + + if tc.shouldLose && diff <= 0 { + t.Errorf("Expected to need to lose weight, but diff is %.2f", diff) + } + + if tc.shouldGain && diff >= 0 { + t.Errorf("Expected to need to gain weight, but diff is %.2f", diff) + } + + if tc.atGoal && diff != 0 { + t.Errorf("Expected to be at goal, but diff is %.2f", diff) + } + }) + } +} + +func TestGoalWeightWithWeightEntries(t *testing.T) { + db := setupTestDB(t) + + // Set up settings with goal weight + db.Exec("INSERT INTO settings (key, value) VALUES ('weight_unit', 'lbs')") + db.Exec("INSERT INTO settings (key, value) VALUES ('height_unit', 'in')") + db.Exec("INSERT INTO settings (key, value) VALUES ('height', '70')") + db.Exec("INSERT INTO settings (key, value) VALUES ('goal_weight', '150')") + + settings, err := models.GetSettings(db) + if err != nil { + t.Fatalf("Failed to get settings: %v", err) + } + + // Add weight entries + models.AddWeight(db, "2024-01-01", 160.0, 23.0) + models.AddWeight(db, "2024-01-05", 158.0, 22.7) + models.AddWeight(db, "2024-01-10", 155.0, 22.3) + models.AddWeight(db, "2024-01-15", 152.0, 21.8) + models.AddWeight(db, "2024-01-20", 150.0, 21.5) // At goal + + // Get latest weight + weights, err := models.GetWeights(db, 1) + if err != nil { + t.Fatalf("Failed to get weights: %v", err) + } + + latestWeight := weights[0].Weight + + // Calculate difference + diff := latestWeight - settings.GoalWeight + + if diff != 0.0 { + t.Errorf("Expected to be at goal (diff = 0), got diff = %.2f", diff) + } + + // Add another entry above goal + models.AddWeight(db, "2024-01-25", 155.0, 22.3) + + weights, err = models.GetWeights(db, 1) + if err != nil { + t.Fatalf("Failed to get weights: %v", err) + } + + latestWeight = weights[0].Weight + diff = latestWeight - settings.GoalWeight + + if diff != 5.0 { + t.Errorf("Expected diff of 5.0 (need to lose), got %.2f", diff) + } + + if diff <= 0 { + t.Errorf("Should need to lose weight, but diff is %.2f", diff) + } +} + +func TestGoalWeightEdgeCases(t *testing.T) { + db := setupTestDB(t) + + testCases := []struct { + name string + goalWeight string + valid bool + }{ + {"Normal goal", "150.5", true}, + {"Zero goal", "0", true}, // Technically valid but not realistic + {"Large goal", "500.0", true}, + {"Small goal", "0.1", true}, + {"Decimal goal", "145.75", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Clear settings + db.Exec("DELETE FROM settings") + + // Insert test settings + db.Exec("INSERT INTO settings (key, value) VALUES ('weight_unit', 'lbs')") + db.Exec("INSERT INTO settings (key, value) VALUES ('height_unit', 'in')") + db.Exec("INSERT INTO settings (key, value) VALUES ('height', '70')") + db.Exec("INSERT INTO settings (key, value) VALUES ('goal_weight', ?)", tc.goalWeight) + + settings, err := models.GetSettings(db) + if tc.valid && err != nil { + t.Errorf("Expected valid goal weight, got error: %v", err) + } + if tc.valid && settings == nil { + t.Errorf("Expected settings to be returned") + } + }) + } +} diff --git a/tests/models_test.go b/tests/models_test.go new file mode 100644 index 0000000..6c32aad --- /dev/null +++ b/tests/models_test.go @@ -0,0 +1,292 @@ +package tests + +import ( + "os" + "testing" + + "github.com/tryonlinux/thicc/internal/database" + "github.com/tryonlinux/thicc/internal/models" +) + +// setupTestDB creates a temporary database for testing +func setupTestDB(t *testing.T) *database.DB { + // Create a temporary database file + tmpFile, err := os.CreateTemp("", "thicc_test_*.db") + if err != nil { + t.Fatalf("Failed to create temp database: %v", err) + } + tmpFile.Close() + + db, err := database.Open(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Clean up function + t.Cleanup(func() { + db.Close() + os.Remove(tmpFile.Name()) + }) + + return db +} + +func TestAddAndGetWeights(t *testing.T) { + db := setupTestDB(t) + + // Add some test weights + err := models.AddWeight(db, "2024-01-01", 70.0, 22.8) + if err != nil { + t.Fatalf("Failed to add weight: %v", err) + } + + err = models.AddWeight(db, "2024-01-02", 69.5, 22.6) + if err != nil { + t.Fatalf("Failed to add weight: %v", err) + } + + // Get weights + weights, err := models.GetWeights(db, 10) + if err != nil { + t.Fatalf("Failed to get weights: %v", err) + } + + if len(weights) != 2 { + t.Errorf("Expected 2 weights, got %d", len(weights)) + } + + // Check they're in descending order by date + if weights[0].Date != "2024-01-02" { + t.Errorf("Expected first weight to be from 2024-01-02, got %s", weights[0].Date) + } +} + +func TestDeleteWeight(t *testing.T) { + db := setupTestDB(t) + + // Add a weight + err := models.AddWeight(db, "2024-01-01", 70.0, 22.8) + if err != nil { + t.Fatalf("Failed to add weight: %v", err) + } + + // Get the weight ID + weights, err := models.GetWeights(db, 1) + if err != nil || len(weights) == 0 { + t.Fatalf("Failed to get weight") + } + + id := weights[0].ID + + // Delete the weight + err = models.DeleteWeight(db, id) + if err != nil { + t.Fatalf("Failed to delete weight: %v", err) + } + + // Verify it's gone + weights, err = models.GetWeights(db, 10) + if err != nil { + t.Fatalf("Failed to get weights: %v", err) + } + + if len(weights) != 0 { + t.Errorf("Expected 0 weights after deletion, got %d", len(weights)) + } +} + +func TestModifyWeight(t *testing.T) { + db := setupTestDB(t) + + // Add a weight + err := models.AddWeight(db, "2024-01-01", 70.0, 22.8) + if err != nil { + t.Fatalf("Failed to add weight: %v", err) + } + + // Get the weight ID + weights, err := models.GetWeights(db, 1) + if err != nil || len(weights) == 0 { + t.Fatalf("Failed to get weight") + } + + id := weights[0].ID + + // Modify the weight + newWeight := 65.0 + newBMI := 21.2 + err = models.ModifyWeight(db, id, newWeight, newBMI) + if err != nil { + t.Fatalf("Failed to modify weight: %v", err) + } + + // Verify the change + weights, err = models.GetWeights(db, 1) + if err != nil || len(weights) == 0 { + t.Fatalf("Failed to get weight after modification") + } + + if weights[0].Weight != newWeight { + t.Errorf("Expected weight %.2f, got %.2f", newWeight, weights[0].Weight) + } + + if weights[0].BMI != newBMI { + t.Errorf("Expected BMI %.2f, got %.2f", newBMI, weights[0].BMI) + } +} + +func TestGetWeightsBetweenDates(t *testing.T) { + db := setupTestDB(t) + + // Add weights on different dates + models.AddWeight(db, "2024-01-01", 70.0, 22.8) + models.AddWeight(db, "2024-01-05", 69.5, 22.6) + models.AddWeight(db, "2024-01-10", 69.0, 22.4) + models.AddWeight(db, "2024-01-15", 68.5, 22.2) + + // Get weights between Jan 5 and Jan 12 + weights, err := models.GetWeightsBetweenDates(db, "2024-01-05", "2024-01-12") + if err != nil { + t.Fatalf("Failed to get weights between dates: %v", err) + } + + if len(weights) != 2 { + t.Errorf("Expected 2 weights, got %d", len(weights)) + } + + // Verify the dates + if weights[0].Date != "2024-01-10" && weights[1].Date != "2024-01-10" { + t.Errorf("Expected to find weight from 2024-01-10") + } + + if weights[0].Date != "2024-01-05" && weights[1].Date != "2024-01-05" { + t.Errorf("Expected to find weight from 2024-01-05") + } +} + +func TestGoalWeightSetting(t *testing.T) { + db := setupTestDB(t) + + // Set up initial settings + _, err := db.Exec("INSERT INTO settings (key, value) VALUES ('weight_unit', 'lbs')") + if err != nil { + t.Fatalf("Failed to insert weight_unit: %v", err) + } + _, err = db.Exec("INSERT INTO settings (key, value) VALUES ('height_unit', 'in')") + if err != nil { + t.Fatalf("Failed to insert height_unit: %v", err) + } + _, err = db.Exec("INSERT INTO settings (key, value) VALUES ('height', '70')") + if err != nil { + t.Fatalf("Failed to insert height: %v", err) + } + _, err = db.Exec("INSERT INTO settings (key, value) VALUES ('goal_weight', '150')") + if err != nil { + t.Fatalf("Failed to insert goal_weight: %v", err) + } + + // Get settings + settings, err := models.GetSettings(db) + if err != nil { + t.Fatalf("Failed to get settings: %v", err) + } + + if settings.GoalWeight != 150.0 { + t.Errorf("Expected goal weight 150.0, got %.2f", settings.GoalWeight) + } + + if settings.WeightUnit != "lbs" { + t.Errorf("Expected weight unit 'lbs', got '%s'", settings.WeightUnit) + } + + if settings.HeightUnit != "in" { + t.Errorf("Expected height unit 'in', got '%s'", settings.HeightUnit) + } + + if settings.Height != 70.0 { + t.Errorf("Expected height 70.0, got %.2f", settings.Height) + } +} + +func TestUpdateGoalWeight(t *testing.T) { + db := setupTestDB(t) + + // Set up initial settings + _, err := db.Exec("INSERT INTO settings (key, value) VALUES ('weight_unit', 'kg')") + if err != nil { + t.Fatalf("Failed to insert weight_unit: %v", err) + } + _, err = db.Exec("INSERT INTO settings (key, value) VALUES ('height_unit', 'cm')") + if err != nil { + t.Fatalf("Failed to insert height_unit: %v", err) + } + _, err = db.Exec("INSERT INTO settings (key, value) VALUES ('height', '175')") + if err != nil { + t.Fatalf("Failed to insert height: %v", err) + } + _, err = db.Exec("INSERT INTO settings (key, value) VALUES ('goal_weight', '70')") + if err != nil { + t.Fatalf("Failed to insert goal_weight: %v", err) + } + + // Update goal weight + newGoal := 65.0 + _, err = db.Exec("UPDATE settings SET value = ? WHERE key = 'goal_weight'", "65") + if err != nil { + t.Fatalf("Failed to update goal weight: %v", err) + } + + // Get settings again + settings, err := models.GetSettings(db) + if err != nil { + t.Fatalf("Failed to get settings: %v", err) + } + + if settings.GoalWeight != newGoal { + t.Errorf("Expected updated goal weight %.2f, got %.2f", newGoal, settings.GoalWeight) + } +} + +func TestGoalWeightDifferentUnits(t *testing.T) { + db := setupTestDB(t) + + testCases := []struct { + name string + weightUnit string + heightUnit string + height string + goalWeight string + expected float64 + }{ + {"Imperial", "lbs", "in", "70", "150", 150.0}, + {"Metric", "kg", "cm", "175", "70", 70.0}, + {"Mixed LbsCm", "lbs", "cm", "178", "165", 165.0}, + {"Mixed KgIn", "kg", "in", "69", "68", 68.0}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Clear settings + db.Exec("DELETE FROM settings") + + // Insert test settings + db.Exec("INSERT INTO settings (key, value) VALUES ('weight_unit', ?)", tc.weightUnit) + db.Exec("INSERT INTO settings (key, value) VALUES ('height_unit', ?)", tc.heightUnit) + db.Exec("INSERT INTO settings (key, value) VALUES ('height', ?)", tc.height) + db.Exec("INSERT INTO settings (key, value) VALUES ('goal_weight', ?)", tc.goalWeight) + + settings, err := models.GetSettings(db) + if err != nil { + t.Fatalf("Failed to get settings: %v", err) + } + + if settings.GoalWeight != tc.expected { + t.Errorf("Expected goal weight %.2f, got %.2f", tc.expected, settings.GoalWeight) + } + + if settings.WeightUnit != tc.weightUnit { + t.Errorf("Expected weight unit '%s', got '%s'", tc.weightUnit, settings.WeightUnit) + } + }) + } +}