|
| 1 | +package stats |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "math" |
| 6 | + "os" |
| 7 | + "sort" |
| 8 | + "strings" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/spf13/cobra" |
| 12 | + |
| 13 | + "github.com/obay/hevycli/internal/api" |
| 14 | + "github.com/obay/hevycli/internal/config" |
| 15 | + "github.com/obay/hevycli/internal/output" |
| 16 | +) |
| 17 | + |
| 18 | +var ( |
| 19 | + progressMetric string |
| 20 | + progressPeriod string |
| 21 | +) |
| 22 | + |
| 23 | +var progressCmd = &cobra.Command{ |
| 24 | + Use: "progress <exercise-name>", |
| 25 | + Short: "Track progress on a specific exercise", |
| 26 | + Long: `Analyze your progress on a specific exercise over time. |
| 27 | +
|
| 28 | +Metrics: |
| 29 | + weight - Max weight used (default) |
| 30 | + volume - Total volume (weight × reps) |
| 31 | + reps - Max reps at heaviest weight |
| 32 | + 1rm - Estimated one-rep max (Brzycki formula) |
| 33 | +
|
| 34 | +Examples: |
| 35 | + hevycli stats progress "Bench Press" |
| 36 | + hevycli stats progress "Squat" --metric 1rm |
| 37 | + hevycli stats progress "Deadlift" --metric volume --period year`, |
| 38 | + Args: cobra.ExactArgs(1), |
| 39 | + RunE: runProgress, |
| 40 | +} |
| 41 | + |
| 42 | +func init() { |
| 43 | + progressCmd.Flags().StringVar(&progressMetric, "metric", "weight", |
| 44 | + "metric to track: weight, volume, reps, 1rm") |
| 45 | + progressCmd.Flags().StringVar(&progressPeriod, "period", "all", |
| 46 | + "time period: week, month, year, all") |
| 47 | +} |
| 48 | + |
| 49 | +// ProgressData holds exercise progress data |
| 50 | +type ProgressData struct { |
| 51 | + Exercise string `json:"exercise"` |
| 52 | + Metric string `json:"metric"` |
| 53 | + Unit string `json:"unit"` |
| 54 | + DataPoints []ProgressPoint `json:"data_points"` |
| 55 | + Analysis ProgressAnalysis `json:"analysis"` |
| 56 | +} |
| 57 | + |
| 58 | +// ProgressPoint is a single data point |
| 59 | +type ProgressPoint struct { |
| 60 | + Date string `json:"date"` |
| 61 | + Value float64 `json:"value"` |
| 62 | +} |
| 63 | + |
| 64 | +// ProgressAnalysis contains trend analysis |
| 65 | +type ProgressAnalysis struct { |
| 66 | + StartingValue float64 `json:"starting_value"` |
| 67 | + CurrentValue float64 `json:"current_value"` |
| 68 | + AbsoluteChange float64 `json:"absolute_change"` |
| 69 | + PercentChange float64 `json:"percent_change"` |
| 70 | + Trend string `json:"trend"` |
| 71 | +} |
| 72 | + |
| 73 | +func runProgress(cmd *cobra.Command, args []string) error { |
| 74 | + exerciseName := args[0] |
| 75 | + |
| 76 | + cfg, err := config.Load("") |
| 77 | + if err != nil { |
| 78 | + return fmt.Errorf("failed to load config: %w", err) |
| 79 | + } |
| 80 | + |
| 81 | + apiKey := cfg.GetAPIKey() |
| 82 | + if apiKey == "" { |
| 83 | + return fmt.Errorf("API key not configured. Run 'hevycli config init' to set up") |
| 84 | + } |
| 85 | + |
| 86 | + client := api.NewClient(apiKey) |
| 87 | + |
| 88 | + // Determine output format |
| 89 | + outputFmt := cfg.Display.OutputFormat |
| 90 | + if cmd.Flags().Changed("output") { |
| 91 | + outputFmt, _ = cmd.Flags().GetString("output") |
| 92 | + } |
| 93 | + |
| 94 | + formatter := output.NewFormatter(output.Options{ |
| 95 | + Format: output.FormatType(outputFmt), |
| 96 | + NoColor: !cfg.Display.Color, |
| 97 | + Writer: os.Stdout, |
| 98 | + }) |
| 99 | + |
| 100 | + // Calculate date range |
| 101 | + now := time.Now() |
| 102 | + var startDate time.Time |
| 103 | + switch progressPeriod { |
| 104 | + case "week": |
| 105 | + startDate = now.AddDate(0, 0, -7) |
| 106 | + case "month": |
| 107 | + startDate = now.AddDate(0, -1, 0) |
| 108 | + case "year": |
| 109 | + startDate = now.AddDate(-1, 0, 0) |
| 110 | + case "all": |
| 111 | + startDate = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) |
| 112 | + default: |
| 113 | + return fmt.Errorf("invalid period: %s", progressPeriod) |
| 114 | + } |
| 115 | + |
| 116 | + // Fetch all workouts |
| 117 | + fmt.Fprintln(os.Stderr, "Fetching workout data...") |
| 118 | + allWorkouts, err := client.GetAllWorkouts() |
| 119 | + if err != nil { |
| 120 | + return fmt.Errorf("failed to fetch workouts: %w", err) |
| 121 | + } |
| 122 | + |
| 123 | + // Find matching exercises and compute metric |
| 124 | + progressData := computeProgress(allWorkouts, exerciseName, progressMetric, startDate, now) |
| 125 | + |
| 126 | + if len(progressData.DataPoints) == 0 { |
| 127 | + return fmt.Errorf("no data found for exercise '%s'", exerciseName) |
| 128 | + } |
| 129 | + |
| 130 | + // Format output |
| 131 | + if outputFmt == "json" { |
| 132 | + out, err := formatter.Format(progressData) |
| 133 | + if err != nil { |
| 134 | + return err |
| 135 | + } |
| 136 | + fmt.Println(out) |
| 137 | + } else { |
| 138 | + printProgressTable(progressData) |
| 139 | + } |
| 140 | + |
| 141 | + return nil |
| 142 | +} |
| 143 | + |
| 144 | +func computeProgress(workouts []api.Workout, exerciseName, metric string, start, end time.Time) ProgressData { |
| 145 | + var data ProgressData |
| 146 | + data.Metric = metric |
| 147 | + |
| 148 | + switch metric { |
| 149 | + case "1rm": |
| 150 | + data.Unit = "kg (estimated)" |
| 151 | + case "volume": |
| 152 | + data.Unit = "kg" |
| 153 | + case "reps": |
| 154 | + data.Unit = "reps" |
| 155 | + default: |
| 156 | + data.Unit = "kg" |
| 157 | + } |
| 158 | + |
| 159 | + // Collect data points by date |
| 160 | + dateValues := make(map[string]float64) |
| 161 | + var matchedExercise string |
| 162 | + |
| 163 | + for _, w := range workouts { |
| 164 | + if w.StartTime.Before(start) || w.StartTime.After(end) { |
| 165 | + continue |
| 166 | + } |
| 167 | + |
| 168 | + dateStr := w.StartTime.Format("2006-01-02") |
| 169 | + |
| 170 | + for _, ex := range w.Exercises { |
| 171 | + // Case-insensitive partial match |
| 172 | + if !strings.Contains(strings.ToLower(ex.Title), strings.ToLower(exerciseName)) { |
| 173 | + continue |
| 174 | + } |
| 175 | + |
| 176 | + if matchedExercise == "" { |
| 177 | + matchedExercise = ex.Title |
| 178 | + } |
| 179 | + |
| 180 | + var value float64 |
| 181 | + switch metric { |
| 182 | + case "weight": |
| 183 | + value = computeMaxWeight(ex.Sets) |
| 184 | + case "volume": |
| 185 | + value = computeVolume(ex.Sets) |
| 186 | + case "reps": |
| 187 | + value = computeMaxReps(ex.Sets) |
| 188 | + case "1rm": |
| 189 | + value = computeEstimated1RM(ex.Sets) |
| 190 | + } |
| 191 | + |
| 192 | + // Keep the best value for each date |
| 193 | + if value > dateValues[dateStr] { |
| 194 | + dateValues[dateStr] = value |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + data.Exercise = matchedExercise |
| 200 | + if data.Exercise == "" { |
| 201 | + data.Exercise = exerciseName |
| 202 | + } |
| 203 | + |
| 204 | + // Convert to sorted data points |
| 205 | + var dates []string |
| 206 | + for date := range dateValues { |
| 207 | + dates = append(dates, date) |
| 208 | + } |
| 209 | + sort.Strings(dates) |
| 210 | + |
| 211 | + for _, date := range dates { |
| 212 | + data.DataPoints = append(data.DataPoints, ProgressPoint{ |
| 213 | + Date: date, |
| 214 | + Value: math.Round(dateValues[date]*100) / 100, |
| 215 | + }) |
| 216 | + } |
| 217 | + |
| 218 | + // Compute analysis |
| 219 | + if len(data.DataPoints) >= 2 { |
| 220 | + first := data.DataPoints[0].Value |
| 221 | + last := data.DataPoints[len(data.DataPoints)-1].Value |
| 222 | + |
| 223 | + data.Analysis.StartingValue = first |
| 224 | + data.Analysis.CurrentValue = last |
| 225 | + data.Analysis.AbsoluteChange = math.Round((last-first)*100) / 100 |
| 226 | + |
| 227 | + if first > 0 { |
| 228 | + data.Analysis.PercentChange = math.Round((last-first)/first*10000) / 100 |
| 229 | + } |
| 230 | + |
| 231 | + if last > first { |
| 232 | + data.Analysis.Trend = "increasing" |
| 233 | + } else if last < first { |
| 234 | + data.Analysis.Trend = "decreasing" |
| 235 | + } else { |
| 236 | + data.Analysis.Trend = "stable" |
| 237 | + } |
| 238 | + } else if len(data.DataPoints) == 1 { |
| 239 | + data.Analysis.StartingValue = data.DataPoints[0].Value |
| 240 | + data.Analysis.CurrentValue = data.DataPoints[0].Value |
| 241 | + data.Analysis.Trend = "insufficient_data" |
| 242 | + } |
| 243 | + |
| 244 | + return data |
| 245 | +} |
| 246 | + |
| 247 | +func computeMaxWeight(sets []api.Set) float64 { |
| 248 | + var maxWeight float64 |
| 249 | + for _, set := range sets { |
| 250 | + if set.WeightKg != nil && *set.WeightKg > maxWeight { |
| 251 | + maxWeight = *set.WeightKg |
| 252 | + } |
| 253 | + } |
| 254 | + return maxWeight |
| 255 | +} |
| 256 | + |
| 257 | +func computeVolume(sets []api.Set) float64 { |
| 258 | + var volume float64 |
| 259 | + for _, set := range sets { |
| 260 | + if set.WeightKg != nil && set.Reps != nil { |
| 261 | + volume += *set.WeightKg * float64(*set.Reps) |
| 262 | + } |
| 263 | + } |
| 264 | + return volume |
| 265 | +} |
| 266 | + |
| 267 | +func computeMaxReps(sets []api.Set) float64 { |
| 268 | + var maxReps int |
| 269 | + for _, set := range sets { |
| 270 | + if set.Reps != nil && *set.Reps > maxReps { |
| 271 | + maxReps = *set.Reps |
| 272 | + } |
| 273 | + } |
| 274 | + return float64(maxReps) |
| 275 | +} |
| 276 | + |
| 277 | +// computeEstimated1RM uses the Brzycki formula: 1RM = weight × (36 / (37 - reps)) |
| 278 | +func computeEstimated1RM(sets []api.Set) float64 { |
| 279 | + var max1RM float64 |
| 280 | + for _, set := range sets { |
| 281 | + if set.WeightKg == nil || set.Reps == nil || *set.Reps == 0 { |
| 282 | + continue |
| 283 | + } |
| 284 | + weight := *set.WeightKg |
| 285 | + reps := *set.Reps |
| 286 | + |
| 287 | + // Brzycki formula works best for reps <= 10 |
| 288 | + if reps > 10 { |
| 289 | + continue |
| 290 | + } |
| 291 | + |
| 292 | + estimated := weight * (36.0 / (37.0 - float64(reps))) |
| 293 | + if estimated > max1RM { |
| 294 | + max1RM = estimated |
| 295 | + } |
| 296 | + } |
| 297 | + return max1RM |
| 298 | +} |
| 299 | + |
| 300 | +func printProgressTable(data ProgressData) { |
| 301 | + fmt.Printf("\n📈 Progress: %s\n", data.Exercise) |
| 302 | + fmt.Printf(" Metric: %s (%s)\n\n", data.Metric, data.Unit) |
| 303 | + |
| 304 | + // Show data points |
| 305 | + fmt.Println(" Date Value") |
| 306 | + fmt.Println(" ──────────────────────") |
| 307 | + for _, dp := range data.DataPoints { |
| 308 | + fmt.Printf(" %s %.1f\n", dp.Date, dp.Value) |
| 309 | + } |
| 310 | + |
| 311 | + // Show analysis |
| 312 | + if data.Analysis.Trend != "" && data.Analysis.Trend != "insufficient_data" { |
| 313 | + fmt.Println("\n 📊 Analysis") |
| 314 | + fmt.Printf(" Starting: %.1f %s\n", data.Analysis.StartingValue, data.Unit) |
| 315 | + fmt.Printf(" Current: %.1f %s\n", data.Analysis.CurrentValue, data.Unit) |
| 316 | + |
| 317 | + changeSign := "" |
| 318 | + if data.Analysis.AbsoluteChange > 0 { |
| 319 | + changeSign = "+" |
| 320 | + } |
| 321 | + fmt.Printf(" Change: %s%.1f (%s%.1f%%)\n", |
| 322 | + changeSign, data.Analysis.AbsoluteChange, |
| 323 | + changeSign, data.Analysis.PercentChange) |
| 324 | + |
| 325 | + trendEmoji := "➡️" |
| 326 | + switch data.Analysis.Trend { |
| 327 | + case "increasing": |
| 328 | + trendEmoji = "📈" |
| 329 | + case "decreasing": |
| 330 | + trendEmoji = "📉" |
| 331 | + } |
| 332 | + fmt.Printf(" Trend: %s %s\n", trendEmoji, data.Analysis.Trend) |
| 333 | + } |
| 334 | + fmt.Println() |
| 335 | +} |
0 commit comments