Skip to content

Commit 4aae0f4

Browse files
obayclaude
andcommitted
feat: add Phase 3 analytics commands
Implement workout analytics and statistics: Stats Summary (hevycli stats summary): - Total workouts, duration, and volume - Unique exercises and total sets - Most frequent exercises - Workout consistency (workouts/week, streaks) - Period filters: week, month, year, all Stats Progress (hevycli stats progress <exercise>): - Track exercise progress over time - Metrics: weight, volume, reps, estimated 1RM - Brzycki formula for 1RM estimation - Trend analysis (increasing/decreasing/stable) - Period filters for date ranges Stats Records (hevycli stats records): - Personal records across all exercises - Max weight and estimated 1RM tracking - Filter by specific exercise - Sorted by value with configurable limit API Client: - Add GetAllWorkouts() helper for fetching all workouts with pagination 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0d13176 commit 4aae0f4

6 files changed

Lines changed: 1014 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/obay/hevycli/cmd/exercise"
1212
"github.com/obay/hevycli/cmd/folder"
1313
"github.com/obay/hevycli/cmd/routine"
14+
"github.com/obay/hevycli/cmd/stats"
1415
"github.com/obay/hevycli/cmd/workout"
1516
internalConfig "github.com/obay/hevycli/internal/config"
1617
"github.com/obay/hevycli/internal/output"
@@ -88,6 +89,7 @@ func init() {
8889
rootCmd.AddCommand(routine.Cmd)
8990
rootCmd.AddCommand(exercise.Cmd)
9091
rootCmd.AddCommand(folder.Cmd)
92+
rootCmd.AddCommand(stats.Cmd)
9193
rootCmd.AddCommand(completion.Cmd)
9294
rootCmd.AddCommand(versionCmd)
9395
}

cmd/stats/progress.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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

Comments
 (0)