diff --git a/.github/thicc.png b/.github/thicc.png new file mode 100644 index 0000000..68dfd7f Binary files /dev/null and b/.github/thicc.png differ diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 0000000..814feff --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,67 @@ +name: PR Tests + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: 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 + id: test + run: | + go test -v ./tests/... 2>&1 | 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) + } + }) + } +}